M1N

Loan Collection Platform — Core Architecture & Addon Design

กำลังโหลดเนื้อหา

Updates & Additions (1)

20/08/2026 13:26
# Loan Collection Platform — Runtime Flows & Internals > **เอกสารคู่กับ:** `loan-collection-platform-architecture.md` > **จุดประสงค์:** เอกสารแรกบอกว่า *มีอะไรบ้าง* — เอกสารนี้บอกว่า *มันทำงานยังไง* ตอน request วิ่งจริง > **กลุ่มผู้อ่าน:** dev ที่จะเขียน core, dev ที่จะเขียน addon, ops ที่จะดูแลระบบ --- ## 0. Legend | สัญลักษณ์ | ความหมาย | |-----------|----------| | `[TX]` | อยู่ใน database transaction เดียวกัน | | `[ASYNC]` | ทำนอก transaction ผ่าน outbox/worker | | `[GATE]` | จุดที่บล็อกได้ — ถ้าไม่ผ่าน จบทันที | | `[AUDIT]` | เขียน audit chain entry | | `[ADDON]` | เรียกออกไปหา addon (มี timeout + circuit breaker) | --- ## 1. Anatomy ของ Request หนึ่งครั้ง ทุก request ที่เข้ามาวิ่งผ่าน chain เดียวกันเสมอ — ไม่มีทางลัด ``` Browser │ cookie: session (HttpOnly, SameSite=Lax, Secure) ▼ Nginx / F5 ──── TLS termination, rate limit, WAF, request-id inject ▼ Next.js (Server Component / Server Action) │ 1. อ่าน session → resolve actor │ 2. ห้ามตัดสินใจเชิงธุรกิจที่นี่ (ซ่อนปุ่มได้ แต่ไม่ใช่การบังคับ) │ 3. แนบ actor context + traceId → เรียก Go ผ่าน mTLS ▼ core-api (Go) — middleware chain ตามลำดับ ├─ 1. recover / panic guard ├─ 2. traceId propagate (OpenTelemetry) ├─ 3. authn : verify JWT (short-lived), ตรวจ session revocation ├─ 4. tenant : resolve tenantId → ผูกกับ connection (search_path / RLS) ├─ 5. authz [GATE] : permission check + compile data scope predicate ├─ 6. idempotency: ถ้ามี Idempotency-Key → ตรวจ replay ├─ 7. validate : schema + business precondition ├─ 8. handler : domain logic (ที่เดียวที่มี logic) ├─ 9. audit [AUDIT] : เขียนใน tx เดียวกับ domain write └─ 10. outbox : ลง event ใน tx เดียวกัน ▼ PostgreSQL ``` **กติกาที่ห้ามละเมิด** 1. Authorization เกิดที่ Go เท่านั้น — UI ซ่อนปุ่มเป็นแค่ UX ไม่ใช่ security 2. Domain write + audit entry + outbox event อยู่ใน **transaction เดียวกัน** เสมอ (ถ้าแยก จะมี window ที่ audit ไม่ตรงกับข้อมูลจริง) 3. การเรียก addon เกิด **นอก** transaction เสมอ (addon ช้า/ค้าง = lock DB ค้าง) 4. `traceId` ต้องไหลไปถึง addon และกลับมา --- ## 2. Consistency Model ### 2.1 Transaction Boundary ```go // Pattern used by every mutating handler. // Domain change, audit entry, and outbox event commit atomically. // External side effects (addon calls) happen AFTER commit, driven by the outbox worker. func (h *Handler) Execute(ctx context.Context, cmd Command) (*Result, error) { // 1. Filter hooks run BEFORE the transaction (they may need external data) plan, err := h.hooks.RunFilters(ctx, "case.beforeAssign", cmd.ToPlan()) if err != nil { return nil, err } // 2. Validator hooks — veto only if d, err := h.hooks.RunValidators(ctx, "case.validate", plan); err != nil || !d.Allow { return nil, VetoError(d) } // 3. Single transaction: domain + audit + outbox var res *Result err = h.db.InTx(ctx, func(tx pgx.Tx) error { if err := h.applyDomain(ctx, tx, plan, &res); err != nil { return err } if err := h.audit.Append(ctx, tx, auditEntry(ctx, cmd, res)); err != nil { return err } return h.outbox.Enqueue(ctx, tx, events(res)) }) if err != nil { return nil, err } // 4. Observers fire asynchronously from the outbox worker — not here return res, nil } ``` ### 2.2 Idempotency | ชนิด | Key | เก็บที่ไหน | |------|-----|-----------| | API mutation | `Idempotency-Key` header (client generate เป็น UUIDv7) | ตาราง `idempotency_key(tenant, key, request_hash, response, expires_at)` TTL 24 ชม. | | Inbound integration | `sourceSystem + sourceRef` | unique constraint บนตารางปลายทาง | | Outbox event | `event_id` | consumer เก็บ `processed_event(consumer, event_id)` | | Batch job | `job_name + business_date` | ตาราง `job_run` — ถ้ามี row สถานะ SUCCESS แล้ว ข้าม | > **หลักคิด:** ถ้า worker ตายกลางทางแล้วรันใหม่ ผลลัพธ์ต้องเหมือนเดิม — ไม่ใช่ "โทรซ้ำ 2 ครั้ง" หรือ "ตัดชำระซ้ำ" ### 2.3 Concurrency Control | สถานการณ์ | กลไก | |-----------|------| | แก้ case พร้อมกัน 2 คน | Optimistic lock: `UPDATE ... WHERE id=$1 AND version=$2` → 0 rows = 409 Conflict | | Batch job ซ้อนกัน | `pg_advisory_lock(hashtext(job_name))` — leader election แบบไม่ต้องมี etcd | | Audit sequence ชน | `pg_advisory_xact_lock(hashtext(tenant_id))` ครอบตอน insert | | Allocation แย่ง case | `SELECT ... FOR UPDATE SKIP LOCKED` ตอนดึงงานเข้าคิว | | Payment posting ซ้ำ | unique `(tenantId, sourceSystem, sourceRef)` | --- ## 3. Flow F01 — EOD Ingestion & DPD Roll ```mermaid sequenceDiagram autonumber participant CRON as PM2 cron (01:00) participant BAT as batch (Go) participant ADN as addon: corebanking participant EXT as Core Banking / ESB participant PG as PostgreSQL CRON->>BAT: run --job eod --date 2026-08-20 BAT->>PG: acquire advisory_lock('eod') + insert job_run(RUNNING) BAT->>ADN: FetchAccounts(since, cursor) ADN->>EXT: SOAP/MQ/file (retry + backoff) EXT-->>ADN: raw records ADN-->>BAT: CanonicalAccount[] (mapped, PII plaintext) loop chunk 5,000 rows BAT->>BAT: vault.Encrypt() + BlindIndex() BAT->>PG: UPSERT contract/debtor (ON CONFLICT sourceRef) end BAT->>PG: recompute DPD, bucket, TFRS9 stage BAT->>PG: detect roll (bucket เปลี่ยน) → emit case.rolled BAT->>PG: auto-create Case สำหรับ contract ที่เข้าเกณฑ์ BAT->>PG: insert job_run(SUCCESS) + audit entry BAT-->>CRON: exit 0 ``` **รายละเอียดที่สำคัญ** - **Chunking:** ประมวลผลทีละ 5,000 rows ต่อ transaction — ไม่ทำทั้ง 5 ล้านใน tx เดียว (WAL ระเบิด + rollback แพง) - **Resumable:** เก็บ cursor ใน `job_run.checkpoint` → ล้มกลางทาง รันต่อได้ ไม่เริ่มใหม่ - **Encrypt ก่อนถึง DB เสมอ:** ข้อมูลดิบจาก addon เป็น plaintext → batch เข้ารหัสก่อน UPSERT - **DPD คำนวณจากอะไร:** `dpd = businessDate - oldestUnpaidDueDate` (นับวันปฏิทิน ไม่ใช่วันทำการ — แต่ config ได้) - **Bucket:** map จาก `BucketDef` ของ tenant — `B0(0), B1(1-30), B2(31-60), B3(61-90), B4(91-120), NPL(>90)` เป็นค่า default - **Case creation:** สร้างเมื่อ DPD ข้าม threshold; ถ้ามี case เปิดอยู่แล้ว **ไม่สร้างซ้ำ** แต่ update priority - **Guard:** ถ้ายอด record ที่ได้ต่างจากวันก่อน > X% → หยุดและ alert (กัน feed พัง แล้วระบบไปปิด case ทั้งพอร์ต) --- ## 4. Flow F02 — Strategy → Allocation → Worklist ```mermaid sequenceDiagram autonumber participant BAT as batch (02:00) participant STR as strategy-engine participant SCO as addon: scoring participant PG as PostgreSQL BAT->>STR: RunStrategy(tenant, businessDate) STR->>PG: SELECT cases WHERE state IN (NEW, IN_PROGRESS, BROKEN_PROMISE) loop per case batch STR->>SCO: Score(features) Note over SCO: timeout 200ms, FAIL_OPEN SCO-->>STR: { ptpScore, rpcScore } STR->>STR: eval RuleSet 'debtor_segmentation' (CEL, first-match) STR->>STR: lookup Treatment matrix (segment × bucket × product) STR->>STR: eval Experiment allocation (deterministic hash) STR->>PG: UPDATE case SET segment, treatmentId, priority end STR->>STR: eval RuleSet 'work_allocation' STR->>PG: assign case → user/team/agency (respect capacity + skill + scope) STR->>PG: build worklist snapshot per collector STR->>PG: audit: strategy.run(version, casesAffected) ``` **Allocation strategies ที่ core รองรับ (เลือกด้วย config)** | Strategy | ใช้เมื่อ | |----------|---------| | `ROUND_ROBIN` | งานคุณภาพใกล้เคียงกัน | | `WEIGHTED_VALUE` | balance ยอดหนี้รวมต่อคนให้เท่ากัน | | `SKILL_BASED` | ต้องการภาษา/ผลิตภัณฑ์/พื้นที่เฉพาะ | | `CONTINUITY` | ให้ collector เดิมที่เคยคุยได้ตัวลูกหนี้ (เพิ่มโอกาส RPC) | | `AGENCY_SPLIT` | แบ่ง % ให้ agency ตามสัญญา + champion/challenger | | `CUSTOM` | เรียก addon `allocator` | **Scoring FAIL_OPEN:** ถ้า addon scoring ตาย → ใช้ score เริ่มต้นจาก rule-based fallback แล้วเดินต่อ ไม่หยุดทั้ง batch (แต่ต้อง alert) --- ## 5. Flow F03 — Contact Attempt (Guardrail Path) > Flow ที่สำคัญที่สุดในระบบ — ผิดที่นี่คือผิดกฎหมาย ```mermaid sequenceDiagram autonumber participant UI as Worklist UI participant API as core-api participant DSP as dispatcher participant GR as Guardrail participant CH as addon: channel (SMS/Voice/LINE) participant PG as PostgreSQL UI->>API: POST /cases/{id}/activities { channel, contactPointId } API->>API: authz [GATE] case:contact + data scope API->>DSP: PreCheck(attempt) DSP->>GR: Check(attempt) Note over GR: 1. contact window (weekday/holiday calendar)<br/>2. frequency ≤ N/day per debtor<br/>3. DNC flag on contact point<br/>4. PDPA consent for this purpose<br/>5. third-party → purpose must be LOCATE<br/>6. hardship/disaster hold GR->>CH: [ADDON] validator hook (addon may ADD rules, never remove) CH-->>GR: Decision alt BLOCK GR-->>DSP: { allow:false, code:"WINDOW_CLOSED", retryAfter } DSP->>PG: [TX] insert Activity(guardrailPass=false) + [AUDIT] DSP-->>UI: 409 + เหตุผล + เวลาที่ติดต่อได้ else ALLOW DSP->>PG: [TX] insert Activity(PENDING) + [AUDIT] + outbox DSP->>CH: [ADDON] Send(req) Note over CH: timeout 5s, FAIL_CLOSED CH-->>DSP: { providerRef, accepted } DSP->>PG: update Activity(SENT, providerRef) end Note over CH,PG: Delivery receipt มาทีหลังผ่าน webhook → update Activity(DELIVERED/FAILED) ``` **จุดที่คนทำพลาดบ่อย** 1. **Guardrail ต้องอยู่ก่อน addon เสมอ** — ถ้าให้ addon เป็นคนเช็ค ธนาคารที่ใช้ addon ของ vendor อื่นจะหลุด 2. **นับความถี่จาก "ติดต่อสำเร็จ" ไม่ใช่ "ครั้งที่กด"** — นิยามอยู่ที่ disposition code ที่ mark `countsAsContact=true` 3. **บันทึกแม้ตอนถูกบล็อก** — เป็นหลักฐานว่าระบบป้องกันจริง ตอน audit จะได้โชว์ได้ 4. **Override ต้องมี reason + approval** และเข้า audit เป็น action แยก (`guardrail.override`) 5. **Webhook ต้อง verify signature** ของ vendor และ idempotent (vendor ส่งซ้ำได้) --- ## 6. Flow F04 — PII Reveal (Decrypt Gateway) ```mermaid sequenceDiagram autonumber participant UI participant API as core-api participant VLT as vault participant KMS participant PG UI->>API: POST /pii/reveal { refs:[...], purpose:"COLLECTION_CALL", caseId } API->>API: [GATE] perm 'pii:decrypt:<field>' + data scope ของ case นี้ API->>API: [GATE] rate limit (per user / per hour) alt bulk หรือ purpose=EXPORT API->>PG: สร้าง approval request (maker-checker) → รอ end API->>VLT: Decrypt(refs, actor, purpose, traceId) VLT->>VLT: unpack [version][keyId][nonce][ct][tag] VLT->>KMS: unwrap DEK (cached in memory, TTL สั้น, mlock) VLT->>VLT: AES-256-GCM Open with AAD = tenant|table|column|rowId VLT->>PG: [AUDIT] pii.decrypt (field, resourceId, purpose, outcome) VLT-->>API: plaintext API-->>UI: { value } Note over UI: หน้าจอโชว์ชั่วคราว, ห้ามเก็บใน state ยาว, ไม่ log ``` **การป้องกันการ dump ข้อมูล** - Threshold: reveal เกิน N ครั้ง/ชม. หรือ N case ที่ไม่ใช่ของตัวเอง → alert ทีม security อัตโนมัติ - `AUDITOR` role เห็นทุกอย่างยกเว้น PII — ตั้งใจแยกเพื่อให้ตรวจสอบได้โดยไม่เพิ่มพื้นที่รั่ว - `SYS_ADMIN` ไม่มี `pii:decrypt:*` เลย — คนดูแลระบบไม่ควรอ่านข้อมูลลูกค้าได้ - ทุก export ฝัง watermark (userId + timestamp) ในไฟล์ → ตามรอยได้ถ้าหลุด --- ## 7. Flow F05 — PTP Lifecycle ```mermaid stateDiagram-v2 [*] --> OPEN: collector สร้างนัดชำระ OPEN --> KEPT: payment >= promisedAmt ภายใน grace OPEN --> PARTIAL: 0 < payment < promisedAmt OPEN --> BROKEN: ถึงกำหนด + grace แล้วไม่ชำระ OPEN --> CANCELLED: ลูกหนี้ขอยกเลิก / restructure แทน PARTIAL --> BROKEN: ส่วนที่เหลือไม่มาภายใน grace BROKEN --> [*] KEPT --> [*] ``` **การประเมิน (ทำโดย job `ptp-monitor` ทุกชั่วโมง)** ```go // PTP evaluation is idempotent: re-running on the same PTP yields the same result. // Grace period is per-tenant config; payments are matched by value date, not posting date. func evaluate(p *Ptp, cfg Config, now time.Time) PtpStatus { deadline := p.PromisedAt.Add(cfg.PtpGracePeriod) // e.g. 2 business days paid := sumPayments(p.CaseID, p.CreatedAt, deadline) switch { case paid >= p.PromisedAmt: return PtpKept case now.After(deadline) && paid == 0: return PtpBroken case now.After(deadline) && paid > 0: return PtpBroken // partial then expired case paid > 0: return PtpPartial default: return PtpOpen } } ``` **Reminder ladder (config L0):** T-3 วัน → SMS, T-1 วัน → SMS/LINE, T+0 เช้า → LINE, T+1 → โทร ทุกขั้นวิ่งผ่าน guardrail เหมือน contact ปกติ — reminder ก็นับเป็นการทวงถาม **Broken promise → ผลกระทบ:** case.state → `BROKEN_PROMISE`, priority +, segment เปลี่ยน (`REPEAT_BREAKER` ถ้าครั้งที่ ≥2), treatment แข็งขึ้น, collector คนเดิมได้งานคืน (continuity) --- ## 8. Flow F06 — Payment & Waterfall ```mermaid sequenceDiagram autonumber participant SRC as Payment file / API / Core Banking participant ADN as addon: payment participant API as core-api participant PG SRC->>ADN: raw payment records ADN->>ADN: parse + map → CanonicalPayment ADN->>API: POST /payments (batch, idempotent by sourceRef) API->>API: matching: accountNoBidx → contract → open case alt ไม่พบคู่ API->>PG: insert to suspense_account + alert else พบ API->>API: [ADDON] filter hook 'payment.beforeAllocate' Note over API: default order FEE → PENALTY → INTEREST → PRINCIPAL<br/>addon อาจเปลี่ยนลำดับตามสัญญาของแต่ละแบงก์ API->>PG: [TX] insert Payment + allocation lines + update contract balances API->>PG: [TX] evaluate PTP + recompute DPD/bucket API->>PG: [TX] [AUDIT] + outbox: payment.posted end Note over API,PG: cure detection → ถ้า DPD กลับเป็น 0 → case.state = CLOSED_PAID ``` **กติกาเรื่องเงิน** - ทุกยอดเป็น `int64` หน่วยสตางค์ — ห้าม float ทุกกรณี รวมถึงตอนคำนวณ commission - Allocation lines เก็บเป็น **รายการแยก** (ตัดค่าปรับเท่าไร ดอกเท่าไร ต้นเท่าไร) ไม่ใช่แค่ยอดรวม → reconcile กับ core banking ได้ - **Reversal** ไม่ลบ record เดิม แต่สร้าง record ตรงข้ามที่อ้าง `reversalOf` → audit chain ไม่ขาด - Payment ที่ตกค้างใน suspense ต้องมี UI ให้ ops จับคู่มือ + audit ทุกครั้ง --- ## 9. Flow F07 — Restructure & Maker-Checker ```mermaid sequenceDiagram autonumber participant COL as Collector (maker) participant API as core-api participant WF as workflow engine participant SUP as Supervisor (checker) participant ADN as addon: corebanking COL->>API: POST /restructures { plan, haircut, tenor } API->>API: simulate (คำนวณค่างวดใหม่, NPV, ผลกระทบ TFRS9) API->>API: [ADDON] validator 'restructure.validate' (นโยบายเฉพาะแบงก์) API->>WF: submit → match ApprovalPolicy by CEL condition Note over WF: levels: [{SUPERVISOR,1},{HEAD,1}] ถ้า haircut > threshold<br/>allowSelf=false → maker ≠ checker WF->>SUP: task in approval inbox (+ SLA timer) SUP->>WF: approve(reason) WF->>API: all levels satisfied API->>PG: [TX] update contract terms + case.state=RESTRUCTURED + [AUDIT] API->>ADN: [ASYNC] PostAdjustment → core banking Note over ADN: ถ้า fail → DLQ + compensating task ให้ ops จัดการ<br/>ห้าม rollback เงียบๆ ``` **ประเด็นที่ต้องระวัง:** การแก้เงื่อนไขสัญญาใน core banking เป็น external system ที่ rollback ไม่ได้ → ต้องใช้ **saga + compensating action** ไม่ใช่ distributed transaction สถานะระหว่างกลางต้องมองเห็นได้ใน UI (`RESTRUCTURE_PENDING_SYNC`) --- ## 10. Flow F08 — Audit Write & Verify ### 10.1 Write path ```mermaid sequenceDiagram participant H as Handler participant PG as PostgreSQL H->>PG: BEGIN H->>PG: pg_advisory_xact_lock(hashtext(tenant_id)) H->>PG: SELECT seq, entry_hash FROM audit_log WHERE tenant=$1 ORDER BY seq DESC LIMIT 1 H->>H: payload_hash = sha256(canonical(payload)) Note over H: PII masked + hashed, ไม่มี plaintext H->>H: entry_hash = sha256(prev_hash || canonical(entry)) H->>PG: INSERT audit_log(seq+1, ..., prev_hash, entry_hash) H->>PG: COMMIT ``` > advisory lock ครอบทั้ง tx ทำให้ audit เป็น serialized ต่อ tenant — throughput ต่อ tenant ถูกจำกัดที่ ~1,000-3,000 entry/s ซึ่งเพียงพอ ถ้าไม่พอในอนาคตค่อยเปลี่ยนเป็น single-writer queue หรือ per-partition chain ### 10.2 Checkpoint (ทุกชั่วโมง) ``` seq_from = จุดสุดท้ายของ checkpoint ก่อนหน้า + 1 seq_to = seq ล่าสุด root_hash = entry_hash ของ seq_to signature = Ed25519_sign(kms_key, tenant|from|to|root|signed_at) → insert audit_checkpoint → export ไป WORM object store (object lock, retention ตามนโยบาย) ``` ### 10.3 Verify ``` 1. ตรวจ seq ต่อเนื่อง ไม่มีช่องว่าง (gapless) 2. re-compute entry_hash ทีละ entry เทียบกับที่เก็บไว้ 3. ตรวจ signature ของ checkpoint ทุกใบ 4. เทียบ root_hash ใน DB กับสำเนาใน WORM → ถ้าไม่ตรงจุดไหน รายงาน seq แรกที่ผิด ``` **ทำไมถึงพิสูจน์ได้:** ถ้าใครแก้ entry ที่ seq=100 → `entry_hash` ของ 100 เปลี่ยน → 101 ที่อ้าง prev_hash ของ 100 ผิด → พังต่อกันไปทั้งสาย และแก้ให้ถูกทั้งสายไม่ได้ เพราะ checkpoint ถูกเซ็นและ export ออกไปข้างนอกแล้ว --- ## 11. Addon Call Path (ละเอียด) ```mermaid sequenceDiagram autonumber participant CORE as core-api participant REG as Addon Registry participant SUP as addon-runtime participant ADN as addon process participant PG CORE->>REG: resolve(point="channel.sms", tenant="GHB") REG-->>CORE: addonId=sms-vendor-x, failureMode=FAIL_CLOSED CORE->>CORE: circuit breaker state? OPEN → fallback ทันที CORE->>SUP: Invoke(addonId, method, req, deadline=5s, traceId) SUP->>SUP: mint capability token (scoped to manifest permissions, TTL=deadline) SUP->>ADN: gRPC call (stdio/unix socket) ADN->>SUP: Host.Case().Get(caseId) Note over SUP: [GATE] ตรวจ token ว่ามี core:case:read SUP->>PG: query (scoped) SUP-->>ADN: masked data ADN->>SUP: Host.Decrypt(ref, purpose) Note over SUP: [GATE] ตรวจ pii:decrypt:accountNo + [AUDIT] ADN-->>SUP: result SUP->>SUP: record latency/error metric, update breaker SUP-->>CORE: result | timeout | error alt error และ failureMode=FAIL_CLOSED CORE-->>CORE: abort operation, คืน error ให้ user else error และ failureMode=FAIL_OPEN CORE-->>CORE: ใช้ default behavior เดินต่อ + log warning end ``` ### 11.1 Failure Mode ต่อ Extension Point | Point | Failure mode | เหตุผล | |-------|--------------|--------| | `guardrail.validate` | **FAIL_CLOSED** | ถ้าเช็คไม่ได้ ห้ามติดต่อ — ปลอดภัยไว้ก่อน | | `channel.*` | FAIL_CLOSED | ส่งไม่ได้ต้องรู้ ไม่ใช่เงียบ | | `corebanking` | FAIL_CLOSED (batch) | ข้อมูลไม่ครบ = ทั้งพอร์ตผิด | | `scoring` | FAIL_OPEN | ไม่มี score ก็ทำงานได้ด้วย rule fallback | | `payment.beforeAllocate` | FAIL_CLOSED | ลำดับตัดชำระผิด = ผิดสัญญา | | `ui.slot` | FAIL_OPEN | error boundary ซ่อน component ไป ระบบยังใช้ได้ | | observer (async) | retry + DLQ | ไม่กระทบ path หลัก | ### 11.2 Addon Install / Enable ``` collectctl addon install pkg.tar.gz ├─ 1. verify signature (vendor key) ├─ 2. parse + validate manifest ตาม JSON Schema ├─ 3. ตรวจ coreCompatibility semver range ├─ 4. ตรวจ permission ที่ขอ — sensitive ต้องมี approval ├─ 5. CREATE SCHEMA addon_<id> + รัน migration ของ addon ├─ 6. เขียนไฟล์ลง /opt/collect/addons/<id>/ ├─ 7. pm2 start addon-<id> + health check ├─ 8. ลงทะเบียนใน registry (สถานะ INSTALLED, ยังไม่ enable) └─ 9. [AUDIT] addon.install collectctl addon enable <id> --tenant GHB └─ registry: enable per tenant → resolve() เริ่มเห็น ``` **Rollback:** `disable` ทำให้ resolve() กลับไปใช้ default/previous provider ทันที ไม่ต้อง restart core --- ## 12. Batch Timeline (คืนหนึ่ง) ``` 00:30 ├─ pre-check: ตรวจ disk, replication lag, addon health 01:00 ├─ F01 EOD ingestion (คาด 45-75 นาที @ 5M accounts) 02:15 ├─ F02 strategy: score → segment → treatment 02:45 ├─ allocation + worklist snapshot 03:00 ├─ campaign build (dialer list, SMS batch สำหรับวันถัดไป) 03:30 ├─ commission calculation (รายวัน accrual) 04:00 ├─ report/MIS extract → DWH (masked) 04:30 ├─ audit checkpoint สรุปวัน + export WORM 05:00 ├─ vacuum/analyze ตารางใหญ่, partition maintenance 05:30 └─ post-check: ถ้ามี job FAILED → page on-call 07:00 collector เริ่มงาน — worklist ต้องพร้อม ``` **Hard deadline:** ทุก job ต้องจบก่อน 06:30 ถ้าเกิน → alert และ degrade เป็น worklist ของเมื่อวาน (ไม่ปล่อยให้ collector ไม่มีงานทำ) --- ## 13. Degradation Matrix — อะไรพัง แล้วอะไรยังใช้ได้ | ส่วนที่พัง | ผลกระทบ | ระบบทำอะไร | ยังทำงานได้ไหม | |-----------|---------|-----------|----------------| | addon channel (SMS) | ส่ง SMS ไม่ได้ | breaker เปิด, queue ค้าง, alert | ✅ ช่องทางอื่นปกติ | | addon corebanking | EOD ไม่มา | job FAILED, ใช้ข้อมูลเมื่อวาน + banner เตือน | ⚠️ ข้อมูลเก่า 1 วัน | | addon scoring | ไม่มี score | fallback rule-based | ✅ | | vault | ถอดรหัสไม่ได้ | ทุกอย่างเป็น masked, ห้ามติดต่อ (ไม่มีเบอร์) | ❌ ต้องกู้ก่อน | | strategy-engine | ไม่จัดงานใหม่ | ใช้ worklist snapshot ล่าสุด | ⚠️ งานไม่ refresh | | dispatcher | ติดต่อไม่ได้ | queue ค้าง, บันทึกเจตนาไว้ | ⚠️ บันทึกงานได้ ส่งไม่ได้ | | PG primary | ทุกอย่างหยุด | failover ไป standby (RTO ≤ 4 ชม.) | ❌ | | Next.js | UI ล่ม | PM2 restart, instance อื่นรับต่อ | ✅ (มีหลาย instance) | > **หลักการ:** ระบบต้อง **บอกได้ว่ากำลังทำงานในโหมดพร่อง** ไม่ใช่แกล้งทำเป็นปกติ — banner บนหน้าจอ + สถานะใน admin --- ## 14. Worked Example — ชีวิตของ Case หนึ่งใบ | วัน | เหตุการณ์ | State | สิ่งที่ระบบทำเบื้องหลัง | |-----|-----------|-------|------------------------| | D+1 | ค้างชำระวันแรก | — | DPD=1, bucket B1, ยังไม่สร้าง case (threshold=5) | | D+5 | ข้าม threshold | `NEW` | สร้าง case, priority=50 | | D+5 02:15 | strategy run | `NEW` | score.ptp=0.72 → segment `HV_EARLY` → treatment `SOFT_DIGITAL` | | D+5 02:45 | allocation | `ASSIGNED` | เข้าคิว team A, collector สมชาย | | D+5 09:00 | ส่ง SMS แจ้งเตือน | `ASSIGNED` | guardrail ผ่าน (ในเวลา, ครั้งแรกของวัน, มี consent) → addon SMS | | D+6 20:30 | collector กดโทร | `ASSIGNED` | guardrail **BLOCK** `WINDOW_CLOSED` → บันทึก + แจ้งเวลาที่โทรได้ | | D+7 10:15 | โทรติด คุยตัวลูกหนี้ | `IN_PROGRESS` | disposition `RPC_PTP`, reveal เบอร์ (audit: pii.decrypt) | | D+7 10:20 | สร้าง PTP 15,000 บ. นัด D+12 | `PTP_ACTIVE` | reminder ladder ถูก schedule (T-3, T-1, T+0) | | D+12 | ไม่มีเงินเข้า | `PTP_ACTIVE` | ptp-monitor เห็น paid=0 แต่ยังใน grace | | D+14 | หมด grace | `BROKEN_PROMISE` | PTP=BROKEN, priority 50→75, segment → `REPEAT_BREAKER` | | D+20 | ลูกหนี้โอนบางส่วน 8,000 | `BROKEN_PROMISE` | waterfall: ค่าปรับ 500 → ดอก 2,300 → ต้น 5,200; DPD ยังไม่ 0 | | D+35 | ขอปรับโครงสร้าง | `RESTRUCTURE_PENDING` | simulate → haircut เกิน threshold → 2 ระดับอนุมัติ | | D+37 | อนุมัติ | `RESTRUCTURED` | sync ไป core banking (saga), `restructureOfferedCount++` | | D+120 | ผิดนัดอีก DPD 90 | `IN_PROGRESS` | guard `escalate_to_prelegal` ผ่าน (เคยเสนอ DR แล้ว 1 ครั้ง) | | D+125 | ออกหนังสือบอกกล่าว | `PRE_LEGAL` | render template + ส่ง EMS + tracking | ทุกบรรทัดข้างบนมี audit entry ที่ต่อกันเป็น hash chain — ตรวจย้อนหลังได้ว่าใครทำอะไรเมื่อไร และระบบบล็อกอะไรไปบ้าง --- ## ภาคผนวก A — Event Catalog (outbox) | Event | เกิดเมื่อ | ผู้บริโภคหลัก | |-------|----------|--------------| | `case.created` | สร้าง case | strategy, notification | | `case.assigned` | จัดงาน | worklist, agency adapter | | `case.rolled` | bucket เปลี่ยน | MIS, strategy | | `case.escalated` | เข้าสถานะ pre-legal/legal | legal module, notification | | `contact.attempted` | ทุกครั้งที่พยายามติดต่อ | MIS, compliance | | `contact.blocked` | guardrail บล็อก | compliance dashboard, alert | | `ptp.created` / `ptp.kept` / `ptp.broken` | PTP เปลี่ยนสถานะ | strategy, notification, commission | | `payment.posted` / `payment.reversed` | รับ/กลับรายการ | commission, MIS, core banking sync | | `restructure.approved` | อนุมัติ DR | core banking adapter, regulatory | | `pii.decrypt` | ถอดรหัส | security monitoring | | `addon.installed` / `addon.failed` | lifecycle ของ addon | ops alert | ## ภาคผนวก B — Idempotency Key Convention ``` API mutation : client UUIDv7 EOD ingestion : eod|<tenant>|<businessDate> PTP evaluation : ptp-eval|<ptpId>|<evalDate> Payment : pay|<sourceSystem>|<sourceRef> Campaign build : campaign|<tenant>|<channel>|<businessDate> Commission : comm|<tenant>|<collectorId>|<periodId> Audit checkpoint : ckpt|<tenant>|<seqTo> ``` ## ภาคผนวก C — คำถามที่ควรตอบได้ก่อน go-live - [ ] ถ้า addon core banking ล่ม 3 วันติด ระบบยังใช้ทวงหนี้ได้ไหม แล้ว collector รู้ไหมว่าข้อมูลเก่า - [ ] ถ้า collector กดโทร 20:01 ระบบบล็อกจริงไหม และมีหลักฐานไหม - [ ] ถ้า DBA เปิด DB ดูโดยตรง เห็นเลขบัตรประชาชนไหม - [ ] ถ้ามีคน reveal PII 500 ครั้งใน 1 ชม. ใครได้รับ alert และภายในกี่นาที - [ ] ถ้าใครแก้ audit_log ตรงๆ ตรวจเจอไหม ภายในกี่ชั่วโมง - [ ] ถ้า EOD ล้มที่ record ที่ 3 ล้าน รันใหม่แล้วข้อมูลซ้ำไหม - [ ] ถ้าลูกค้ารายที่ 2 ขอเปลี่ยนลำดับตัดชำระ ต้องแก้ core กี่บรรทัด (คำตอบที่ถูกคือ: ศูนย์) - [ ] ถ้าต้องกู้ DB จาก backup เมื่อ 6 เดือนก่อน key ยังถอดได้ไหม ใครถือ passphrase

CONFIRM_DELETE

Are you sure you want to delete this article?
"Loan Collection Platform — Core Architecture & Addon Design"
This action cannot be undone.