Private Docs

AMLO Screening — Architecture

สถาปัตยกรรมการ block ผู้ใช้ที่สังกัดบริษัทติด AMLO — สถานะโค้ดปัจจุบันที่ verify แล้ว, นิยาม flagged แบบ sticky, จุดบังคับใช้ฝั่ง server (session layer), การ fan-out แบบ reconcile-by-pull, สัญญา API, data model, config และกฎแข็ง

อัปเดต: 2026-08-18

หน้านี้อธิบายว่าระบบจะเป็นยังไง — แนวคิด, จุดบังคับใช้, ข้อมูล, สัญญา API, config ทุก scenario พร้อม Sequence Diagram อยู่ที่ Scenario + Sequence Diagram · เรื่องที่ยังต้องตัดสินใจและงานที่ต้องปิดก่อน go-live อยู่ที่ Open Items

ฐานหลักฐาน: ทุก fact ในหน้านี้ตรวจกับโค้ดจริงบน origin/development ของแต่ละ repo (fetch 18/08/2026) ไม่ใช่ working tree ในเครื่อง — สำคัญเพราะ checkout หลายตัวตามหลัง origin หลายสิบ commit และ fact ที่ต่างกันมีผลต่อ design จริง (ดูตัวอย่างที่ §9)


1. โจทย์

AMLO screening = การตรวจว่าบริษัทลูกค้าติด watchlist ฟอกเงินหรือไม่ ระบบได้ไฟล์รายวันจากต้นทางแล้ว ingest เข้ามาเก็บไว้ที่ centralized-service

สิ่งที่ต้องได้:

  1. ผู้ใช้ที่ บริษัทที่ active อยู่ ติด AMLO ต้อง ทำกระบวนการใด ๆ ไม่ได้
  2. ผู้ใช้ที่ค้าง session อยู่ต้องรู้ทันทีผ่าน SignalR → modal
  3. sync มือได้ ไม่ต้องรอรอบ
  4. ผู้ใช้ที่ login ทีหลังต้องรู้ตอน bootstrap ผ่านการเรียก API เส้นเดียว

🔴 หลักการที่ทั้งเอกสารนี้ยืนอยู่บน: modal คือ UX ไม่ใช่การ block ผู้ใช้ที่ไม่ได้รับ push (offline, ปิด browser, ยิง API ตรง, remote app เรียก service อื่น) ต้องถูกปฏิเสธที่ฝั่ง server อยู่ดี ถ้าออกแบบให้ modal เป็นตัวกั้น = ควบคุมด้าน compliance ล้มเหลวโดยไม่มีใครรู้


2. สถานะโค้ดปัจจุบัน (verify แล้ว — ห้ามเดาเกินนี้)

2.1 Centralized — เจ้าของข้อมูล match

