AMLO — ตารางข้อมูล สถานะ และการลงข้อมูลตามสถานการณ์
ทุกตาราง ทุกค่าสถานะ และลำดับการเขียนข้อมูลของแต่ละสถานการณ์ พร้อม query ตรวจว่าไม่ตกหล่น — เขียนจากฐานข้อมูล dev จริง
อัปเดต: 2026-09-01
คู่กับ สถาปัตยกรรมและ Flow · หน้านี้ตอบว่า “สถานการณ์นี้ข้อมูลลงตารางไหนบ้าง และสถานะเปลี่ยนเป็นอะไร” schema ดึงจาก
information_schemaของ dev จริงเมื่อ 01/09/2026
1. ตารางทั้งหมด 11 ตาราง
ฝั่ง Centralized (DEV_CentralizedDb schema los)
| ตาราง | หนึ่งแถวคือ | เขียนโดย |
|---|---|---|
amlo_ingestion_batch | หนึ่งไฟล์ที่รับเข้ามา | ingest |
amlo_ingestion_reject | หนึ่งบรรทัดที่ parse ไม่ผ่าน | ingest |
amlo_screening_match | หนึ่งบรรทัดที่จับคู่ได้ (ดิบ) | ingest |
amlo_screening_snapshot_header | หนึ่ง generation | generation builder |
amlo_screening_snapshot_detail | บรรทัดที่ถูกแช่แข็งเข้า generation | generation builder |
amlo_screened_company | สรุประดับบริษัทของ generation | generation builder |
ฝั่ง UserService (AuthDb schema public)
| ตาราง | หนึ่งแถวคือ | เขียนโดย |
|---|---|---|
Companies (คอลัมน์ Amlo*) | สถานะ AMLO ล่าสุดของบริษัท | reconcile job |
AmloUserUnblocks | สิทธิ์ปลดล็อกของ (คน, บริษัท) | admin unblock + reconcile job |
AmloAuditLogs | เหตุการณ์หนึ่งครั้ง (append-only) | ทั้งสองทาง |
UserCompanyMappings | ความเป็นสมาชิกบริษัท | ระบบ onboarding (ไม่ใช่ AMLO) |
Users | ผู้ใช้ | ระบบ user (ไม่ใช่ AMLO) |
2. สถานะทั้งหมด — อย่าจำผิดว่ามีแค่ Flagged
2.1 Companies.AmloStatus — ระดับบริษัท มี 4 ค่า
| ค่า | ความหมาย | ใครเขียน |
|---|---|---|
Flagged | ติด AMLO | reconcile job |
Clear | ตรวจแล้วไม่ติด | reconcile job |
NotScreenable | มีใน enum แต่ ไม่มีโค้ดไหนเขียนค่านี้เลย (ApplyAmloStatusAsync เขียนได้แค่ Flagged/Clear) | – |
null | ยังไม่เคยตรวจ · บริษัทที่ไม่มี CustCode ถูกคัดออกตั้งแต่ query จึงค้างที่ค่านี้ ไม่ใช่ NotScreenable | ค่าเริ่มต้น |
2.2 effective status — ระดับ (คน, บริษัท) มี 4 ค่า
คำนวณจาก GetEffectiveStatus(rawStatus, hasActiveGrant) ไม่ได้เก็บในตาราง
| raw | มีสิทธิ์ Active | effective | ผลจาก guard |
|---|---|---|---|
Flagged | ❌ | Flagged | 403 COMPANY_AMLO_BLOCKED |
Flagged | ✅ | Clear | ผ่าน |
Clear | – | Clear | ผ่าน |
NotScreenable | ✅ หรือ ❌ | NotScreenable | 403 COMPANY_AMLO_NOT_SCREENABLE |
null | ✅ หรือ ❌ | Unknown | 403 COMPANY_AMLO_STATUS_UNKNOWN |
🔴 สิทธิ์ปลดล็อก override ได้เฉพาะ Flagged เท่านั้น — NotScreenable และ null ปลดไม่ได้ ต่อให้ให้สิทธิ์ไปแล้วก็ยังโดน 403
🔴 null ก็โดน 403 — บริษัทที่ยังไม่เคยตรวจถูกบล็อกด้วย และไม่โผล่ในหน้า Compliance Cases (หน้านั้นแสดงเฉพาะ Flagged) ⇒ ปลดให้ไม่ได้ผ่านหน้าจอ
snapshot dev 01/09/2026 (ค่าจะเปลี่ยน ให้ query ใหม่ด้วย §5.6):
null= 61 บริษัท ·Flagged= 4 ·NotScreenable= 1 (จาก 66) ⇒ ถ้าเปิด enforcement ตอนนั้นคนเกือบทั้งระบบจะโดน
2.3 AmloUserUnblocks.Status — 3 ค่าใน enum (เขียนจริง 2)
🔴 ค่าใน DB ไม่ใช่ Revoked — enum เก็บเป็น string ตามชื่อ member
| ค่าในคอลัมน์ DB | ความหมาย |
|---|---|
Active | สิทธิ์ใช้ได้อยู่ |
RevokedBySync | job เพิกถอนเพราะหลักฐานเปลี่ยน |
RevokedByAdmin | มีใน enum แต่ ยังไม่มีโค้ดไหนเขียน |
⚠️ "Revoked" เป็นค่าที่ API collapse ให้ตอนตอบ เท่านั้น (GetAmloCompanyMembersHandler) — เขียน WHERE "Status" = 'Revoked' ใน DB จะได้ 0 แถวเสมอ แล้วสรุปผิดว่าไม่เคยมีการเพิกถอน
มี partial unique index:
UNIQUE ("CompanyId","UserId") WHERE "Status" = 'Active'
⇒ หนึ่งคนมีสิทธิ์ Active ได้ใบเดียวต่อบริษัท แต่มีประวัติที่ถูกเพิกถอนได้หลายใบ
⇒ หา “สถานะปัจจุบัน” ต้องหยิบแถวล่าสุดตาม CreatedAt ไม่ใช่แถวไหนก็ได้
2.4 AmloAuditLogs.Action
| ค่า | เกิดเมื่อ | มี TargetUserId |
|---|---|---|
UserUnblockGranted | admin ปลดล็อกให้คนหนึ่ง | ✅ |
RevokedBySync | job เพิกถอนเพราะหลักฐานเปลี่ยน | ✅ |
CompanyBlocked / AutoUnblocked / EvidenceRotated | เหตุการณ์ระดับบริษัท | ❌ |
GateHeld | มีใน enum แต่ ยังไม่มีโค้ดไหนเขียน | ❌ |
หน้าประวัติแสดงเฉพาะ 2 แบบแรก (ที่มี target user)
2.5 snapshot_header.GateResult
| ค่า | ความหมาย | reconcile ทำอะไร |
|---|---|---|
Passed | ด่านตรวจผ่าน | บล็อก + Clear sweep |
Held | ด่านตรวจไม่ให้ใช้ปลดล็อก (มี 6 เหตุ เช่น generation แรก หรือ FileSemantics=Delta) | บล็อกอย่างเดียว ไม่ปลดใคร |
Failed | รอบนั้น build ไม่สมบูรณ์ (stale run) | ข้ามทั้งรอบเงียบ ๆ |
null | ยังไม่ได้ตัดสิน | ข้ามทั้งรอบเช่นกัน |
⚠️ reconcile รับเฉพาะ Passed/Held — เจอค่าอื่นไม่ทำอะไรเลยและไม่ error ⇒ ดูแค่ “job รันแล้ว” ไม่พอ ต้องดู GateResult ด้วย
2.6 amlo_ingestion_batch.Status
มี 5 ค่า ไม่ใช่ 3
| ค่า | terminal? | หมายเหตุ |
|---|---|---|
Processing | – | กำลังทำ |
Completed | ✅ | เข้า generation ได้ |
CompletedWithErrors | ✅ | เข้า generation ได้เท่ากับ Completed — อย่าลืมใส่ในเงื่อนไข query |
Failed | ❌ | ไม่ terminal — ถูก retry ทุกรอบ ไม่ใช่จบแล้ว |
Rejected | ✅ | ชื่อไฟล์ผิดรูป ต้องแก้ไฟล์บน SFTP ก่อน |
⚠️ Completed ไม่ได้แปลว่าถูกนำไปสร้าง generation แล้ว — ดู GenerationBuiltAtUtc ต่างหาก
3. การลงข้อมูลตามสถานการณ์
สถานการณ์ A — ไฟล์ใหม่เข้ามา
| ลำดับ | ตาราง | ลงอะไร |
|---|---|---|
| 1 | amlo_ingestion_batch | 1 แถว Processing → Completed |
| 2 | amlo_screening_match | n แถว (บรรทัดที่จับคู่ได้) |
| 3 | amlo_ingestion_reject | m แถว (บรรทัดที่ parse ไม่ผ่าน) |
| 4 | snapshot_header | 1 แถว IsCurrent=true · แถวเดิมถูกปิด |
| 5 | snapshot_detail | สำเนาแช่แข็งของ match รอบนี้ |
| 6 | amlo_screened_company | 1 แถวต่อบริษัท พร้อม EvidenceFingerprint |
| 7 | amlo_ingestion_batch | update GenerationBuiltAtUtc |
🔴 ขั้น 4–7 จะไม่เกิดถ้า processed = 0 (ไม่มีไฟล์ใหม่) เพราะ AmloIngestionRunService return ก่อนเรียก builder
✅ แต่ builder มีกลไกกู้ batch ที่ค้างในตัวเอง — ResolveBatchIdsAsync union ทุก batch ที่ GenerationBuiltAtUtc IS NULL เข้ามาด้วยทุกครั้งที่ถูกเรียก
⇒ วางไฟล์ใหม่ 1 ไฟล์ = ลาก batch เก่าที่ค้างทั้งหมดเข้า generation ให้เอง · 🔴 ห้าม insert snapshot_header/screened_company ด้วยมือ
สถานการณ์ B — reconcile job รันทุกชั่วโมง
| กรณี | Companies | AmloUserUnblocks | AmloAuditLogs | 3C |
|---|---|---|---|---|
| อยู่ในรอบ ไม่เคยติดมาก่อน | เขียน 6 คอลัมน์ Flagged | อาจมี grant เก่าค้างที่ถูก revoke ในรอบเดียวกัน | CompanyBlocked | ส่งให้สมาชิกที่สถานะเปลี่ยน |
| อยู่ในรอบ ติดอยู่แล้ว fingerprint เท่าเดิม | เขียนทับ 6 คอลัมน์ทุกรอบ (AmloAsOfUtc/AmloGenerationNo เปลี่ยน) | ไม่แตะ | ไม่มี | ไม่ส่ง |
| อยู่ในรอบ fingerprint ต่าง + version ตรง | เขียนทับ fingerprint ใหม่ | เฉพาะใบที่ version ตรง → RevokedBySync | RevokedBySync ต่อคน + EvidenceRotated ระดับบริษัท | ส่งให้คนที่ถูกเพิกถอน |
| อยู่ในรอบ fingerprint ต่าง แต่ version ไม่ตรง | เขียนทับ | ไม่แตะเลย — grant รอดทุกใบ | ไม่มี | ไม่ส่ง |
หายจากรอบ (Snapshot) | AmloStatus=Clear · AmloEvidenceFingerprint=null | ไม่แตะเลย — grant ค้างต่อไป | AutoUnblocked | ส่ง |
หายจากรอบ (Delta) | ไม่แตะเลย | ไม่แตะ | ไม่มี | ไม่ส่ง |
🔴 แถวที่ 4 กับ 5 คือช่องที่ต้องระวัง — grant ที่รอดจาก version gate บวกกับ grant ที่ค้างบนบริษัทที่ถูก Clear แล้ว ถ้าบริษัทกลับมา Flagged อีกครั้งด้วย version คนละตัว คนนั้นได้ effective Clear ทั้งที่ไม่มีใครอนุมัติรอบใหม่
⚠️ AmloAsOfUtc ถูกเขียนทับทุกรอบ ⇒ ไม่ใช่ “วันที่ติดครั้งแรก” แต่หน้า Cases เรียงด้วยค่านี้
⚠️ แถวสุดท้ายคือค่าที่ใช้อยู่จริงตอนนี้ — บริษัทที่หลุดจากไฟล์จะติดค้างตลอดไปจนกว่าจะมีคนปลดด้วยมือ
สถานการณ์ C — admin ปลดล็อกให้ 1 คน
ทั้งหมดอยู่ใน transaction เดียว ล็อกแถว Companies ด้วย FOR UPDATE
| ลำดับ | ตาราง | ลงอะไร |
|---|---|---|
| 1 | AmloUserUnblocks | 1 แถวใหม่ Status=Active + GrantedAgainstFingerprint/GenerationId |
| 2 | AmloAuditLogs | UserUnblockGranted + Reason + EvidenceFileRefs (jsonb array ของ fileId) |
| 3 | Service Bus | AmloStateChanged ให้ผู้ใช้คนนั้น |
ไม่แตะ Companies.AmloStatus เลย — บริษัทยังเป็น Flagged ตลอด เปลี่ยนแค่ effective status ของคนคนเดียว
สถานการณ์ D — ผู้ใช้เรียก API ที่มี guard
ไม่เขียนอะไรเลย อ่านอย่างเดียว: Companies.AmloStatus + มี AmloUserUnblocks ที่ Active ไหม → คำนวณ effective → ผ่านหรือ 403
4. ไฟล์หลักฐาน
เก็บที่ FileManagementService หมวด amlo-evidence · AmloAuditLogs.EvidenceFileRefs เก็บแค่ array ของ fileId ไม่ได้เก็บชื่อไฟล์
ชนิดที่รับ (ตั้งแต่ 01/09): PDF · JPEG · PNG เท่านั้น — เพื่อให้ preview ผ่านเบราว์เซอร์ได้ทุกไฟล์ (เดิมรับ Office ด้วย)
สิทธิ์เข้าถึง: RequiredRoles = Global Admin / AdminMaker / AdminApprover ⇒ admin ที่ไม่ใช่ผู้อัปโหลดก็โหลดได้ (เดิมเป็น owner-only ได้ 403)
การตรวจไฟล์ — client 8 กฎ · server 10 กฎ (server มี IsDangerousExtension + แยก null-byte เป็นข้อต่างหาก)
ชนิดไฟล์ · นามสกุล · นามสกุลตรงกับชนิด · ชื่อไฟล์ห้ามมีจุดเกินหนึ่งจุด · ห้ามอักขระ path · ยาว ≤255 · ขนาด 1 byte–10 MB · ลายเซ็นไบต์แรกตรงกับชนิด
client เป็นแค่ UX ให้รู้เร็ว — server ยังเป็นด่านจริงเสมอ
⚠️ กฎ “จุดเกินหนึ่งจุด” ทำให้ รายงาน ก.ค.2569.pdf ไม่ผ่าน — เป็นกฎเดิมของ FileUploadSecurityHelper ที่ทุก category ใช้ร่วมกัน
5. Query ตรวจว่าไม่ตกหล่น
5.1 [Centralized] batch ที่ ingest แล้วแต่ยังไม่ได้สร้าง generation 🔴
ตัวสำคัญที่สุด — ตรงนี้คือช่องที่ทำให้ข้อมูลค้างเงียบ ๆ
SELECT "FileName", "Status", "RowCountSuccess", "CreatedAt"
FROM los.amlo_ingestion_batch
WHERE "Status" IN ('Completed', 'CompletedWithErrors')
AND "GenerationBuiltAtUtc" IS NULL;
⚠️ ต้องมี CompletedWithErrors ด้วย — โค้ดถือว่ามีสิทธิ์เข้า generation เท่ากับ Completed (predicate เดียวกับ GetUnbuiltTerminalBatchIdsAsync)
ควรได้ 0 แถว · มีแถว = ยังไม่เคยกลายเป็น generation · วางไฟล์ใหม่ 1 ไฟล์ ระบบจะเก็บให้เองทั้งหมด ไม่ต้องแก้ด้วยมือ
5.2 [Centralized] generation ปัจจุบันมาจากของจริงหรือคนใส่มือ
SELECT "GenerationNo", "GateResult", "IsCurrent", "CreatedBy", "SourceFileNames",
"RowCountTotal", "DistinctCustCodeCount"
FROM los.amlo_screening_snapshot_header WHERE "IsCurrent" = true;
SELECT count(*) AS detail_rows FROM los.amlo_screening_snapshot_detail;
CreatedBy ต้องเป็นของระบบ ไม่ใช่ manual:* · detail_rows ต้องมากกว่า 0
บน dev ตอนนี้ generation #1 มี
CreatedBy = manual:amlo-simulate-29-08และ detail = 0 แถว ⇒ เป็นข้อมูลที่คนใส่มือ ไม่ใช่ผลจากไฟล์
5.3 [ข้ามระบบ] สองฝั่งตรงกันไหม
รันบน Centralized แล้วเทียบกับผลจาก UserService ด้วยมือ (คนละฐาน join ไม่ได้)
-- [Centralized] บริษัทที่รอบปัจจุบันบอกว่าติด
SELECT sc."CustCodeNormalized"
FROM los.amlo_screened_company sc
JOIN los.amlo_screening_snapshot_header h ON h."Id" = sc."SnapshotHeaderId"
WHERE h."IsCurrent" = true ORDER BY 1;
-- [UserService] บริษัทที่ระบบบล็อกอยู่จริง
SELECT "CustCode" FROM "Companies"
WHERE "AmloStatus" = 'Flagged' AND "IsDeleted" = false ORDER BY 1;
| ผลต่าง | แปลว่า |
|---|---|
| มีใน Centralized ไม่มีใน UserService | reconcile ยังไม่รัน · CustCode ไม่ match · หรือไม่มีบริษัทนั้นในระบบ |
| มีใน UserService ไม่มีใน Centralized | ปกติเมื่อ FileSemantics=Delta (ไม่มี Clear sweep) |
5.4 [UserService] สิทธิ์ที่ควรถูกเพิกถอนแต่ยังค้าง
SELECT c."CustCode", u."Email",
g."GrantedAgainstFingerprint" AS granted_against,
c."AmloEvidenceFingerprint" AS current_fingerprint
FROM "AmloUserUnblocks" g
JOIN "Companies" c ON c."Id" = g."CompanyId"
JOIN "Users" u ON u."Id" = g."UserId"
WHERE g."Status" = 'Active'
AND c."AmloStatus" = 'Flagged'
AND c."AmloFingerprintVersion" = g."GrantedAgainstFingerprintVersion"
AND c."AmloEvidenceFingerprint" IS DISTINCT FROM g."GrantedAgainstFingerprint";
🔴 2 เงื่อนไขที่เพิ่มมาจำเป็น ไม่ใช่ของแถม — ถ้าไม่ใส่จะได้ false positive 2 ทาง:
- บริษัทที่ถูก
Clearแล้วมีAmloEvidenceFingerprint = nullแต่ grant ยังถือค่าเดิม ⇒ ขึ้นเป็น “ค้าง” ทั้งที่ไม่ใช่ defect - grant ที่
FingerprintVersionไม่ตรงถูกตั้งใจปล่อยไว้ (ดู §3 สถานการณ์ B แถว 4)
ควรได้ 0 แถว · มีแถว = reconcile job ยังไม่ทำงานหรือทำงานไม่ครบ
5.5 [UserService] สิทธิ์ Active ซ้ำ
SELECT "CompanyId", "UserId", count(*)
FROM "AmloUserUnblocks" WHERE "Status" = 'Active'
GROUP BY 1,2 HAVING count(*) > 1;
ควรได้ 0 แถว (partial unique index กันอยู่แล้ว — มีแถว = index หาย)
5.6 [UserService] แจกแจงสถานะแบบไม่ตกหล่น
SELECT COALESCE(NULLIF("AmloStatus", ''), '(ยังไม่เคยตรวจ)') AS status, count(*)
FROM "Companies" WHERE "IsDeleted" = false GROUP BY 1 ORDER BY 2 DESC;
ใช้ GROUP BY แทนการไล่ค่าทีละตัว — ถ้าวันหนึ่งมีค่าใหม่เพิ่มจะได้ไม่นับตกหล่น
5.7 [UserService] ใครถูกบล็อกจริง ณ ตอนนี้
SELECT u."Email", c."CustCode", c."NameTH"
FROM "Companies" c
JOIN "UserCompanyMappings" m ON m."CompanyId" = c."Id" AND m."IsStatus" = 'Active'
JOIN "Users" u ON u."Id" = m."UserId" AND u."IsDeleted" = false
WHERE c."AmloStatus" = 'Flagged' AND c."IsDeleted" = false
AND NOT EXISTS (SELECT 1 FROM "AmloUserUnblocks" g
WHERE g."CompanyId" = c."Id" AND g."UserId" = u."Id" AND g."Status" = 'Active')
ORDER BY c."CustCode", u."Email";
⚠️ IsStatus ใน DB เป็น character varying ค่า Active/Inactive (ยืนยันด้วย information_schema + ข้อมูลจริงบน dev) · ต้องกรองด้วย เพราะ guard เช็คความเป็นสมาชิกก่อนแตะ AMLO — คนที่ mapping ไม่ Active ได้ COMPANY_NOT_MEMBER ไม่ใช่ AMLO block
6. กฎการเขียน query กับตารางเหล่านี้
AmloAuditLogs ไม่มี foreign key เลยโดยตั้งใจ — เป็น append-only ที่เก็บค่า denormalize ไว้ในตัว (CompanyCustCode, ActorEmail, SnapshotGenerationNo, SnapshotFileNames) เพื่อให้อ่านย้อนหลังได้แม้ข้อมูลต้นทางถูกแก้หรือลบ
⇒ LEFT JOIN เสมอ ห้าม INNER JOIN ไม่งั้นแถวที่ user ถูกลบไปแล้วจะหายจากรายงาน
EvidenceFileRefs เป็น jsonb ที่อาจเป็น null — แถวจาก background job ไม่เคยเขียน field นี้ · deserialize ต้องรองรับ null และข้อมูลผิดรูปโดยไม่ throw
หา grant ปัจจุบันต้อง DISTINCT ON ... ORDER BY CreatedAt DESC — ไม่ใช่หยิบแถวไหนก็ได้ เพราะหนึ่งคนมีหลายแถวได้
7. เช็คลิสต์ก่อน deploy env ใหม่
| # | อย่าง | ทำไม |
|---|---|---|
| 1 | apply migration 8 ตัว 2 ฐาน — UserService (AuthDb) 4 ตัว และ Centralized (DEV_CentralizedDb) อีก 4 ตัว | ไม่มี auto-migrate ทั้งสอง repo · image ที่มีโค้ดแต่ DB ไม่มีคอลัมน์ = ทั้ง service ตาย ไม่ใช่แค่ AMLO |
| 2 | เพิ่ม menu row /amlo, /amlo/operations, /amlo/cases, /amlo/history + ผูก role | เมนูมาจาก DB ไม่ใช่โค้ด · ไม่ทำ = เปิดหน้าไม่ได้ |
| 3 | เพิ่ม APIM operation ของ endpoint ใหม่ (env ที่ไม่ใช้ wildcard) | ไม่มี = หน้าโหลดไม่ขึ้นทั้งที่ backend พร้อม · PUT ทีละ operation ห้าม import ทับ |
| 4 | ตรวจ AmloEnforcement.Enabled ให้ตรงกับที่ตั้งใจ | ค่ามาจาก IaC overlay ตอน build ไม่ใช่ env var |
| 5 | ฝั่ง Centralized: เปิด AmloIngestion.Enabled + ใส่ SFTP secret 3 ตัวใน KV | IaC ตอนนี้ dev = true · sit/uat = false · prod ยังไม่มี config dir เลย |
| 6 | รัน query §5.1 หลัง deploy | จับ batch ที่ค้างไม่ได้สร้าง generation |