กำลังโหลดเนื้อหา
Loan Collection Platform Core Architecture & Addon Design
Updates & Additions (1)
# 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