เรื่องความจริงหลักฐาน
endpoint อ่านผลGET api/centralized-service/v{v}/amlo-screening/{custCode}AmloScreeningMatchResponse(Id, CustCode, SearchMethod, Similarity, FlagSystem, AmloType, LogDatetimeUtc, CreatedAt); ไม่พบ → 404Controllers/v1/AmloScreeningController.cs:15-26, Features/AmloScreening/DTOs/AmloScreeningMatchResponse.cs:6-24
ความหมายของ id ใน URL ที่ Owner ส่งมา (.../amlo-screening/6329225)คือ CustCode ไม่ใช่ juristicId หรือ internal idเหมือนข้างบน
query ที่อยู่เบื้องหลังlatest match ต่อ CustCode — OrderByDescending(LogDatetimeUtc).ThenByDescending(CreatedAt).ThenByDescending(Id)Repositories/Amlo/AmloScreeningMatchRepository.cs:30-42
🔴 auth ของ endpoint อ่านไม่มี[RequireApiKey] ไม่ได้ติด และ [Authorize] ก็ไม่มี (ไฟล์ import SupApp_util_lib.Abstractions.Security ไว้แต่ไม่ได้ใช้)AmloScreeningController.cs:1-26
auth ของ endpoint สั่ง ingestมี [RequireApiKey] แล้วAdminAmloIngestionController.cs:19
⚠️ กลไก API keyApiKeyAuthFilter register แบบ global ก็จริง แต่ เป็น opt-in — ไม่มี [RequireApiKey] = ปล่อยผ่านทันทีBackend_Package/src/Filters/ApiKeyAuthFilter.cs:33-41
ingestionSFTP → BackgroundService รายวัน ตาม AmloIngestion:DailyRunHourBangkok default 23 (ไม่ใช่ตี 5) กันซ้อนด้วย leader-lock Redis amlo-ingest:leader TTL 30 นาทีBackgroundJobs/AmloScreeningIngestionBackgroundService.cs, BackgroundJobs/AmloIngestionRunService.cs:21,45-50, Configuration/AmloIngestionSettings.cs:26
manual triggerPOST .../v1/admin/amlo-ingestion/run → 200 AmloIngestionRunSummary(Total, Processed, Skipped, Failed); 409 เฉพาะตอน Enabled=falseAdminAmloIngestionController.cs:26-38
🔴 manual trigger ตอนชน lockคืน 200 (0,0,0,0) เงียบ ๆ ซึ่งแยกไม่ออกจากกรณี “ไม่มีไฟล์ใหม่”AmloIngestionRunService.cs:45-50,113
🔴 ingestion ไม่ publish event ใดเลยgrep Publish/Outbox/IMediator ใน feature = 0 ทั้งที่ repo มี IAuditEventPublisher/ISignalPublisher/outbox ใช้ที่อื่นCentralized02.Infrastructure/DependencyInjection.cs:252-265

2.2 Data model ปัจจุบัน (schema los)

erDiagram
    amlo_ingestion_batch ||--o{ amlo_screening_match : "BatchId"
    amlo_ingestion_batch ||--o{ amlo_ingestion_reject : "BatchId"

    amlo_ingestion_batch {
        uuid Id PK
        varchar FileName UK "unique = idempotency key"
        date StampDate "จากชื่อไฟล์"
        int RowCount
        int RowCountSuccess
        varchar Status "Processing/Completed/CompletedWithErrors/Failed"
        timestamptz ProcessedAt
        text ErrorMessage
    }
    amlo_screening_match {
        uuid Id PK
        uuid BatchId FK
        varchar CustCode "คีย์ที่ผูกกับบริษัทเรา"
        varchar SearchMethod
        numeric Similarity "fuzzy score"
        uuid RecordGuid
        varchar AmloType
        timestamptz LogDatetimeUtc
    }
    amlo_ingestion_reject {
        uuid Id PK
        uuid BatchId FK
        int LineNumber
        text RawLine
        varchar ErrorReason
    }

🔴 สองข้อที่กำหนดรูปร่างของ design ทั้งหมด:

  1. ไม่มี column ที่บอกว่า “ติด/ไม่ติด” — flagged = มี match row อยู่เท่านั้น
  2. ไม่มี state “cleared” และ match เก่าไม่เคยถูกลบข้าม batch (ลบเฉพาะ BatchId ของตัวเองตอน retry) → latest-match query จะคืน match เก่าตลอดไป แม้บริษัทหลุด watchlist ไปแล้ว — AmloIngestionOrchestrator.cs:154-157, AmloScreeningMatchRepository.cs:22-28

2.3 UserService — เจ้าของความสัมพันธ์ user ↔ company

  • membership = UserCompanyMapping(UserId, CompanyId, IsStatus, IsDefault) · บริษัทที่ active = IsDefault=true
  • สลับบริษัท: PATCH /api/user-service/v1/companies/default body {companyId}SetDefaultCompanyHandler เรียก ICompanyAccessGuard.ValidateAccessAsync ก่อนเสมอ · มี DELETE สำหรับล้าง default
  • GET /v1/users/me คืน Companies[] = (Id, NameTH, NameEN, IsDefault, Type, JuristicId, ProfileImageURL, Role, CustCode)มี CustCode ต่อบริษัทอยู่แล้ว
  • GET /v1/companies/cust-codes คืน (CustCode, CompanyId, UserId) ของทั้งระบบ — มีอยู่แล้วแต่ยังไม่มี service ไหนเรียกเลย ([AllowAnonymous] และ ไม่มี [RequireApiKey] อยู่ในไฟล์เลย ไม่ใช่แค่ถูก comment ไว้)
  • cache API: POST /v1/cache/users/warm/{oid}, POST /v1/cache/users/refresh/{userId}, DELETE /v1/cache/users/{userId} ([AllowAnonymous], [RequireApiKey] comment ไว้เช่นกัน)
  • 🔴 ไม่มี chokepoint ระดับ pipeline ที่เห็นบริษัทที่ active — ตัวที่ใกล้สุดคือ ICompanyAccessGuard ซึ่งเป็น application-layer helper และถูกเรียกจาก แค่ 4 handler ใน feature เดียว (SetDefaultCompanyHandler.cs:41, DetachSelfHandler.cs:39, GetCompanyDetailHandler.cs:32, ListCorporateMembersHandler.cs:84) แถมตรวจแค่ “เป็นสมาชิกบริษัทนี้ไหม” ไม่ได้ตรวจสถานะบริษัท

2.4 Redis user-info blob (ของกลางใน SupApp_util_lib)

  • key {KeyPrefix}:user:info:{oid} · มี defaultCompanyId, defaultCompanyName, companies[] (companyId, companyName, isDefault, status, role, custCode)
  • 🔴 middleware ทิ้ง blob ทั้งก้อน ถ้า SchemaVersion != CurrentSchemaVersion (เทียบแบบ strict) → field ใหม่ต้องเป็น optional และ ห้าม bump schema version (comment ในไฟล์สั่งไว้ตรง ๆ) — RedisUserInfoMiddleware.cs:193-198, UserInfoForRedis.cs:64-73
  • middleware re-warm จาก HTTP fallback แล้ว stamp TTL ใหม่ → invalidate อาจถูก re-warm ทับได้ถ้าลำดับผิด
  • 🔴 blob ไม่มีลายเซ็น — อะไรก็ตามที่เขียน Redis ได้ ก็แก้ค่าใน blob ได้

2.5 SignalR + Frontend

  • hub เดียว AppHub route /api/notification-service/hubs/app [Authorize] · group = user-{internalUserId}
  • PersonalSignalConsumer relay event name/payload อะไรก็ได้แบบ ephemeral จาก topic notification-personalเพิ่ม event ใหม่ได้โดยไม่ต้องแก้โค้ด NotificationService เลย
  • ⚠️ แต่ถ้า payload deserialize เป็น InAppNotificationPayload แล้วมี Title ไม่ว่าง มันจะถูก เก็บเป็น in-app notification แทนการ relay → payload ของเราต้อง ห้ามมี field titlePersonalSignalConsumer.cs:99-110
  • FE subscribe อยู่ 4 event: Notification, UnreadCountUpdated, MaintenanceToggle, AppStatusChanged
  • FE bootstrap: syncUserProfile()realtime.initialize()syncUserInfoAndPerm() (/users/me) — SignalR ต่อก่อน perms เสร็จ = มี race window
  • modal: ex-modal (@exim/ui-kit) ทำให้ปิดไม่ได้ด้วย [closeOnBackdrop]="false" [closeOnEscape]="false" และไม่ผูก (closed)
  • สลับบริษัทฝั่ง FE: CorporateState.switchCompanysyncUserInfoAndPermPATCH companies/default → patch signal ในหน่วยความจำ (ไม่ reload, ไม่แตะ SignalR)

3. นิยาม “ติด AMLO” — sticky ไม่ใช่ตาม batch ล่าสุด

นี่คือจุดที่ผิดง่ายที่สุดและพังเงียบที่สุด

นิยามที่ใช้: บริษัทถือว่า Flagged เมื่อมี match row ของ custCode นั้นอยู่ (เคยมีก็นับ) และ การหายไปจากไฟล์รอบหลังไม่ปลดบล็อกเอง — ปลดได้ทางเดียวคือ clearance ที่บันทึกไว้ (ใคร ปลดเมื่อไหร่ ด้วยเหตุผล/หลักฐานอะไร)

ทำไมไม่ใช้ “มี match ใน batch ล่าสุด”: นิยามนั้นถูกหักล้างด้วยโค้ดจริง 3 ทาง

  1. Complete() ตั้งสถานะเป็น CompletedWithErrors ทันทีที่มีแถวเสียแม้แถวเดียว และทั้งสองสถานะเป็น terminal — กรอง Status == Completed อย่างเดียว = ข้าม batch ล่าสุดของวันนั้นทั้งใบ แล้วเงียบ ๆ ถอยไปใช้ข้อมูลเก่า (AmloIngestionBatch.cs:55,69-78)
  2. หนึ่งรอบรันประมวลผลหลายไฟล์ แต่ละไฟล์เป็นคนละ batch และชื่อไฟล์อนุญาตหลายไฟล์ต่อวัน (AMLO_(?<ts>\d{8,17})\.csv) — “batch ล่าสุด” = ทิ้งไฟล์อื่นของวันเดียวกัน (AmloIngestionRunService.cs:89-108, AmloFileName.cs:16)
  3. Failed ไม่ใช่ terminal → ไฟล์เก่าที่เคย fail ถูก retry ทีหลังได้ ถ้าเรียง “ล่าสุด” ด้วย ProcessedAt การ retry ไฟล์เมื่อ 3 วันก่อนจะกลายเป็น “ล่าสุด” แล้ว ปลดบล็อกทุกคนที่เพิ่งโดนบล็อกวันนี้ (AmloIngestionBatch.cs:55,81-87)

นิยาม sticky ยังถูกต้องไม่ว่าไฟล์ต้นทางจะเป็น full snapshot หรือ delta ซึ่งเรายังไม่รู้ (ดู Open Items §1) — และตรงกับสิ่งที่ endpoint ปัจจุบันคืนอยู่แล้ว จึงไม่ต้องเปลี่ยน query semantics ในเฟสแรก

⚠️ แถวที่ parse ไม่ผ่าน = custCode ที่ยังไม่ถูกตรวจ — batch ที่มี reject ห้ามถือว่าครอบคลุมครบ ต้องคง flag เดิมไว้แล้ว alert ห้ามให้ parse error กลายเป็น false negative ด้าน compliance

สามสถานะ ไม่ใช่สอง

สถานะความหมายผลต่อผู้ใช้
Flaggedมี matchบล็อก
Clearตรวจแล้วไม่พบ (asOfUtc + batch ที่อ้างอิง)ผ่าน
NotScreenableตรวจไม่ได้ — CustCode ว่าง/null/รูปแบบไม่ตรงกับที่อยู่ในไฟล์ไม่ใช่ Clear ต้องขึ้น ops alert

ถ้าไม่มี NotScreenable บริษัทที่ยังไม่ได้ sync custCode จาก core-banking จะตกลงถัง “ไม่ติด” อย่างเงียบ ๆ และไม่มีวันถูกบล็อกได้เลย (Company.CustCode เป็น nullable จริง)


4. จุดบังคับใช้ (enforcement) — หัวใจของงานนี้

4.1 ทำไม “ใส่ flag ใน Redis blob แล้วให้ UserService ตรวจ” ไม่พอ

  • ICompanyAccessGuard ถูกเรียกจาก 4 handler ใน feature เดียวของ UserService เท่านั้น
  • Backend_WorkflowService และ Backend_TaskService grep หา GetUserInfo() / DefaultCompanyId = 0 → field ใหม่ใน blob มองไม่เห็นเลยโดยสิ้นเชิง
  • service ที่ยังใช้ SupApp_util_lib เวอร์ชันเก่า จะ deserialize field ใหม่เป็น null แล้วเมินเงียบ ๆ และเราไม่มีทางรู้จาก runtime
  • field ต้องเป็น optional (default = ไม่บล็อก) และทางเลือกที่จะบังคับให้ fail-closed คือ bump CurrentSchemaVersion ซึ่ง ห้ามทำ เพราะ strict != จะทำให้ service ที่ยังไม่อัปเกรด ทิ้ง blob ทั้งก้อน จน NameIdentifier หายและพังหนักกว่าเดิม
  • call แบบ service-to-service (X-API-Key) ไม่ผ่าน middleware อยู่แล้ว และ WorkflowService ยังใช้ BypassAuthHandler บน non-prod

สรุป: blob layer ทำให้ flag มองเห็นได้ แต่ไม่ได้ทำให้ บังคับใช้ และโดยโครงสร้างแล้วมันเป็น fail-open เสมอ

4.2 โมเดล 3 ชั้นที่เสนอ

flowchart TB
    subgraph L1["ชั้น 1 — Session (การรับประกันจริง)"]
        A["APIM facade อ่าน cookie<br/>→ POST /sentinel/internal/resolve-session"]
        B["resolve-session คืน accessToken<br/><b>+ amloBlocked: true/false/null</b>"]
        C{"amloBlocked = true<br/>และ path ไม่อยู่ใน allowlist?"}
        A --> B --> C
        C -->|ใช่| D["403 COMPANY_AMLO_BLOCKED<br/>ไม่ต้องถึง backend เลย"]
        C -->|ไม่| E["แนบ Bearer แล้ว forward ตามเดิม"]
    end
    subgraph L2["ชั้น 2 — UserService (แหล่งความจริงของ allow)"]
        F["state ระดับ company ที่ materialize ไว้<br/>Flagged / Clear / NotScreenable + asOfUtc"]
        G["ตรวจใน CompanyAccessGuard + handler ที่เป็น 'การกระทำ'"]
    end
    subgraph L3["ชั้น 3 — Redis blob (deny hint เท่านั้น)"]
        H["amloBlocked ใน UserInfoForRedis<br/>ใช้ปฏิเสธเร็ว ๆ ได้ แต่ห้ามใช้เป็นใบอนุญาต"]
    end
    E --> G
    G --> F
    G -.อ่านเป็น hint.-> H

กฎแข็ง 3 ข้อ

  1. ชั้น 1 คือสิ่งที่รับประกันความครอบคลุม เพราะทุก request ที่ถือ cookie วิ่งผ่าน facade อยู่แล้ว — APIM ไม่ต้องรู้ business logic อะไรเลย แค่อ่าน boolean หนึ่งตัวจาก response ที่มันแกะอยู่แล้ว และ staleness ถูกจำกัดด้วย cache 120 วินาทีของ facade ที่มีอยู่แล้ว
  2. blob เป็นได้แค่ “hint สำหรับปฏิเสธ” ห้ามเป็น “ใบอนุญาต” — handler ที่เห็น amloBlocked=false ยังต้องยืนยันกับ state ในชั้น 2 ก่อนทำรายการ เพราะ blob ไม่มีลายเซ็นและใครที่เขียน Redis ได้ก็แก้ได้
  3. tri-state + fail-closed — ค่าที่เป็นไปได้คือ true (ปฏิเสธ) / false (ตรวจแล้วสะอาด ณ amloAsOfUtc) / null = UNKNOWN (blob ก่อนมีฟีเจอร์, lib เก่า, เขียนไม่ครบ, หรือ GetUserInfo() เป็น null). UNKNOWN และ “สะอาดแต่เก่าเกิน N ชั่วโมง” ต้องปฏิเสธหรือไปตรวจซ้ำ สำหรับทุก action ที่มีมูลค่า ส่วน read-only ปล่อยผ่านได้

⚠️ ถ้า Owner ไม่อนุมัติให้แตะ Sentinel/APIM ชั้น 1 หายไป ทางเลือกที่ซื่อสัตย์คือย้ายการบังคับใช้ไปไว้ใน middleware ของ SupApp_util_lib (short-circuit 403 เอง) พร้อมบัญชีเวอร์ชัน lib รายบริการ และต้องเขียนตรง ๆ ในเอกสารว่า service ที่ยังไม่ deploy ใหม่ = ยังไม่ถูกบังคับใช้ ห้ามพูดว่า “ครอบคลุมแล้ว”

4.3 Allowlist — ถ้าลืมข้อนี้ ผู้ใช้ที่ติด AMLO จะติดค้างจนทำอะไรไม่ได้เลย

ถ้าบล็อกทุก path ผู้ใช้จะเห็นแค่จอว่างหรือ spinner หมุน แล้วสลับไปบริษัทที่สะอาดก็ไม่ได้ ออกจากระบบก็ไม่ได้ ต้องยกเว้น:

pathเหตุผล
GET /v1/users/memodal ต้องใช้รายชื่อบริษัท + flag ต่อบริษัท
PATCH / DELETE /v1/companies/defaultคือทางออกเดียวของผู้ใช้ (สลับไปบริษัทสะอาด)
auth-flow paths (/sentinel/login, /signin-oidc-, /signout-callback-oidc-, /sentinel/logout)passthrough อยู่แล้วในนโยบาย facade ปัจจุบัน
/api/notification-service/hubs/appถ้าตัด hub ทิ้ง จะไม่มีทาง push “ปลดบล็อกแล้ว” กลับไปหาผู้ใช้ได้อีกเลย

และต้องสะท้อนกฎเดียวกันในชั้น 2: ปฏิเสธเฉพาะ action ที่ผูกกับบริษัทที่ติด — การสลับ เข้าไป บริษัทที่สะอาดต้องทำได้เสมอ


5. การกระจายผล (fan-out) — pull เป็นหลัก push เป็นของแถม

flowchart LR
    SFTP[/"ไฟล์ AMLO_*.csv<br/>บน SFTP"/] --> ING["Centralized ingestion<br/>(รอบเวลา หรือสั่งมือ)"]
    ING --> DB[("los.amlo_screening_match")]
    ING -->|เสร็จรอบ| REC
    TIMER["timer เดินช้า ๆ<br/>(safety net)"] --> REC
    REC["UserService reconcile<br/>1. อ่าน /companies/cust-codes<br/>2. ถาม Centralized ทีละ custCode<br/>3. คำนวณสถานะใหม่"]
    REC --> ST[("company AMLO state<br/>Flagged/Clear/NotScreenable")]
    ST -->|commit แล้วค่อย| INV["invalidate Redis user-info<br/>ของ user ที่กระทบ"]
    ST -->|best-effort| PUSH["publish topic notification-personal<br/>→ SignalR event AmloBlocked"]
    PUSH --> FE["modal เด้ง"]
    INV --> NEXT["request ถัดไปเห็นค่าใหม่"]

ทำไม pull ไม่ใช่ push: สายส่งวันนี้รั่วได้ทุกข้อต่อ — Centralized ไม่มี outbox (crash หลัง commit ก่อน publish = event หายถาวร), consumer ฝั่ง UserService เป็น raw Service Bus ไม่มี outbox ไม่มี dedup (ข้อความเน่าเข้า DLQ = ผู้ใช้คนนั้นไม่ถูกบล็อกตลอดกาลแบบเงียบ ๆ), และ PersonalSignalConsumer ถือว่างานเสร็จทันทีหลัง NotifyUserAsync ไม่ว่าจะมี connection อยู่จริงหรือไม่ (PersonalSignalConsumer.cs:109-112)

การ reconcile ด้วยการดึงสถานะทั้งชุดมาคำนวณใหม่ ทำให้ระบบ self-healing: event หายก็แค่ช้า ไม่ใช่ผิดถาวร

ลำดับที่ห้ามสลับ: commit state ก่อน → invalidate cache ทีหลัง → invalidate ซ้ำอีกครั้งหลัง commit ถ้าสลับลำดับ request ที่วิ่งพร้อมกันจะ re-warm ค่าเก่ากลับเข้า Redis พร้อมประทับ TTL ใหม่ยาวอีก 24 ชม.

payload ของ event: ต้องไม่มี field title (มิฉะนั้นถูกเก็บเป็น in-app notification แทนที่จะ relay ออก SignalR) และต้องไม่มีข้อมูล match ใด ๆ (ดู §7)


6. สัญญา API ที่เปลี่ยน

6.1 /users/me — ตอบ scenario 4 ด้วย endpoint ที่ FE เรียกอยู่แล้ว

Owner ขอ “call api 1 เส้นตอน login” — เส้นนั้นควรเป็น /users/me ที่ FE เรียกอยู่แล้วตอน bootstrap ไม่ใช่ endpoint ใหม่ เพราะ modal ต้องใช้ทั้ง “ถูกบล็อกไหม” และ “บริษัทไหนสะอาดบ้าง” ซึ่งอยู่ก้อนเดียวกัน การแยกเป็นสองเส้น = สอง round trip ที่ต้อง sync กันเอง และไปขยาย race window ที่ §2.5 มีอยู่แล้ว

// GET /api/user-service/v1/users/me  (เพิ่ม field เท่านั้น ไม่ breaking)
{
  "user": { /* เหมือนเดิม */ },
  "companies": [
    {
      "id": "…", "nameTH": "…", "isDefault": true, "custCode": "6329225",
      "amloBlocked": true          // ← เพิ่ม: true | false | null (null = UNKNOWN/ยังตรวจไม่ได้)
    }
  ],
  "amloAsOfUtc": "2026-08-18T05:00:00Z"   // ← เพิ่ม: ข้อมูลนี้สดแค่ไหน
}

6.2 ห้ามคืนอะไรกลับไปที่ FE

Similarity, SearchMethod, FlagSystem, AmloType, RecordGuid, match Id, LogDatetimeUtc, batchId และชื่อ/เลขประจำตัวใน watchlist — ห้ามหลุดไป FE และห้ามลง log เพราะเป็นทั้งวิธีการคัดกรองของธนาคารและข้อมูลตัวตนจาก watchlist ของบุคคลที่สาม (รู้ score/method = จูนข้อมูลหลบการ match ได้)

การปฏิเสธคืน 403 + code COMPANY_AMLO_BLOCKED พร้อมข้อความไทยกลาง ๆ ไม่มีรายละเอียด match

6.3 manual sync

reuse POST /v1/admin/amlo-ingestion/run แต่ต้องแก้ 2 อย่าง:

  1. แยกผลลัพธ์ให้ออก — วันนี้ทั้ง “ชน lock” และ “ไม่มีไฟล์ใหม่” คืน 200 (0,0,0,0) เหมือนกัน ต้องคืน 409 เมื่อชน lock และคืน outcome ที่อ่านออก ไม่ใช่ให้ผู้เรียกเดาจากตัวเลข
  2. 🔴 sync มือต้องสั่ง recompute ด้วย ไม่ใช่แค่อ่าน SFTP ซ้ำ — ถ้าไม่มีไฟล์ใหม่ ปุ่มนี้จะ “สำเร็จ” โดยไม่เกิดอะไรขึ้นเลย ซึ่งไม่ตรงกับที่ Owner ต้องการ (“sync มือได้โดยไม่ต้องรอรอบ” = ให้ผลการบล็อกอัปเดตเดี๋ยวนี้)

7. Audit trail (ข้อบังคับ ไม่ใช่ของแถม)

ต้องบันทึกให้ครบ 5 อย่าง: (1) วงจรชีวิตของ batch รวมถึงรอบที่ได้ 0 แถว (2) การเปลี่ยนสถานะรายบริษัท (companyId, custCode, flagged/cleared, batchId, asOfUtc, เหตุผล) (3) ทุกครั้งที่บังคับใช้จริง (403 พร้อม user/company/batch ที่ใช้ตัดสิน — นี่คือหลักฐานว่าระบบทำงาน) (4) ใครสั่ง sync มือ เมื่อไหร่ ผลอะไร (5) ใครปลดบล็อกด้วยหลักฐานอะไร

ใช้ IAuditEventLogger + AsbAuditEventPublisher ที่มีอยู่แล้ว (เติม actor/source/correlation-id ให้อัตโนมัติ) — ⚠️ แต่มันจะ degrade เป็น NullAuditEventPublisher เงียบ ๆ ถ้า env ไม่ได้ตั้ง Service Bus ต้องมี startup guard/health check ใน env ที่เปิดใช้ควบคุมนี้ ไม่งั้น audit trail หายทั้งชุดโดยไม่มี error


8. Config

keyค่าที่ต้องเป็นที่แก้
AmloIngestion:DailyRunHourBangkok5 (ตี 5 ตาม Owner)IaC ทุก env — แก้ appsettings.{ENV}.json ใน repo อย่างเดียวไม่มีผล CI overlay ทับตอน build
AmloIngestion:Enabledtrue ใน env ที่มี SFTPIaC
AmloIngestion:StabilityWindowMinutesต้องคำนวณคู่กับเวลาไฟล์มาถึงIaC
RedisUserInfo TTL (CacheTtlSeconds)ต้องรู้ค่าจริงต่อ env เพราะมันคือขอบบนของความ staleIaC/KV — ต้องไปยืนยัน

⚠️ กับดักเวลา: รอบรันจะหยิบเฉพาะไฟล์ที่นิ่งเกิน StabilityWindowMinutes ถ้าไฟล์มาถึงประมาณ 04

และ window = 30 นาที รอบตี 5 จะ ข้ามไฟล์นั้นแล้วรอไปอีก 24 ชั่วโมง ต้องยืนยันเวลาที่ไฟล์มาถึงจริงก่อนตั้งชั่วโมง แล้วตั้งเป็น (เวลาไฟล์มาถึง + stability window + เผื่อ)


9. บันทึกวิธีทำงาน — ทำไมต้องอ่านจาก origin ไม่ใช่ working tree

ระหว่างทำเอกสารนี้ การอ่าน checkout ในเครื่องให้ข้อสรุปผิด 2 ครั้ง และทั้งสองครั้งเปลี่ยน design:

  1. checkout ของ Centralized ตามหลัง 19 commit → สรุปว่า “ไม่มี endpoint amlo-screening/{id} ในระบบ” ทั้งที่มีอยู่จริงบน origin/development
  2. checkout เดียวกันทำให้สรุปว่า admin endpoint ไม่มี auth เลย ทั้งที่บน origin/development ติด [RequireApiKey] แล้ว — ของจริงที่ยังเปิดโล่งคือ endpoint อ่านผล (AmloScreeningController) ต่างหาก

Backend_Package ในเครื่องตามหลัง 52 commit — ของที่ใหม่จริงในช่วงนั้นคือ field encryption at rest ส่วน strict schema-version guard มีมาก่อนแล้ว (ยืนยันบน origin/development: UserInfoForRedis.cs:65,70) ทั้งคู่คือสิ่งที่กำหนดข้อจำกัดใน §4.1 โดยตรง

กฎ: fact ที่ใช้ตัดสิน design ต้องอ่านจาก origin/<branch> หลัง fetch เสมอ