Orchestration Layer - Operating Model
ฉบับร่าง v1.0.0 · 10 มิถุนายน 2026 Business Requirements · Use Cases · Architecture · API Contract · Data Model · Design Decisions · Delivery Roadmap
Delivery Roadmap
จัดลำดับงานทั้งหมดในเล่มนี้ตาม ขนาดของปัญหาเชิงธุรกิจ (size of business problem) — ไม่ใช่ตามขนาดงาน (effort)
Prioritization lens
ให้คะแนนปัญหาที่แต่ละชิ้นแก้ จาก 3 มิติ:
| มิติ | คำถาม |
|---|---|
| Breadth | แตะกี่ transaction / กี่ tenant — ปัญหาแนวนอน (ทุกออเดอร์) หรือเฉพาะกิจ? |
| Revenue/Cost impact | ปลดบล็อกรายได้, เพิ่มยอด, หรือลดต้นทุน — มากแค่ไหน |
| Blocking | ไม่แก้แล้วเดินต่อไม่ได้ หรือแค่เสียโอกาส (additive)? |
Foundation (§0) อยู่นอกการจัดลำดับนี้ — เป็น P0 เพราะเป็น dependency ไม่ใช่เพราะปัญหาใหญ่สุด ทุก OM วิ่งไม่ได้ถ้าไม่มี consent/grant + catalog + event bus + settlement ก่อน
ข้อสมมุติที่ต้องยืนยัน: ลำดับ OM ด้านล่างอิงเหตุผลเชิงโครงสร้างจาก §1 + t-shirt size ของ UC — ไม่ได้อิงตัวเลขจริง (GMV ต่อ OM, สัดส่วนต้นทุน last-mile, อัตราโต catalog) ถ้ามีข้อมูลพวกนี้ Phase 1–3 อาจสลับ (เกณฑ์ใน §K.3)
Problem sizing
| อันดับ | ปัญหา (§1) | OM | Breadth | Impact | Blocking? | เหตุผล |
|---|---|---|---|---|---|---|
| 1 | Fulfillment Reach | OM-1 Stockist | สูงสุด — ทุกออเดอร์ที่ต้องส่ง | ปลดบล็อกการขาย + ใช้คลัง/รถที่ว่างให้เกิดรายได้ | บล็อก — ส่งไม่ถึง = ขายไม่ได้ | แนวนอนสุด แตะทุกออเดอร์ + unlock asset ที่จมทั้งเครือข่าย |
| 1b | Carrier Access | OM-4 Shipper | สูง — ทุกออเดอร์ขา last-mile | ลดต้นทุน last-mile + เพิ่ม fill-rate | บล็อก OM-1 — packed แล้วไม่มีคนส่ง | enabler ของ OM-1 (packed → delivery job); แยกกันไม่ได้ |
| 2 | Product Assortment | OM-3 Share SKU | กลาง-สูง — ต่อ SKU ที่แชร์ | เพิ่มยอด/ขยาย catalog เร็ว | additive — ไม่บล็อก แต่เสียโอกาสโต | growth lever; dropship reuse fulfillment OM-1 |
| 3 | Purchasing Power | OM-2 Consolidate | กลาง — เฉพาะ campaign/รอบ | ลดต้นทุนซื้อ (MOQ/tier) | additive — เฉพาะกิจ | cost lever ทรงพลังแต่ surface แคบ เป็นรอบ ไม่ใช่ทุกออเดอร์ |
สรุปลำดับ: Foundation → (OM-1 + OM-4 เป็น delivery backbone คู่กัน) → OM-3 → OM-2
Dependency จริง (บังคับลำดับ)
บังคับลำดับไม่ว่าปัญหาใหญ่แค่ไหน:
Foundation (F1 grant · F3 catalog · event bus · F4 settlement · idempotency/audit)
│
├── OM-1 ──┬── ต้องการ OM-4 (packed → delivery job) ← ส่งคู่กัน
│ └── reuse โดย OM-3 dropship
├── OM-3 ── ต้องการ F3 Master Catalog (SKU ต่าง supplier = สินค้าเดียว)
└── OM-2 ── ต้องการ F3 Master Catalog (รวม volume ข้าม supplier)
- F3 Master Catalog = hard gate ของ OM-2 และ OM-3 — ต้องรู้ identity สินค้าข้าม supplier
- OM-1 ↔ OM-4 จับคู่กัน — OM-1 ที่ packed ต้องมี OM-4 รับช่วงส่ง มิฉะนั้น flow ไม่ปิด
- เกณฑ์สลับ Phase 1–3: ถ้า pain หลักคือ ต้นทุนซื้อ (มาร์จิ้นบาง, distributor มีอำนาจราคา) → ดัน OM-2 ขึ้นก่อน OM-3; ถ้า pain คือ catalog โตไม่ทัน → คง OM-3 อันดับ 2
มุมมองภาพรว
| Phase | แก้ปัญหา | ขนาดปัญหา | ขนาดงาน | ปลดบล็อกอะไรต่อ |
|---|---|---|---|---|
| 0 Foundation | (prerequisite) | — | L | ปลดล็อกทุก OM |
| 1 OM-1 + OM-4 | Fulfillment + Carrier | ใหญ่สุด | XL+XL | delivery backbone + dropship ของ OM-3 |
| 2 OM-3 | Assortment | ใหญ่ | L | ใช้ fulfillment เดิมต่อยอด |
| 3 OM-2 | Purchasing Power | กลาง | L | — |
| 4 Hardening | การเงิน/scale/compliance | — | M-L | go-live เต็ม |
สารบัญ (Table of Contents)
ส่วนที่ 1 — รากฐานและกติกากลาง (A–F)
- A. ภาพรวม & หลักการ
- B. Conventions (native flow-api) - GET /health — Health check
- C. Authentication & Authorization - POST /auth/delegation-tokens — ขอ Delegation Token (Token Exchange)
- D. Response & Error Envelope
- E. On-behalf → Tenant Route Mapping
- F. สถาปัตยกรรมการเชื่อม Tenant ↔ Orchestration
ส่วนที่ 2 — Foundation + Operating Models (เรื่องที่ 0–5)
- เรื่องที่ 0 — Foundation
- 0.0 ทำไมต้องมี Foundation
- 0.1 UC-F1 — Establish Collaboration
- POST /api/v1/orchestration/collaborations — เสนอความร่วมมือ → pending
- POST /collaborations/:id/accept — counterparty ตอบรับ → active (+grant)
- POST /collaborations/:id/decline — ปฏิเสธ → rejected
- PUT /collaborations/:id/approve — operator อนุมัติ → active
- GET /consolidation/campaigns — รายการ (paged)
- GET /consolidation/campaigns/:id — รายละเอียด
- GET /grants/:id — รายละเอียด grant
- GET /grants — รายการ grant (paged)
- 0.2 UC-F2 — Revoke / Suspend Collaboration
- 0.3 UC-F3 — Map SKU to Master Catalog
- POST /catalog/map — map SKU เดียวเข้า Master Catalog
- POST /catalog/map/batch — map แบบ batch (async)
- GET /catalog/map/batch/:job_id — สถานะ batch
- GET /catalog/items/:master_sku_id — golden record
- GET /catalog/review-queue — คิวที่ต้อง steward review (paged)
- PUT /catalog/items/:id/resolve — steward ตัดสิน (ผูก/merge, reversible+audit)
- POST /catalog/unmap — ถอด mapping (reversible + audit)
- 0.4 UC-F4 — Settlement Run
- เรื่องที่ 1 — OM-1 Stockist
- 1.0 ปัญหา & ภาพรวม OM-1
- 1.1 UC-1.1 — Assign Fulfillment to Stockist
- POST /fulfillment/assignments — สร้าง assignment (saga entrypoint)
- GET /fulfillment/assignments/:id — รายละเอียด (full view: orchestrator/operator)
- GET /fulfillment/assignments — รายการ assignment (paged, มุมมองตาม role)
- PUT /fulfillment/assignments/:id/reassign — บังคับ reassign (operator)
- PUT /fulfillment/assignments/:id/cancel — ยกเลิก (compensation: release reservation)
- 1.2 UC-1.2 — Track Fulfillment
- 1.3 UC-1.3 — Operate Fulfillment
- เรื่องที่ 2 — OM-2 Consolidate Purchase
- เรื่องที่ 3 — OM-3 Share SKU
- เรื่องที่ 4 — OM-4 Shipper Matching
- 4.0 ปัญหา & ภาพรวม OM-4
- 4.1 UC-4.1 — Carrier Onboarding
- 4.2 UC-4.2 — Create Delivery Job
- 4.3 UC-4.3 — Match Carrier
- POST /delivery/jobs/:id/match — เริ่ม matching (หรือ auto จาก event)
- GET /carriers/me/offers — offer ที่ค้างของ carrier (AuthorizationCarrierToken)
- POST /delivery/offers/:offer_id/accept — รับงาน (first wins, optimistic lock)
- POST /delivery/offers/:offer_id/decline — ปฏิเสธ offer
- POST /delivery/jobs/:id/bids — เสนอราคา (auction)
- PUT /delivery/jobs/:id/assign — operator assign ตรง (ทางออกของ escalated)
- 4.4 UC-4.4 — Execute & Track Delivery
- เรื่องที่ 5 — Settlement, Reverse & Events
- 5.1 Settlement & Billing
- UC-5.1 — Dispute & Adjust Settlement
- GET /orchestration/settlement/ledger — รายการ ledger (paged)
- POST /orchestration/settlement/runs — รัน settlement (UC-F4)
- GET /orchestration/settlement/runs — รายการ run (paged)
- GET /orchestration/settlement/runs/:run_id — สถานะ run + เอกสารที่ออก
- PUT /orchestration/settlement/entries/:id/dispute — เปิดข้อพิพาท (กันออกจากรอบ)
- PUT /orchestration/settlement/entries/:id/resolve-dispute — ปิดข้อพิพาท
- UC-5.1 — Dispute & Adjust Settlement
- 5.2 Reverse Logistics
- 5.3 Webhooks & Domain Events
- 5.1 Settlement & Billing
ส่วนที่ 3 — ภาคผนวก Reference (G–L)
- G. State Machine Reference
- H. Endpoint Summary Matrix
- I. DB Schema ของ Orchestration Layer
- J. สิ่งที่ต้องเติมต่อ
- K. Delivery Roadmap (จัดลำดับตามขนาดปัญหา)
- L. Changelog
ส่วนที่ 1 — รากฐานและกติกากลาง (A–F)
สิ่งที่ทุก section ใช้ร่วมกัน — หลักการออกแบบ, convention ของ API, การยืนยันตัวตนข้าม tenant, รูปแบบ response/error, การ map เส้นทาง และสถาปัตยกรรมที่ทุก flow วิ่งอยู่ อ่านส่วนนี้ครั้งเดียว ส่วนอื่นจะไม่อธิบายซ้ำ
A. ภาพรวม & หลักการ
ทั้ง 4 Operating Model เกิดจาก ปัญหาเชิงธุรกิจ 4 เรื่อง (เอกสารหลัก §1) — แต่ละ OM แก้หนึ่งเรื่อง โดยทุก OM วิ่งบน Orchestration Layer (Platform Core) ผ่าน Foundation ชุดเดียว และเรียก tenant service เดิมแบบ on-behalf:
กฎทองที่บังคับทุก endpoint:
- AP-3 / BR-02 Consent-gated: mutating cross-tenant call ต้องมี collaboration
active+ grant มิฉะนั้น403 GRANT_DENIED(default deny) - AP-4 No direct cross-DB reach: การอ่าน/เขียนของอีก tenant ต้องผ่าน orchestrator (grant-checked) แล้ว orchestrator เรียก tenant route เดิมด้วย delegation token — ไม่ query DB ข้ามกัน
- AP-5 Event-driven + Saga: flow ข้าม tenant ตอบ
202 Accepted+ resource id แล้วติดตามผ่าน event/polling - AP-7 Degrade gracefully: orchestration ล่ม ต้องไม่บล็อกการขายภายใน tenant (flag
new_archใช้ค่อยๆ เปิด)
A.1 ความเข้ากันได้กับ flow-api (Tenant Layer) — สิ่งที่สำรวจจาก source จริง
สเปกนี้ทำให้ orchestration layer พูดภาษาเดียวกับ tenant layer ที่มีอยู่ ตารางสรุปคอนเวนชันจริงที่สกัดจาก flow-api:
| ด้าน | ของจริงใน flow-api | orchestration ทำตาม |
|---|---|---|
| Framework | Go + Fiber v2 (gofiber/fiber/v2), GORM/MySQL, Redis, MinIO | เหมือนกัน |
| API Gateway | https://ttmart-gateway.flow-solution.co | base เดียวกัน |
| Base path | app.Group("api/v1") (บางส่วน api/v2) | api/v1/orchestration/... |
| Route group แบบ "ช่องทาง" | ordering/ backoffice/ ssc/ vansales/ merchant/ pos/ consumer/ external/ public/ | เพิ่มช่องทางใหม่ orchestration/ + reuse external/logistics/* |
| Auth middleware | AuthorizationCustomerToken() / SupplierToken() / SscToken() / VanSaleToken() / MerchantToken() / ExternalToken() | เพิ่ม AuthorizationOrchestrationToken() + delegation |
| Tenant scoping | jwthandler.GetSupplierId(c) / GetSupplierIds(c) → filter ทุก query ด้วย supplier_id (single-owner) | grant-checked on-behalf เท่านั้น (AP-4) |
| JWT claims (jwthandler) | supplier_id, supplier_ids[], customer_id, user_id, new_arch, default_warehouse, route_planner_id, price_tier_group_id, van_sale_id | ต่อยอด: acting_for, on_behalf_of, grant_id, scope[] |
| Response (data GET) | คืน payload ดิบ ตรงๆ c.JSON(result) (ไม่มี envelope) | เหมือนกัน |
| Response (status/error) | httpserve.NewResponse(status, "CODE") / NewResponseWithData(status,"CODE",data) → {status_code, message, data} ; message = UPPER_SNAKE code | เหมือนกัน |
| Path casing | kebab-case (all-sub-categories, flash-sale-product) + บาง snake (product_groups, purchase_order) ; param :id :barcode | kebab-case เป็นหลัก |
| Verbs | Get/Post/Put/Delete/Patch ; Put /activate/:id /deactivate/:id สำหรับเปลี่ยนสถานะ | ทำตาม pattern เดียวกัน |
| Health | GET /health | เหมือนกัน |
| ไม่มีในของเดิม | Idempotency-Key / X-Request-ID / correlation id | orchestration เพิ่มใหม่ (cross-tenant saga ต้องใช้) |
ของเดิมที่ orchestration reuse ตรงๆ (พบใน route จริง):
external/logistics/purchase-orders,backoffice/purchase→ ออก PO รวม (OM-2)external/logistics/delivery,external/logistics/shipments,vansales/delivery→ delivery/shipment (OM-4)backoffice/returns,ordering/returns,vansales/returns→ reverse logisticsmaster_sku_id(GetMasterSkuPaged/master-detail),pricetiers→ Master Catalog (OM-2/3)tccroutinglibrary + claimroute_planner_id→ matching (OM-4)- flag
new_arch(JWT) → Transition/Coexistence (เปิด orchestration ทีละ tenant)
B. Conventions (native flow-api)
Base URL & Mount point
https://ttmart-gateway.flow-solution.co/api/v1/orchestration
- mount แบบเดียวกับ
flow-api:api := app.Group("api/v1")แล้วapi.Group("orchestration/...") - versioning อยู่ใน path (
api/v1) ตาม pattern เดิม; breaking →api/v2 - ทุก response
application/json; charset=utf-8
Route group ใหม่ (ทำตาม pattern channel เดิม)
// orchestration-service/startup/orchmodule/routes.go (เสนอ)
api.Group("orchestration/collaborations").Use(middleware.AuthorizationOrchestrationToken()).
Post("/", h.Propose).
Post("/:id/accept", h.Accept).
Put("/:id/revoke", h.Revoke)
Headers
| Header | ของเดิม flow-api | orchestration |
|---|---|---|
Authorization: Bearer <jwt> | ✓ ทุก protected route | ✓ (access / delegation token) |
Idempotency-Key | — (ไม่มี) | ✓ ใหม่ ทุก POST/PUT ที่เปลี่ยนสถานะ cross-tenant |
X-Request-ID | — | ✓ ใหม่ (เข้าสู่ correlation/audit) |
X-On-Behalf-Of | — | tenant ปลายทางของ on-behalf call |
Idempotency-Key/X-Request-ID เป็น ของใหม่ที่ orchestration เพิ่ม เพราะ saga ข้าม tenant ต้อง dedup — tenant layer เดิมไม่ได้ใช้
Path & Query casing (ตามของจริง)
- path segment หลายคำ → kebab-case (
delivery-jobs,shared-skus,combined-po,purchase-orders) - path param →
:id(uuid string เหมือนcustomer_id/supplier_idในของเดิม) - query param → snake_case (
collaboration_id,master_sku_id,status,page,page_size)
Pagination (ตาม flow-api: page/limit, payload ดิบ)
ของเดิมใช้ GetSkuPaged style — query page/limit แล้วคืน object ที่มี total + list ดิบ:
GET .../collaborations?status=active&page=1&page_size=50
{ "total": 128, "page": 1, "page_size": 50, "data": [ { ... } ] }
Health check
Liveness/readiness probe — pattern เดียวกับ
GET /healthของ flow-api เดิม (ไม่ต้องใช้ token)
GET /health
Auth: public (no token required) — liveness/readiness probe
Response 200
{ "status": "ok", "service": "orchestration-service", "version": "3.1.0", "timestamp": "2026-06-10T08:00:00Z" }
หมายเหตุ pagination: flow-api เดิมบางจุดใช้
page/limit— orchestration standardize เป็นpage/page_sizeทุก endpoint (gateway/adapter map เป็นlimitของเดิมได้)
Idempotency-Key semantics (นิยามให้ชัด)
- บังคับกับทุก
POST/PUTที่เปลี่ยนสถานะ cross-tenant · key = UUID ที่ client สร้าง · เก็บในprocessed_events(TTL 24 ชม.) - key เดิม + payload เดิม → คืน ผลลัพธ์เดิม (replay — ไม่ทำงานซ้ำ ไม่สร้าง resource ซ้ำ)
- key เดิม + payload ต่างกัน →
409 IDEMPOTENCY_KEY_REUSED - ไม่ส่ง key ใน endpoint ที่บังคับ →
400 IDEMPOTENCY_KEY_REQUIRED
C. Authentication & Authorization
ต่อยอด jwthandler + middleware เดิมของ flow-api (ไม่สร้าง IdP ใหม่)
Token / Middleware (mirror pattern เดิม)
| Token | Middleware (เสนอ ตาม naming เดิม) | claims |
|---|---|---|
| Orchestration access | AuthorizationOrchestrationToken() | supplier_id, supplier_ids[], user_id, roles |
| Delegation token (ทำแทน) | ตรวจใน on-behalf call | acting_for, on_behalf_of, grant_id, scope[], exp, sig |
| Carrier (OM-4, actor ใหม่) | AuthorizationCarrierToken() | carrier_id, roles, exp |
| External logistics (reuse) | AuthorizationExternalToken() (มีอยู่แล้ว) | — |
ขอ Delegation Token (Token Exchange)
ออกผ่าน Keycloak Token Exchange — orchestrator ใช้ก่อนเรียก on-behalf API ของ tenant อีกราย (ดู On-behalf → Tenant Route Mapping)
Use case context: ทุก on-behalf call ใน UC-1.1 (reserve stock B), UC-2.4 (สร้าง inbound), UC-3.2 (สร้าง linked SKU), UC-3.3 (sync), UC-5.2 (returns) ต้องถือ token นี้ — tenant service ตรวจซ้ำชั้นที่ 2 (defense in depth)
TTL: 300s (default) — ขอใหม่ได้ต่อ saga step · เมื่อ grant ถูก revoke → token ถูกปฏิเสธ < 5s (BR-04)
POST /auth/delegation-tokens
ออกผ่าน Keycloak Token Exchange; orchestrator ใช้ก่อนเรียก on-behalf API ของ tenant อีกราย (ดู §E)
Headers: Authorization: Bearer <orchestration-token> · Idempotency-Key · X-Request-ID
Request
{
"grant_id": "grant_01H8Y...",
"on_behalf_of": "supplier_B",
"acting_for": "supplier_A",
"scope": ["inventory:reserve", "inventory:read_availability"],
"resource_filter": { "sku_ids": ["sku_123"] }
}
Response 200
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_at": "2026-06-10T08:35:00Z",
"grant_id": "grant_01H8Y...",
"scope": ["inventory:reserve", "inventory:read_availability"]
}
Errors
{ "status_code": 403, "message": "GRANT_DENIED", "data": { "reason": "no_active_collaboration" } }
422 SCOPE_INVALID · 410 GRANT_REVOKED
TTL & revocation: delegation token อายุสั้น (default 300s — ขอใหม่ได้ต่อ saga step) ผูก
grant_idเสมอ; เมื่อ grant ถูก revoke token ที่ยังไม่หมดอายุต้องถูกปฏิเสธผ่าน grant-status check ฝั่ง tenant (สอดคล้อง BR-04 ตัดสิทธิ์ < 5s) — ห้าม cache ผล verify นานกว่า 5s
Enforce 2 ชั้น (defense in depth)
- orchestrator ตรวจ collaboration active? + resource_filter + action ก่อนออก token
- tenant service (flow-api) ตรวจ token ซ้ำผ่าน middleware ใหม่ก่อนทำงานจริง — แบบเดียวกับที่
jwthandler.GetSupplierIds(c)filter query วันนี้ แต่ได้ scope จาก delegation แทน
- Revocation: grant cache invalidate < 5s (BR-04)
D. Response & Error Envelope
ทำตาม flow-api เป๊ะ — 2 รูปแบบ:
1) Data GET → payload ดิบ (ไม่มี wrapper)
เหมือน c.JSON(result) ของเดิม:
{ "collaboration_id": "collab_01H...", "status": "active", "type": "stockist", "grants": [ ... ] }
2) Status / Error → httpserve.Response wrapper
ตรงกับ httpserve.NewResponse(status, "CODE") / NewResponseWithData(status, "CODE", data) ของจริง:
{ "status_code": 409, "message": "RESERVE_FAILED", "data": { "message": "สต็อกไม่พอ", "reassigned_to": "supplier_C" } }
message= UPPER_SNAKE error code (ตรงกับของจริง เช่นREWARD_LIMIT_EXCEEDED)data= รายละเอียดเสริม (รวมข้อความภาษาไทยสำหรับผู้ใช้ ตาม pattern เดิม)
HTTP status
200/201 สำเร็จ · 202 รับเข้า saga (cross-tenant async) · 400/401/403/404 · 409/422 ขัดสถานะ/business rule · 429/5xx
Error code catalog (message string)
| Code | HTTP | UC |
|---|---|---|
GRANT_DENIED | 403 | ทุก cross-tenant |
GRANT_REVOKED | 410 | F2, 3.4 |
SCOPE_INVALID | 422 | F1, 3.1 |
IDEMPOTENCY_KEY_REUSED | 409 | ทุก mutating |
NEEDS_REVIEW | 409 | F3 |
CATALOG_NOT_MAPPED | 422 | F3, 2.2, 3.1 |
RESERVE_FAILED | 409 | 1.1 |
SLA_TIMEOUT | 408 | 1.1, 4.3 |
MOQ_NOT_MET | 422 | 2.3 |
WINDOW_CLOSED | 422 | 2.2, 2.3 |
PRICE_POLICY_VIOLATION | 422 | 3.2 |
NO_CARRIER_AVAILABLE | 409 | 4.3 |
OFFER_EXPIRED | 409 | 4.3 |
DOUBLE_ASSIGN | 409 | 4.3 |
KYC_FAILED | 422 | 4.1 |
DISPUTED_EXCLUDED | 409 | F4 |
INVALID_STATE | 409 | ทุก state-changing (สถานะปัจจุบันไม่อนุญาต action นั้น) |
INVALID_TRANSITION | 409 | 1.3, 4.4, 5.2 (transition ข้ามขั้น — ดู §G) |
INVALID_WINDOW | 422 | 2.1 |
INCOMPLETE_ADDRESS | 422 | 4.2 |
SKU_ALREADY_IMPORTED | 409 | 3.2 |
NO_STOCKIST_AVAILABLE | 409 | 1.1 |
POD_REQUIRED | 422 | 4.4 |
CARRIER_NOT_ELIGIBLE | 422 | 4.3 |
IDEMPOTENCY_KEY_REQUIRED | 400 | ทุก mutating ที่บังคับ key |
VALIDATION_ERROR | 400 | ทุก endpoint (payload ไม่ครบ/ผิด type) |
UNAUTHORIZED | 401 | ทุก protected route (token หมดอายุ/ไม่มี) |
FORBIDDEN | 403 | role ไม่ตรง (เช่น operator-only) |
NOT_FOUND | 404 | resource ไม่มี หรืออยู่นอก scope (ไม่ leak ว่ามีอยู่) |
RATE_LIMITED | 429 | ทุก endpoint (Retry-After header) |
โครงสร้างทุก UC: อธิบาย use case → API endpoints (native paths) → Acceptance (Gherkin ย่อ)
E. On-behalf → Tenant Route Mapping
หัวใจของ AP-4: orchestrator ไม่แตะ DB ข้าม tenant แต่เรียก route เดิมของ flow-api ด้วย delegation token แทนเจ้าของ (on_behalf_of)
| orchestration action | เรียก tenant route เดิม (flow-api) | token |
|---|---|---|
| reserve/confirm stock ของ B (OM-1) | inventory-service on-behalf (inventory:reserve) | delegation(on_behalf_of=B) |
| relay สถานะกลับ order ของ A | order-service ordering/orders | delegation(on_behalf_of=A) |
| ออก PO รวม (OM-2) | external/logistics/purchase-orders (มีอยู่) | external/delegation |
| สร้าง linked SKU ในสโคป A (OM-3) | backoffice/skus (CreateSkuWithPrice) | delegation(on_behalf_of=A) |
| sync product (OM-3) | backoffice/skus (UpdateSkuWithPrice) | delegation(on_behalf_of=A) |
| delivery/shipment (OM-4) | external/logistics/delivery · /shipments (มีอยู่) | external |
| route estimate (OM-4) | tccrouting library + claim route_planner_id | service |
| คืนสินค้า (reverse) | ordering/returns · backoffice/returns (มีอยู่) | delegation |
F. สถาปัตยกรรมการเชื่อม Tenant ↔ Orchestration
F.1 ภาพรวม 3-Plane (วาง orchestration บน flow-backend เดิม)
orchestration ถูกวางเป็น Platform Core / Global Plane แยกจาก tenant services เดิม (flow-api) ซึ่งยังเป็น single-owner ไม่เปลี่ยน — เชื่อมกันด้วย 2 ช่องทางเท่านั้น: (1) async event ผ่าน Outbox→CDC→Kafka, (2) sync on-behalf REST ด้วย delegation token
กติกาการเชื่อม (บังคับ):
- tenant service ไม่เรียก orchestration ตรง — มันแค่ปล่อย domain event ผ่าน outbox (DB = source of truth, Kafka = derived)
- orchestration ไม่ query DB ของ tenant — เขียน/อ่านผ่าน on-behalf REST (เส้น 2) เท่านั้น (AP-4) โดยถือ delegation token ที่ผูก
grant_id+on_behalf_of - ไม่มี FK ข้าม boundary — orchestration อ้าง entity ของ tenant ด้วยคู่
(supplier_id, entity_id)แล้ว validate ที่ app layer + reconcile ด้วย event (OI-1: เริ่มที่ schema แยกใน DB เดิม — ADR-07)
F.2 ลำดับการเชื่อมแบบ end-to-end (OM-1 เป็นตัวอย่าง)
แสดงครบทั้ง event path + grant check 2 ชั้น + on-behalf write + compensation:
F.3 Topology & Coexistence (อิง code: flag new_arch)
- claim/flag
new_archที่มีอยู่จริงใน JWT (jwthandler.GetNewArch(c)) เป็นสวิตช์เปิด orchestration ต่อ tenant — เริ่ม shadow แล้วค่อย enforce (AP-7 fallback-to-self) - DB topology (ADR-07/OI-1): เริ่มด้วย schema
orchestrationแยกใน MySQL instance เดิม ของ flow → ใช้ transaction/outbox ร่วม, migrate ง่าย; แยก instance ภายหลังเมื่อต้อง scale อิสระ
ส่วนที่ 2 — Foundation + Operating Models (เรื่องที่ 0–5)
เรียงตามลำดับการทำงานจริง — เริ่มจากการตั้งความร่วมมือ (Foundation) ผ่านการส่งแทน, ซื้อรวม, ขายแทน, หาคนส่ง และจบที่เงินกับขาคืน ทุกเรื่องเปิดด้วยปัญหาธุรกิจ ตามด้วย use case, diagram และ API
เรื่องที่ 0 — Foundation
0.0 ทำไมต้องมี Foundation
ข้อจำกัดแกน (เอกสารหลัก §2 + ยืนยันจาก code): ทุก query ใน flow-api ถูก filter ด้วย jwthandler.GetSupplierIds(c) → single-owner ผูก supplier_id. ทั้ง 4 OM เกี่ยวข้อง ≥ 2 supplier ต่อ transaction การ "แก้ตรงๆ" ทำลาย isolation จึงต้อง "เชื่อม ไม่รวม" ผ่าน Foundation 4 ตัว:
0.1 UC-F1 — Establish Collaboration
Foundation · P0 · M
- Actors: Initiator Supplier(A), Counterparty Supplier(B), Operator
- Pre: ทั้งสอง supplier active
- Post: collaboration
active+ grant + eventCollaborationActivated - Trigger: initiator เสนอ
- Main: เลือกประเภท+counterparty → ระบุ scope+terms+ช่วงเวลา → validate
pending→ แจ้ง B → accept → (ถ้าเข้าเกณฑ์) operator อนุมัติ →active+ ออก grant + event - Alternate: ไม่ต้องอนุมัติ → ข้าม
- Exception:
SCOPE_INVALID; decline →rejected; timeout →expired - Rules: BR-02 (default deny), BR-03 (least privilege)
- หัวใจ: ทุก action ข้าม tenant อ้าง collaboration active เสมอ
APIs ในหัวข้อนี้
POST /api/v1/orchestration/collaborations— เสนอความร่วมมือ →pendingPOST /collaborations/:id/accept— counterparty ตอบรับ →active(+grant)POST /collaborations/:id/decline— ปฏิเสธ →rejectedPUT /collaborations/:id/approve— operator อนุมัติ →activeGET /collaborations— รายการ (paged)GET /collaborations/:id— รายละเอียดGET /grants/:id— รายละเอียด grantGET /grants— รายการ grant (paged)
เสนอความร่วมมือ → pending
UC-F1 ขั้น 1: initiator (A) เสนอความร่วมมือ — เลือกประเภท + counterparty → ระบุ scope (least privilege, BR-03) + terms + ช่วงเวลา → validate →
pending→ ระบบแจ้ง counterparty (B)scope ต้องอ้างเฉพาะ resource ของ initiator เท่านั้น มิฉะนั้น
SCOPE_INVALID
POST /api/v1/orchestration/collaborations
Headers: Authorization: Bearer <jwt> · X-Request-ID · Idempotency-Key
Request
{
"type": "stockist",
"counterparty_supplier_id": "supplier_B",
"scope": {
"sku_ids": ["sku_123"],
"actions": ["inventory:reserve", "inventory:read_availability"],
"resource_filter": { "region": "TH-C" }
},
"terms": { "fee_model": "percentage", "fee_value": 3.5, "sla": { "accept_timeout_sec": 600 } },
"valid_from": "2026-06-11T00:00:00Z",
"valid_to": "2026-12-31T23:59:59Z"
}
Response 201
{
"collaboration_id": "collab_01H8X...",
"type": "stockist",
"initiator_supplier_id": "supplier_A",
"counterparty_supplier_id": "supplier_B",
"status": "pending",
"requires_operator_approval": false,
"valid_from": "2026-06-11T00:00:00Z",
"valid_to": "2026-12-31T23:59:59Z",
"created_date": "2026-06-10T08:30:00Z"
}
Errors
{ "status_code": 422, "message": "SCOPE_INVALID", "data": { "field": "scope.sku_ids[0]", "reason": "not_owned_by_initiator" } }
409 IDEMPOTENCY_KEY_REUSED · 403 GRANT_DENIED
counterparty ตอบรับ → active (+grant)
UC-F1 ขั้น 2: counterparty (B) ตอบรับข้อเสนอ → ถ้าไม่ต้องอนุมัติ →
activeทันที + ออก grant อัตโนมัติตาม scope + emitCollaborationActivated— ถ้าเข้าเกณฑ์ต้องอนุมัติ → รอ operator (PUT /collaborations/:id/approve)
POST /collaborations/:id/accept
Request: ไม่มี body
Response 200
{
"collaboration_id": "collab_01H8X...",
"status": "active",
"grants": [
{ "grant_id": "grant_01H8Y...", "grantee_supplier_id": "supplier_A", "on_behalf_of_supplier_id": "supplier_B", "actions": ["inventory:reserve", "inventory:read_availability"], "status": "active" }
],
"activated_at": "2026-06-10T09:00:00Z"
}
Errors: 409 INVALID_STATE (ไม่อยู่ pending) · 403 GRANT_DENIED
ปฏิเสธ → rejected
UC-F1 exception: counterparty ปฏิเสธข้อเสนอ →
rejected(จบ flow)
POST /collaborations/:id/decline
Request
{ "reason": "ไม่ตรงเงื่อนไขค่าธรรมเนียม" }
Response 200
{ "collaboration_id": "collab_01H8X...", "status": "rejected", "updated_date": "2026-06-10T09:05:00Z" }
Errors: 409 INVALID_STATE
operator อนุมัติ → active
UC-F1 ขั้นเสริม: กรณีเข้าเกณฑ์ต้องอนุมัติ (เช่น วงเงินเกินเกณฑ์) operator เป็นผู้ปลดล็อกเป็น
active+ ออก grant
PUT /collaborations/:id/approve
Request
{ "approved_by": "operator_07", "note": "วงเงินเกินเกณฑ์ ต้องอนุมัติ" }
Response 200
{ "collaboration_id": "collab_01H8X...", "status": "active", "approved_by": "operator_07", "grants": [ { "grant_id": "grant_01H8Y...", "status": "active" } ] }
Errors: 403 FORBIDDEN (ไม่ใช่ operator) · 409 INVALID_STATE
รายการ (paged)
GET /collaborations
Query: status type counterparty page page_size
Response 200
{
"total": 12, "page": 1, "page_size": 50,
"data": [
{ "collaboration_id": "collab_01H8X...", "type": "stockist", "counterparty_supplier_id": "supplier_B", "status": "active", "valid_to": "2026-12-31T23:59:59Z" }
]
}
รายละเอียด
GET /collaborations/:id
Response 200
{
"collaboration_id": "collab_01H8X...",
"type": "stockist",
"initiator_supplier_id": "supplier_A",
"counterparty_supplier_id": "supplier_B",
"scope": { "sku_ids": ["sku_123"], "actions": ["inventory:reserve","inventory:read_availability"], "resource_filter": { "region": "TH-C" } },
"terms": { "fee_model": "percentage", "fee_value": 3.5, "sla": { "accept_timeout_sec": 600 } },
"status": "active",
"valid_from": "2026-06-11T00:00:00Z",
"valid_to": "2026-12-31T23:59:59Z",
"created_date": "2026-06-10T08:30:00Z",
"updated_date": "2026-06-10T09:00:00Z"
}
Errors: 404 NOT_FOUND (ไม่อยู่ใน scope ที่เห็นได้)
รายละเอียด grant
GET /grants/:id
Response 200
{
"grant_id": "grant_01H8Y...",
"collaboration_id": "collab_01H8X...",
"grantee_supplier_id": "supplier_A",
"on_behalf_of_supplier_id": "supplier_B",
"actions": ["inventory:reserve", "inventory:read_availability"],
"resource_filter": { "sku_ids": ["sku_123"] },
"status": "active",
"created_date": "2026-06-10T09:00:00Z"
}
รายการ grant (paged)
GET /grants
Query: collaboration_id status on_behalf_of page page_size
Response 200
{
"total": 2, "page": 1, "page_size": 50,
"data": [
{ "grant_id": "grant_01H8Y...", "collaboration_id": "collab_01H8X...", "grantee_supplier_id": "supplier_A", "on_behalf_of_supplier_id": "supplier_B", "actions": ["inventory:reserve"], "status": "active" }
]
}
Acceptance: A,B active + scope อ้าง SKU ของ A เท่านั้น → B รับ (+อนุมัติถ้าจำเป็น) → active + grant + event · scope ผิด → SCOPE_INVALID · B decline → rejected
0.2 UC-F2 — Revoke / Suspend Collaboration
Foundation · P0 · M
- Actors: ฝ่ายใดฝ่ายหนึ่ง, Operator
- Pre: active/suspended
- Post: revoked/suspended + grant ใหม่ถูกปฏิเสธ + in-flight ตาม policy +
CollaborationRevoked - Main: revoke/suspend + เหตุผล → กำหนด effective + policy in-flight → invalidate grant → จัดการ in-flight (honor/compensate) → event + audit
- Alternate: suspend → resume
- Exception: compensation ไม่ได้ → flag operator
- Rules: BR-04,06
- NFR: ตัดสิทธิ์ < 5s
APIs ในหัวข้อนี้
PUT /collaborations/:id/revoke— เพิกถอน →revokedPUT /collaborations/:id/suspend— พักชั่วคราว →suspendedPUT /collaborations/:id/resume— กลับมาใช้งาน →active
เพิกถอน → revoked
UC-F2 main flow: เพิกถอน + เหตุผล → กำหนด effective + policy in-flight → invalidate grants < 5 วินาที (BR-04) → จัดการ in-flight (
honorทำต่อจนจบ /compensateชดเชยกลับ) → emitCollaborationRevoked+ audit · ตอบ202เพราะ in-flight handling เป็น async (AP-5)
PUT /collaborations/:id/revoke
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "reason": "SLA repeatedly breached", "effective": "immediate", "in_flight_policy": "honor" }
Response 202
{
"collaboration_id": "collab_01H8X...",
"status": "revoked",
"grants_invalidated": 1,
"in_flight_policy": "honor",
"in_flight_count": 2,
"effective_at": "2026-06-10T10:00:00Z"
}
Errors: 409 INVALID_STATE (ไม่อยู่ active/suspended) · 403 FORBIDDEN
พักชั่วคราว → suspended
UC-F2 alternate: พักชั่วคราว (เช่น ระหว่างตรวจสอบข้อพิพาท) — grant ถูก invalidate แต่ resume กลับได้
PUT /collaborations/:id/suspend
Request
{ "reason": "ตรวจสอบข้อพิพาทชั่วคราว" }
Response 200
{ "collaboration_id": "collab_01H8X...", "status": "suspended", "grants_invalidated": 1, "updated_date": "2026-06-10T10:05:00Z" }
Errors: 409 INVALID_STATE
กลับมาใช้งาน → active
UC-F2 alternate: จาก
suspendedกลับเป็นactive— grant ถูก restore
PUT /collaborations/:id/resume
Request: ไม่มี body
Response 200
{ "collaboration_id": "collab_01H8X...", "status": "active", "grants_restored": 1, "updated_date": "2026-06-10T10:10:00Z" }
Errors: 409 INVALID_STATE (ไม่อยู่ suspended)
Acceptance: A เพิกถอน → cross-tenant request ใหม่ของ B ถูกปฏิเสธ (GRANT_REVOKED) ภายใน 5 วินาที + audit · suspended → resume → grant กลับมา
0.3 UC-F3 — Map SKU to Master Catalog
Foundation · P0 · M · ต่อยอด
skus.master_sku_id(มีอยู่ใน flow-api)
- Actors: Supplier, Identity Resolution Engine
- Pre: SKU มี barcode/attr
- Post: SKU มี
master_sku_id+SkuMappedToCatalog - Main: รับ SKU → ค้น GTIN → unique ผูก / ไม่พบ สร้าง+ผูก → event
- Exception: กำกวม →
needs_review; ไม่มี barcode → fuzzy+ยืนยัน - Rules: BR-01 (ผูก ไม่โอน)
- ทำไม: OM-2/OM-3 ต้องรู้ว่า SKU ต่าง supplier = สินค้าเดียว · (ของเดิมมี
GetMasterSkuPaged,master-detailอยู่แล้ว — ต่อยอด)
APIs ในหัวข้อนี้
POST /catalog/map— map SKU เดียวเข้า Master CatalogPOST /catalog/map/batch— map แบบ batch (async)GET /catalog/map/batch/:job_id— สถานะ batchGET /catalog/items/:master_sku_id— golden recordGET /catalog/review-queue— คิวที่ต้อง steward review (paged)PUT /catalog/items/:id/resolve— steward ตัดสิน (ผูก/merge, reversible+audit)POST /catalog/unmap— ถอด mapping (reversible + audit)
map SKU เดียวเข้า Master Catalog
UC-F3 main flow: รับ SKU → ค้น GTIN → unique ผูกของเดิม / ไม่พบ สร้าง catalog_item ใหม่ + ผูก → set
master_sku_id+ emitSkuMappedToCatalogException: กำกวม (หลาย candidate ใกล้เคียง) →
409 NEEDS_REVIEWเข้าคิว steward (GET /catalog/review-queue) · ไม่มี barcode → fuzzy match + ยืนยันRules: BR-01 (ผูก ไม่โอน — identity mapping ไม่ใช่ ownership transfer)
POST /catalog/map
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{
"supplier_id": "supplier_A",
"sku_id": "sku_123",
"barcode": "8850001234567",
"attributes": { "title": "นมจืด 200ml", "brand": "Brand X", "size": "200ml" }
}
Response 200 (พบ GTIN unique / สร้างใหม่)
{
"supplier_id": "supplier_A",
"sku_id": "sku_123",
"master_sku_id": "cat_01H8Z...",
"match_method": "deterministic_gtin",
"status": "mapped",
"created_date": "2026-06-10T08:40:00Z"
}
Response 409 (กำกวม → needs_review)
{
"status_code": 409,
"message": "NEEDS_REVIEW",
"data": {
"sku_id": "sku_123",
"candidates": [
{ "master_sku_id": "cat_01A...", "title": "นมจืด 200ml", "score": 0.82 },
{ "master_sku_id": "cat_01B...", "title": "นมจืด 200มล", "score": 0.79 }
]
}
}
map แบบ batch (async)
UC-F3 (batch): map หลาย SKU พร้อมกัน — ตอบ
202+job_idแล้ว poll สถานะ (AP-5)
POST /catalog/map/batch
Request
{ "supplier_id": "supplier_A", "items": [ { "sku_id": "sku_123", "barcode": "8850001234567" }, { "sku_id": "sku_124", "barcode": "8850009999999" } ] }
Response 202
{ "job_id": "mapjob_01H...", "status": "processing", "total": 2 }
สถานะ batch
GET /catalog/map/batch/:job_id
Response 200
{
"job_id": "mapjob_01H...",
"status": "completed",
"total": 2, "mapped": 1, "created": 0, "needs_review": 1,
"results": [
{ "sku_id": "sku_123", "master_sku_id": "cat_01H8Z...", "status": "mapped" },
{ "sku_id": "sku_124", "status": "needs_review" }
]
}
golden record
golden record + รายการ SKU ทุก supplier ที่ผูกอยู่ — ฐานของ OM-2/OM-3 ("SKU ต่าง supplier = สินค้าเดียวกัน")
GET /catalog/items/:master_sku_id
Response 200
{
"master_sku_id": "cat_01H8Z...",
"gtin": "8850001234567",
"title": "นมจืด 200ml",
"brand": "Brand X",
"attributes": { "size": "200ml" },
"status": "active",
"mapped_skus": [ { "supplier_id": "supplier_A", "sku_id": "sku_123" }, { "supplier_id": "supplier_B", "sku_id": "sku_900" } ]
}
Errors: 404 NOT_FOUND
คิวที่ต้อง steward review (paged)
คิว mapping กำกวมรอ data steward ตัดสิน — backing store = ตาราง
orchestration.catalog_review_queue(ใหม่ v3.1)
GET /catalog/review-queue
Query: page page_size
Response 200
{
"total": 3, "page": 1, "page_size": 50,
"data": [ { "sku_id": "sku_124", "supplier_id": "supplier_A", "candidates": [ { "master_sku_id": "cat_01A...", "score": 0.81 } ] } ]
}
steward ตัดสิน (ผูก/merge, reversible+audit)
UC-F3 (steward): ตัดสินรายการกำกวม —
action:link(ผูกกับ candidate) ·create(สร้าง catalog ใหม่) ·merge(รวม 2 catalog_item — reversible ผ่านคอลัมน์merged_into) — ทุกการตัดสินทิ้ง audit trail
PUT /catalog/items/:id/resolve
Request
{ "action": "link", "sku_id": "sku_124", "master_sku_id": "cat_01A...", "resolved_by": "steward_02" }
Response 200
{ "sku_id": "sku_124", "master_sku_id": "cat_01A...", "match_method": "manual", "status": "mapped" }
Errors: 403 FORBIDDEN · 409 INVALID_STATE
ถอด mapping (reversible + audit)
ถอด
master_sku_idออกจาก SKU (เช่น map ผิดสินค้า) — reversible + audit · ถ้า SKU ถูกใช้ใน campaign/shared-sku grant ที่ active อยู่ →409 INVALID_STATE(ต้องปิดก่อน) — endpoint เพิ่มใน v3.1
POST /catalog/unmap
Request
{ "supplier_id": "supplier_A", "sku_id": "sku_123", "reason": "map ผิดสินค้า" }
Response 200
{ "supplier_id": "supplier_A", "sku_id": "sku_123", "previous_master_sku_id": "cat_01H8Z...", "status": "unmapped" }
Errors: 404 NOT_FOUND · 409 INVALID_STATE (SKU ถูกใช้ใน campaign/shared-sku grant ที่ active อยู่ — ต้องปิดก่อน)
Acceptance: barcode X มี catalog_item → ผูกของเดิม · barcode Y ใหม่ → สร้าง+ผูก · กำกวม → needs_review
0.4 UC-F4 — Settlement Run
Foundation · P0 · L ·
settlement_ledger
- Actors: Operator, Settlement Engine
- Pre: ledger
accrued - Post: invoice/payout
invoiced→settled - Main: รวบรวมตามรอบ/คู่ค้า → คำนวณสุทธิ + VAT netting → ออกเอกสาร + sync bplus/vsms/certu → invoiced → ชำระ → settled
- Alternate: disputed → กันออก
- Rules: BR-07,08
- NFR: ถูกต้อง 100% + reconcile, idempotent ต่อรอบ · (detail §5.1)
รากฐานพร้อมแล้ว: ความร่วมมือมี consent, สินค้ามีตัวตนกลาง, เงินมีบัญชีแยกประเภท — เรื่องที่ 1 คือ OM แรกที่ใช้ทั้งหมดนี้จริง: ให้คลังของ B ส่งของแทน A
เรื่องที่ 1 — OM-1 Stockist
1.0 ปัญหา & ภาพรวม OM-1
ปัญหา 1 — Fulfillment Reach (ส่งไม่ถึง / คลังที่มีอยู่ก็ไม่ได้ใช้): แต่ละ supplier ลงทุนคลังและรถส่งเอง ใช้เครือข่ายกันไม่ได้ → ถูกจำกัดพื้นที่/ความเร็ว ขณะที่คลังกำลังเหลือก็หารายได้เพิ่มไม่ได้
ทางแก้ (OM-1 Stockist): Supplier A ขาย, Supplier B fulfill/ส่ง — reuse order-service (เจ้าของออเดอร์ A), inventory-service (on-behalf reserve ที่ B), และต่อ OM-4 เมื่อ packed
Isolation: A เห็นแค่สถานะ + availability ของ SKU ใน scope (ไม่เห็นสต็อกเต็มของ B); B เห็นแค่ order line + ที่อยู่ (ไม่เห็นราคา/ลูกค้าของ A) — บังคับด้วย field-projection แบบเดียวกับที่ของเดิม filter ด้วย supplier_ids
1.1 UC-1.1 — Assign Fulfillment to Stockist
OM-1 · P0 · XL · entity
fulfillment_assignment(อ้าง sale_order, inventory)
- Actors: Seller A, Stockist B, Engine, Inventory(B)
- Pre: collaboration stockist active; SKU ใน scope; grant
reserve+read_availability - Post(success): assignment + reserve B + สถานะเริ่มไหล · Minimal: reserve ไม่ได้ → ไม่มี assignment ผูก stock ค้าง · Trigger: event
SaleOrderCreated - Main: A สร้าง order → event → routing เลือก B+คลัง → assignment
assigned→ reserve(B) on-behalf →StockReserved→ accept→picking→packed→shipped → relay A → delivered →SettlementAccrued - Alternate: หลาย stockist → strategy; packed → สร้าง delivery job (OM-4)
- Exception: reserve fail → reassign/fallback A; reject ใน SLA → reassign; ยกเลิกก่อน ship → compensation; orchestration ล่ม → ขายต่อได้+reconcile (AP-7)
- Saga:
assign → reserve(B)[comp: release] → accept → pick → pack → ship → deliver → settle· NFR: routing < 2s, reserve idempotent
APIs ในหัวข้อนี้
POST /fulfillment/assignments— สร้าง assignment (saga entrypoint)GET /fulfillment/assignments/:id— รายละเอียด (full view: orchestrator/operator)GET /fulfillment/assignments— รายการ assignment (paged, มุมมองตาม role)PUT /fulfillment/assignments/:id/reassign— บังคับ reassign (operator)PUT /fulfillment/assignments/:id/cancel— ยกเลิก (compensation: release reservation)
สร้าง assignment (saga entrypoint)
UC-1.1 entrypoint: ปกติ orchestrator สร้างอัตโนมัติจาก event
SaleOrderCreated; endpoint นี้สำหรับ replay/manualSaga:
assign → reserve(B)[comp: release] → accept → pick → pack → ship → deliver → settle· ตอบ202แล้วติดตามผ่าน event/polling (AP-5) · routing เลือก stockist + คลังด้วยtccrouting(< 2s)ก่อน reserve มี grant check 2 ชั้น: orchestrator ตรวจ collaboration active + scope → ออก delegation token (on_behalf_of=B) → inventory-service (B) ตรวจ token ซ้ำแล้วจึง reserve
POST /fulfillment/assignments
ปกติ orchestrator สร้างจาก event SaleOrderCreated; endpoint นี้สำหรับ replay/manual
Headers: Authorization · X-Request-ID · Idempotency-Key
Request
{ "sale_order_id": "ord_A_001", "owner_supplier_id": "supplier_A", "routing_strategy": "nearest" }
Response 202
{
"assignment_id": "fa_01H9A...",
"sale_order_id": "ord_A_001",
"owner_supplier_id": "supplier_A",
"stockist_supplier_id": "supplier_B",
"warehouse_id": "wh_9",
"status": "assigned",
"routing_strategy": "nearest",
"correlation_id": "saga_01H9A...",
"created_date": "2026-06-10T11:00:00Z"
}
Response 409 (reserve fail → saga reassign/fallback)
{ "status_code": 409, "message": "RESERVE_FAILED", "data": { "tried_supplier_id": "supplier_B", "reassigned_to": "supplier_C", "fallback_to_self": false } }
Errors: 403 GRANT_DENIED · 409 IDEMPOTENCY_KEY_REUSED
รายละเอียด (full view: orchestrator/operator)
GET /fulfillment/assignments/:id
Response 200
{
"assignment_id": "fa_01H9A...",
"sale_order_id": "ord_A_001",
"owner_supplier_id": "supplier_A",
"stockist_supplier_id": "supplier_B",
"warehouse_id": "wh_9",
"status": "picking",
"correlation_id": "saga_01H9A...",
"timeline": [
{ "status": "assigned", "at": "2026-06-10T11:00:00Z" },
{ "status": "accepted", "at": "2026-06-10T11:05:00Z" },
{ "status": "picking", "at": "2026-06-10T11:10:00Z" }
],
"created_date": "2026-06-10T11:00:00Z",
"updated_date": "2026-06-10T11:10:00Z"
}
Errors: 404 NOT_FOUND
รายการ assignment (paged, มุมมองตาม role)
Projection ตาม role (BR-05): seller เห็นเฉพาะของออเดอร์ตน (ไม่เห็น
warehouse_id), stockist เห็นเฉพาะงานของตน, operator = full view — list endpoint เพิ่มใน v3.1
GET /fulfillment/assignments
Query: status sale_order_id stockist_supplier_id page page_size
Response 200
{
"total": 9, "page": 1, "page_size": 50,
"data": [
{ "assignment_id": "fa_01H9A...", "sale_order_id": "ord_A_001", "stockist_supplier_id": "supplier_B", "status": "picking", "created_date": "2026-06-10T11:00:00Z" }
]
}
บังคับ reassign (operator)
UC-1.1 exception: B ไม่ accept ใน SLA / สต็อกไม่พอ → operator บังคับเลือก stockist ใหม่ ·
409 NO_STOCKIST_AVAILABLEเมื่อไม่มีตัวเลือก → fallback A ส่งเอง (AP-7)
PUT /fulfillment/assignments/:id/reassign
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "reason": "B ไม่ accept ใน SLA", "preferred_supplier_id": null }
Response 202
{ "assignment_id": "fa_01H9A...", "status": "assigned", "stockist_supplier_id": "supplier_C", "reassigned_from": "supplier_B" }
Errors: 409 INVALID_STATE (ship แล้ว reassign ไม่ได้) · 409 NO_STOCKIST_AVAILABLE
ยกเลิก (compensation: release reservation)
UC-1.1 exception (ยกเลิกก่อน ship): trigger saga compensation — ปลด reservation ที่จองไว้ที่ B · ถ้า
shippedแล้ว →409 INVALID_STATEต้องใช้ return flow (UC-5.2) — endpoint เพิ่มใน v3.1
PUT /fulfillment/assignments/:id/cancel
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "reason": "ลูกค้ายกเลิกออเดอร์" }
Response 202
{ "assignment_id": "fa_01H9A...", "status": "cancelled", "reservation_released": true, "canceled_date": "2026-06-10T11:20:00Z" }
Errors: 409 INVALID_STATE (shipped แล้ว → ต้องใช้ return flow)
Acceptance: collaboration active + SKU ใน scope → A สร้าง order → assigned + จองสต็อก B + A เห็นสถานะ · สต็อกไม่พอ → reassign; ไม่มี → fallback A · ยกเลิกก่อนส่ง → ปลดจอง + cancelled · A ดู availability → เห็นเฉพาะจำนวนพร้อมขายของ SKU ใน scope
1.2 UC-1.2 — Track Fulfillment
OM-1 · P1 · S · read-only
- Actors: Seller A
- Pre: มี assignment
- Post: A เห็นสถานะ read-only
- Main: ดึงสถานะ+timeline+tracking → แสดง ETA+เลขพัสดุ
- Exception: ไม่ใช่ของ A → ปฏิเสธ
- Rules: BR-05
APIs ในหัวข้อนี้
GET /fulfillment/assignments/:id/status— ติดตามสถานะ (มุมมอง Seller A, projection)
ติดตามสถานะ (มุมมอง Seller A, projection)
UC-1.2 (read-only): A เห็นแค่สถานะ/timeline/tracking — ไม่เห็น
warehouse_id, สต็อก, หรือข้อมูลภายในของ B (field-projection, BR-05) · assignment ของ supplier อื่น →404 NOT_FOUND(ไม่ leak ว่ามีอยู่)
GET /fulfillment/assignments/:id/status
Response 200
{
"assignment_id": "fa_01H9A...",
"sale_order_id": "ord_A_001",
"status": "shipped",
"timeline": [
{ "status": "assigned", "at": "2026-06-10T11:00:00Z" },
{ "status": "accepted", "at": "2026-06-10T11:05:00Z" },
{ "status": "packed", "at": "2026-06-10T12:00:00Z" },
{ "status": "shipped", "at": "2026-06-10T13:00:00Z" }
],
"tracking": { "carrier": "Flash", "tracking_no": "TH123456789", "eta": "2026-06-12T10:00:00Z" }
}
Errors: 404 NOT_FOUND (assignment ไม่ใช่ของ A — ไม่ leak)
Acceptance: assignment shipped → A เปิด → เห็น shipped+timeline+เลขพัสดุ · ออเดอร์ของ supplier อื่น → ปฏิเสธ
1.3 UC-1.3 — Operate Fulfillment
OM-1 · P0 · M
- Actors: Stockist B, Engine
- Pre: assignment assigned/accepted ของ B
- Post: อัปเดตถึง shipped/delivered + relay
- Main: เห็นคิว (เฉพาะ order line+ที่อยู่) → accept → confirm reservation → pick→pack → ship/trigger OM-4 → event
- Alternate: reject → UC-1.1 exception
- Exception: ของชำรุด/ขาด → shortfall → reassign/partial
- Rules: BR-05
- NFR: idempotent, กัน double-pick
APIs ในหัวข้อนี้
GET /fulfillment/queue— คิวงานของ Stockist B (projection: order line + ที่อยู่)POST /fulfillment/assignments/:id/accept— รับงาน → confirm reservationPOST /fulfillment/assignments/:id/reject— ปฏิเสธ → UC-1.1 reassignPUT /fulfillment/assignments/:id/transition— เปลี่ยนสถานะงาน
คิวงานของ Stockist B (projection: order line + ที่อยู่)
UC-1.3 ขั้น 1: B เห็นคิวงานเฉพาะ order line + ที่อยู่จัดส่ง — ไม่เห็นราคาขาย/ส่วนลด/ชื่อลูกค้าจริงของ A (field-projection, BR-05)
GET /fulfillment/queue
Query: status page page_size
Response 200
{
"total": 5, "page": 1, "page_size": 50,
"data": [
{
"assignment_id": "fa_01H9A...",
"status": "assigned",
"items": [ { "sku_id": "sku_123", "title": "นมจืด 200ml", "qty": 12 } ],
"ship_to": { "name": "ร้านปลายทาง", "address": "...", "subdistrict": "...", "province": "กรุงเทพฯ", "postcode": "10110" }
}
]
}
รับงาน → confirm reservation
UC-1.3 ขั้น 2: B รับงาน → orchestrator confirm reservation ที่ inventory-service (B) on-behalf · เกิน accept window (SLA จาก terms) →
408 SLA_TIMEOUTเพราะถูก reassign ไปแล้ว (UC-1.1 exception)
POST /fulfillment/assignments/:id/accept
Request: ไม่มี body
Response 200
{ "assignment_id": "fa_01H9A...", "status": "accepted", "reservation_confirmed": true, "updated_date": "2026-06-10T11:05:00Z" }
Errors: 409 INVALID_STATE · 408 SLA_TIMEOUT (เกิน accept window → ถูก reassign แล้ว)
ปฏิเสธ → UC-1.1 reassign
UC-1.3 alternate: B ปฏิเสธ (เช่น คลังไม่ว่าง) → กลับเข้า UC-1.1 reassign อัตโนมัติ
POST /fulfillment/assignments/:id/reject
Request
{ "reason": "คลังไม่ว่าง" }
Response 200
{ "assignment_id": "fa_01H9A...", "status": "rejected", "reassign_triggered": true }
เปลี่ยนสถานะงาน
UC-1.3 main flow: B เดินสถานะงานตาม state machine —
to:picking|packed|shipped
packed→ trigger OM-4 (สร้าง delivery job อัตโนมัติ — คืนdelivery_job_id)- ของชำรุด/ขาด → ส่ง
shortfall: { sku_id, missing_qty }→ reassign/partial- ข้ามขั้น (เช่น
assigned → shipped) →409 INVALID_TRANSITION- Idempotency-Key กัน double-pick
Emits:
FulfillmentStatusChanged(relay กลับ order ของ A)
PUT /fulfillment/assignments/:id/transition
Request
{ "to": "packed", "note": "2 boxes", "shortfall": null }
Response 200
{
"assignment_id": "fa_01H9A...",
"status": "packed",
"delivery_job_id": "job_01H9B...",
"updated_date": "2026-06-10T12:00:00Z"
}
Errors: 409 INVALID_TRANSITION (เช่น assigned → shipped ข้ามขั้น) · 409 IDEMPOTENCY_KEY_REUSED (กัน double-pick)
Acceptance: assigned ของ B → accept+pick+pack+ship → shipped + relay A · B ดูรายละเอียด → เห็นเฉพาะ order line + ที่อยู่
ออเดอร์ของ A ถูกส่งถึงมือผู้รับโดยคลังของ B แล้ว — เรื่องที่ 2 เปลี่ยนโจทย์จาก "ขายให้ถึง" เป็น "ซื้อให้ถูก": รวมยอดซื้อข้ามผู้ขายเพื่อแลกราคาที่ดีกว่า
เรื่องที่ 2 — OM-2 Consolidate Purchase
2.0 ปัญหา & ภาพรวม OM-2
ปัญหา 2 — Purchasing Power (ซื้อแพงเพราะต่างคนต่างซื้อ): ผู้ขายแต่ละรายสั่งแยกกัน ไม่ถึง MOQ จึงไม่ได้ราคา tier ดี → ต้นทุนสูง เสียอำนาจต่อรอง
ทางแก้ (OM-2 Consolidate): รวม volume preorder ข้ามผู้ขาย → ออก PO รวมก้อนเดียว (reuse external/logistics/purchase-orders) · ต้องมี Master Catalog (UC-F3)
2.1 UC-2.1 — Open Consolidation Campaign
OM-2 · P1 · M ·
consolidation_campaign
- Actors: Organizer, Engine
- Pre: สินค้ามี catalog identity; ระบุ distributor
- Post: campaign
open - Main: ระบุ catalog/distributor/window/MOQ/tier → validate
open→ ประกาศ supplier ที่มี collaboration consolidate - Exception: window ไม่ valid → reject
APIs ในหัวข้อนี้
POST /consolidation/campaigns— เปิด campaign →openGET /consolidation/campaigns— รายการ (paged)GET /consolidation/campaigns/:id— รายละเอียด
เปิด campaign → open
UC-2.1 main flow: organizer ระบุ catalog identity (
master_sku_id— ต้องผ่าน UC-F3 ก่อน) + distributor + window + MOQ + tiers → validate →open→ ประกาศให้ supplier ที่มี collaboration consolidate · window ไม่ valid (close_atก่อนopen_at) →422 INVALID_WINDOW
POST /consolidation/campaigns
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{
"organizer_supplier_id": "supplier_A",
"master_sku_id": "cat_01H8Z...",
"distributor_id": "dist_77",
"window": { "open_at": "2026-06-11T00:00:00Z", "close_at": "2026-06-18T23:59:59Z" },
"moq": 1000,
"tiers": [ { "min_qty": 1000, "unit_price": 95 }, { "min_qty": 5000, "unit_price": 88 } ]
}
Response 201
{
"campaign_id": "camp_01H9C...",
"master_sku_id": "cat_01H8Z...",
"distributor_id": "dist_77",
"moq": 1000,
"tiers": [ { "min_qty": 1000, "unit_price": 95 }, { "min_qty": 5000, "unit_price": 88 } ],
"window": { "open_at": "2026-06-11T00:00:00Z", "close_at": "2026-06-18T23:59:59Z" },
"status": "open",
"total_qty": 0,
"created_date": "2026-06-10T08:00:00Z"
}
Errors
{ "status_code": 422, "message": "INVALID_WINDOW", "data": { "reason": "close_at_before_open_at" } }
รายการ (paged)
GET /consolidation/campaigns
Query: status master_sku_id page page_size
Response 200
{
"total": 4, "page": 1, "page_size": 50,
"data": [ { "campaign_id": "camp_01H9C...", "master_sku_id": "cat_01H8Z...", "status": "open", "total_qty": 1250, "moq": 1000, "close_at": "2026-06-18T23:59:59Z" } ]
}
รายละเอียด
GET /consolidation/campaigns/:id
Response 200
{
"campaign_id": "camp_01H9C...",
"organizer_supplier_id": "supplier_A",
"master_sku_id": "cat_01H8Z...",
"distributor_id": "dist_77",
"moq": 1000,
"tiers": [ { "min_qty": 1000, "unit_price": 95 }, { "min_qty": 5000, "unit_price": 88 } ],
"window": { "open_at": "2026-06-11T00:00:00Z", "close_at": "2026-06-18T23:59:59Z" },
"status": "open",
"total_qty": 1250,
"moq_progress": 1.25,
"commitment_count": 6
}
Errors: 404 NOT_FOUND
Acceptance: catalog identity + distributor + window/MOQ/tier ถูกต้อง → open + แจ้งผู้ขายที่มีสิทธิ์ · close ก่อน open → ปฏิเสธ
2.2 UC-2.2 — Commit Volume
OM-2 · P1 · M ·
consolidation_commitment
- Actors: Seller
- Pre: campaign open; collaboration consolidate active; grant commit
- Post: มี commitment
- Main: เปิด preorder กับลูกค้า → ผูกจำนวน+sku (catalog เดียวกัน) → commitment → อัปเดตยอดรวม
- Exception: sku ไม่ map catalog → reject; window ปิด → reject
- NFR: ยอดรวม consistent ภายใต้ concurrent (atomic increment)
APIs ในหัวข้อนี้
POST /consolidation/campaigns/:id/commitments— commit volumePUT /consolidation/commitments/:id— แก้จำนวน (ก่อนปิด)DELETE /consolidation/commitments/:id— ถอน commitment (ก่อนปิด)
commit volume
UC-2.2 main flow: seller เปิด preorder กับลูกค้า → ผูกจำนวน + sku (ต้อง map catalog เดียวกับ campaign) → commitment → atomic increment ยอดรวม campaign (consistent ภายใต้ concurrent)
Exceptions: sku ไม่ map catalog →
422 CATALOG_NOT_MAPPED· window ปิดแล้ว →422 WINDOW_CLOSED· ไม่มี grant commit →403 GRANT_DENIED
POST /consolidation/campaigns/:id/commitments
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "supplier_id": "supplier_A", "sku_id": "sku_123", "qty": 250 }
Response 201
{
"commitment_id": "comm_01H9D...",
"campaign_id": "camp_01H9C...",
"supplier_id": "supplier_A",
"sku_id": "sku_123",
"qty": 250,
"status": "committed",
"campaign_total_qty": 1250,
"moq_progress": 1.25,
"created_date": "2026-06-12T10:00:00Z"
}
Errors
{ "status_code": 422, "message": "CATALOG_NOT_MAPPED", "data": { "sku_id": "sku_123" } }
422 WINDOW_CLOSED · 403 GRANT_DENIED
แก้จำนวน (ก่อนปิด)
PUT /consolidation/commitments/:id
Request
{ "qty": 300 }
Response 200
{ "commitment_id": "comm_01H9D...", "qty": 300, "campaign_total_qty": 1300, "moq_progress": 1.30, "updated_date": "2026-06-12T11:00:00Z" }
Errors: 422 WINDOW_CLOSED
ถอน commitment (ก่อนปิด)
ถอน commitment → status
withdrawn+ ลดยอดรวม · คืน200พร้อม body (ไม่ใช่ 204 — แก้ inconsistency ใน v3.1)
DELETE /consolidation/commitments/:id
Response 200
{ "commitment_id": "comm_01H9D...", "status": "withdrawn", "campaign_total_qty": 1000 }
Errors: 422 WINDOW_CLOSED
Acceptance: campaign open + SKU map catalog เดียวกัน → commit → บันทึก + ยอดรวมเพิ่ม · closed → ปฏิเสธ (WINDOW_CLOSED)
2.3 UC-2.3 — Close & Place Combined PO
OM-2 · P1 · L · อ้าง
purchase_orders(reuse)
- Actors: Organizer, Engine, Distributor
- Pre: campaign open ถึงเวลาปิด
- Post(success):
ordered+ PO รวม 1 ใบ +CombinedPOPlaced - Main: ปิด
closed→ รวม qty + ตรวจ MOQ + ราคา tier → MOQ ถึง → ออก PO รวม → distributor →ordered - Exception: MOQ ไม่ถึง →
cancelled→ ยกเลิก/refund/fallback ราคาเดี่ยว - NFR: ออก PO idempotent
APIs ในหัวข้อนี้
PUT /consolidation/campaigns/:id/close— ปิด + ออก PO รวมGET /consolidation/campaigns/:id/combined-po— PO รวมที่ออก
ปิด + ออก PO รวม
UC-2.3 main flow: ปิด
closed→ รวม qty + ตรวจ MOQ + เลือกราคา tier → MOQ ถึง → ออก PO รวม 1 ใบผ่านexternal/logistics/purchase-orders(reuse) →ordered+ emitCombinedPOPlacedMOQ ไม่ถึง →
cancelled+ trigger refund preorder (422 MOQ_NOT_MET)Idempotent ด้วย Idempotency-Key — ปิดซ้ำไม่ออก PO ซ้ำ · body ว่าง หรือ
{ "forced": true }กรณี organizer สั่งปิดก่อนเวลา · (campaign ที่ถึงclose_atจะถูก scheduler ปิดอัตโนมัติทุก ≤ 1 นาที)
PUT /consolidation/campaigns/:id/close
Headers: Idempotency-Key (ปิดซ้ำไม่ออก PO ซ้ำ)
Request: ไม่มี body (หรือ { "forced": true } กรณี organizer สั่งปิดก่อนเวลา)
Response 202 (MOQ ถึง)
{
"campaign_id": "camp_01H9C...",
"status": "ordered",
"total_qty": 1300,
"combined_po_id": "po_01H9E...",
"final_tier": { "min_qty": 1000, "unit_price": 88 },
"ordered_at": "2026-06-18T23:59:59Z"
}
Response 422 (MOQ ไม่ถึง → cancelled)
{
"status_code": 422,
"message": "MOQ_NOT_MET",
"data": { "campaign_id": "camp_01H9C...", "status": "cancelled", "total_qty": 800, "moq": 1000, "refund_triggered": true }
}
PO รวมที่ออก
GET /consolidation/campaigns/:id/combined-po
Response 200
{
"combined_po_id": "po_01H9E...",
"campaign_id": "camp_01H9C...",
"distributor_id": "dist_77",
"total_qty": 1300,
"unit_price": 88,
"total_amount": 114400,
"status": "placed",
"placed_at": "2026-06-18T23:59:59Z"
}
Errors: 404 NOT_FOUND (ยังไม่ออก PO)
Acceptance: ยอดรวม ≥ MOQ → ออก PO รวม 1 ใบ + ordered · < MOQ → cancelled + refund · ปิดและออก PO แล้ว → ปิดอีก → ไม่มี PO ใหม่ (idempotent)
2.4 UC-2.4 — Receive & Allocate
OM-2 · P1 · L ·
consolidation_allocation(อ้าง inbound)
- Actors: Engine, Sellers, Inventory
- Pre: campaign ordered
- Post: allocation + inbound ต่อ supplier;
settled - Trigger:
InboundReceived - Main: รับของ
receiving→ แบ่ง allocation ตามสัดส่วน → สร้าง inbound ต่อ supplier (on-behalfinventory-service) → fulfill preorder →SettlementAccrued→settled - Alternate: รับไม่ครบ → pro-rata + shortfall
- NFR (invariant): Σ allocation = ปริมาณรับจริง
APIs ในหัวข้อนี้
POST /consolidation/campaigns/:id/inbound— บันทึกรับของ + แบ่ง allocationGET /consolidation/campaigns/:id/allocations— รายการ allocation
บันทึกรับของ + แบ่ง allocation
UC-2.4 main flow: รับของ →
receiving→ แบ่ง allocation ตามสัดส่วน commitment → สร้าง inbound ต่อ supplier (on-behalfinventory-service) → fulfill preorder →SettlementAccrued→settledInvariant (NFR):
allocation_sum=received_qtyเสมอ — รับไม่ครบ → pro-rata + บันทึกshortfall_qty
POST /consolidation/campaigns/:id/inbound
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "received_qty": 980, "reference": "grn_01H9F..." }
Response 202
{
"campaign_id": "camp_01H9C...",
"status": "receiving",
"received_qty": 980,
"ordered_qty": 1300,
"allocations": [
{ "supplier_id": "supplier_A", "committed_qty": 300, "allocated_qty": 226, "shortfall_qty": 74, "inbound_ref": "inb_A_01H..." },
{ "supplier_id": "supplier_B", "committed_qty": 1000, "allocated_qty": 754, "shortfall_qty": 246, "inbound_ref": "inb_B_01H..." }
],
"allocation_sum": 980
}
Invariant:
allocation_sum=received_qtyเสมอ (รับไม่ครบ → pro-rata + บันทึกshortfall_qty)
รายการ allocation
GET /consolidation/campaigns/:id/allocations
Response 200
{
"campaign_id": "camp_01H9C...",
"status": "settled",
"allocations": [
{ "allocation_id": "alloc_01H...", "supplier_id": "supplier_A", "allocated_qty": 226, "shortfall_qty": 74, "inbound_ref": "inb_A_01H..." }
],
"allocation_sum": 980
}
Acceptance: ordered + รับครบ → allocation ตามสัดส่วน + inbound ต่อ supplier + settled · รับไม่ครบ → pro-rata + shortfall · รับ N → Σ allocation = N
ซื้อถูกลงแล้วด้วยพลังของยอดรวม — เรื่องที่ 3 คือขยายหน้าร้าน: ขายสินค้าของกันและกันโดยไม่ต้องถือสต็อกเอง
เรื่องที่ 3 — OM-3 Share SKU
3.0 ปัญหา & ภาพรวม OM-3
ปัญหา 3 — Product Assortment (catalog แคบ ขยายช้า): เพิ่มสินค้าใหม่ต้องสร้าง+สต็อกเองทุก SKU → catalog แคบ ขณะที่เจ้าของแบรนด์ก็ขยายช่องทางไม่ได้
ทางแก้ (OM-3 Share SKU): Supplier B แชร์สินค้าให้ A ขายแทน — สร้าง linked SKU เป็น row จริงในสโคป A ผ่าน backoffice/skus (reuse CreateSkuWithPrice) เพื่อให้ POS/order เดิมเห็นเป็น SKU ปกติ (ADR-06)
3.1 UC-3.1 — Publish Shared SKU
OM-3 · P1 · M ·
shared_sku_grant
- Actors: Owner B
- Pre: SKU มี catalog identity; collaboration share/pool
- Post: grant
active+SharedSkuPublished - Main: เลือก SKU + share_mode + price_policy + sync_fields + ผู้รับ → validate
active→ event - Exception: ไม่มี catalog → UC-F3 ก่อน
- NFR: price_policy versioned
APIs ในหัวข้อนี้
POST /shared-skus/publish— เผยแพร่ SKU ให้ผู้รับ → grantactiveGET /shared-skus/catalog— shared catalog ที่ consumer มีสิทธิ์เห็น (paged)GET /shared-skus/grants— grant ที่ owner เผยแพร่ไว้ (paged)
เผยแพร่ SKU ให้ผู้รับ → grant active
UC-3.1 Main Flow: Owner B เลือก SKU +
share_mode+price_policy+sync_fields+ ผู้รับ → validate → grantactive→ emitSharedSkuPublished
Field ค่า share_modecatalog_only|sell_on_behalf|dropshipprice_policy.typefixed|rrp|markup_allowed(versioned — NFR)Pre-condition: SKU ต้องมี catalog identity แล้ว (UC-F3) — ไม่งั้น
422 CATALOG_NOT_MAPPED
POST /shared-skus/publish
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{
"owner_supplier_id": "supplier_B",
"sku_ids": ["sku_B_001"],
"share_mode": "dropship",
"price_policy": { "type": "fixed", "value": 120, "version": 1 },
"sync_fields": ["title", "image", "description"],
"audience": { "type": "suppliers", "supplier_ids": ["supplier_A"] }
}
Response 201
{
"grant_id": "ssg_01H9G...",
"owner_supplier_id": "supplier_B",
"sku_ids": ["sku_B_001"],
"share_mode": "dropship",
"price_policy": { "type": "fixed", "value": 120, "version": 1 },
"status": "active",
"created_date": "2026-06-10T08:00:00Z"
}
Errors
{ "status_code": 422, "message": "CATALOG_NOT_MAPPED", "data": { "sku_id": "sku_B_001", "hint": "ทำ POST /catalog/map ก่อน" } }
shared catalog ที่ consumer มีสิทธิ์เห็น (paged)
UC-3.1/3.2: consumer เปิดดูแคตตาล็อกสินค้าที่ตัวเองได้รับสิทธิ์ — จุดเริ่มก่อน import
GET /shared-skus/catalog
Query: page page_size
Response 200
{
"total": 8, "page": 1, "page_size": 50,
"data": [
{ "grant_id": "ssg_01H9G...", "source_supplier_id": "supplier_B", "source_sku_id": "sku_B_001", "master_sku_id": "cat_01H8Z...", "title": "นมจืด 200ml", "share_mode": "dropship", "price_policy": { "type": "fixed", "value": 120 } }
]
}
grant ที่ owner เผยแพร่ไว้ (paged)
GET /shared-skus/grants
Query: status share_mode page page_size
Response 200
{
"total": 3, "page": 1, "page_size": 50,
"data": [ { "grant_id": "ssg_01H9G...", "sku_ids": ["sku_B_001"], "share_mode": "dropship", "status": "active", "consumer_count": 2 } ]
}
Acceptance: SKU มี catalog identity → publish พร้อม mode+price_policy → grant active + event · ยังไม่ map catalog → ให้ทำ mapping ก่อน
3.2 UC-3.2 — Import & Sell Shared SKU
OM-3 · P1 · L · สร้าง linked
skus(A)
- Actors: Consumer A, Engine
- Pre: มี grant ที่ A รับ; grant sell_on_behalf
- Post: linked SKU ขายผ่าน flow ปกติ
- Main: เปิด shared catalog → import → สร้าง linked SKU (row จริง, master_sku_id→catalog, source=B) → ตั้งราคาภายใต้ policy → ขายผ่าน
ordering/posเดิม - Alternate: dropship → trigger UC-1.1; A มี SKU ซ้ำ → merge
- Exception: ราคาเกินกรอบ → reject
- NFR: linked SKU query เข้ากันได้กับ flow supplier-scoped เดิม
APIs ในหัวข้อนี้
POST /shared-skus/:grant_id/import— import → สร้าง linked SKU ในสโคป A
import → สร้าง linked SKU ในสโคป A
UC-3.2 Main Flow: consumer เปิด shared catalog → import → orchestrator เรียก
backoffice/skusCreateSkuWithPriceon-behalf consumer (ADR-06: linked SKU = row จริง ในสโคป A, ผูกmaster_sku_id→ catalog,source= B) → ตั้งราคาภายใต้ price_policy → ขายผ่านordering/posเดิม — ไม่มี endpoint ขายพิเศษAlternate:
dropship→ เมื่อขายได้ trigger UC-1.1 (B fulfills) · consumer มี SKU ซ้ำ → merge Exception: ราคาเกินกรอบ policy →422 PRICE_POLICY_VIOLATIONNFR: linked SKU query เข้ากันได้กับ flow supplier-scoped เดิม
POST /shared-skus/:grant_id/import
orchestrator เรียก backoffice/skus CreateSkuWithPrice on-behalf A (ADR-06: linked SKU = row จริง)
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "consumer_supplier_id": "supplier_A", "source_sku_id": "sku_B_001", "list_price": 135 }
Response 201
{
"linked_sku_id": "sku_A_900",
"shared_sku_grant_id": "ssg_01H9G...",
"consumer_supplier_id": "supplier_A",
"source_supplier_id": "supplier_B",
"source_sku_id": "sku_B_001",
"master_sku_id": "cat_01H8Z...",
"share_mode": "dropship",
"list_price": 135,
"status": "active",
"created_date": "2026-06-10T09:00:00Z"
}
Errors
{ "status_code": 422, "message": "PRICE_POLICY_VIOLATION", "data": { "policy": "fixed", "allowed_price": 120, "submitted": 135 } }
403 GRANT_DENIED · 409 SKU_ALREADY_IMPORTED (มีซ้ำ → ใช้ merge)
การขายใช้ flow ปกติของ A (
ordering/orders,pos/ordersเดิม) — ไม่มี endpoint พิเศษ;dropship→ เมื่อขายได้ trigger UC-1.1
Acceptance: A เป็นผู้รับ grant → import → linked SKU ในสโคป A ผูก catalog เดียวกัน ขายผ่าน flow ปกติ · price_policy=fixed + ตั้งราคาต่าง → ปฏิเสธ · mode=dropship + A ขาย → trigger fulfill โดย B
3.3 UC-3.3 — Sync Product Updates
OM-3 · P1 · M
- Actors: Engine, product-service(B)/(A)
- Pre: linked SKU active
- Post: A สอดคล้องต้นฉบับตาม sync_fields
- Trigger:
SharedSkuUpdated - Main: B แก้ → event → หา linked SKU ทุก consumer → อัปเดตเฉพาะ sync_fields (เคารพ price_policy) → markup คงส่วนต่าง
- Alternate: A override → ไม่ทับ
- Exception: grant paused → ข้าม
- NFR: near-real-time, ไม่กระทบ order เปิดอยู่
APIs ในหัวข้อนี้
GET /shared-skus/:grant_id/sync-status— สถานะการ sync ของ linked SKU ทุก consumerPOST /shared-skus/:grant_id/resync— บังคับ resyncsync_fields(on-behalf consumer)
สถานะการ sync ของ linked SKU ทุก consumer
UC-3.3: ตรวจว่า linked SKU แต่ละราย sync ตรงกับ source version หรือไม่ (field ที่ consumer override จะไม่ถูกทับ)
GET /shared-skus/:grant_id/sync-status
Response 200
{
"grant_id": "ssg_01H9G...",
"sync_fields": ["title", "image", "description"],
"source_version": 5,
"linked_skus": [
{ "consumer_supplier_id": "supplier_A", "linked_sku_id": "sku_A_900", "synced_version": 5, "in_sync": true, "overrides": ["title"] },
{ "consumer_supplier_id": "supplier_C", "linked_sku_id": "sku_C_410", "synced_version": 4, "in_sync": false }
]
}
บังคับ resync sync_fields (on-behalf consumer)
UC-3.3: บังคับ sync ใหม่ — ไม่ระบุ
consumer_supplier_id= resync ทุก consumerกติกา: เคารพ price_policy · ไม่ทับ field ที่ consumer override · grant
paused→ ข้าม · NFR: near-real-time, ไม่กระทบ order ที่เปิดอยู่
POST /shared-skus/:grant_id/resync
Request
{ "consumer_supplier_id": "supplier_C", "fields": ["title", "image", "description"] }
Note: ไม่ระบุ consumer_supplier_id = resync ทุก consumer · เคารพ price_policy + A override (ไม่ทับ field ที่ override) · grant paused → ข้าม
Response 202
{ "grant_id": "ssg_01H9G...", "resynced": 1, "skipped_paused": 0, "skipped_override": 1, "target_version": 5 }
Acceptance: sync_fields=[title,image] + B แก้ title → linked SKU อัปเดต title ไม่แตะ field อื่น · A override title → B แก้ → ไม่ถูกทับ
3.4 UC-3.4 — Revoke Share
OM-3 · P1 · M
- Actors: Owner B
- Pre: grant active/paused
- Post: revoked; linked SKU deactivate; order ค้าง honored
- Main: เพิกถอน →
SharedSkuRevoked→ deactivate linked SKU → honor in-flight - Exception: dropship ค้าง → honor จนจบ
- NFR: deactivate < 5s
APIs ในหัวข้อนี้
PUT /shared-skus/:grant_id/revoke— เพิกถอน → deactivate linked SKUPUT /shared-skus/:grant_id/pause— พักชั่วคราว →pausedPUT /shared-skus/:grant_id/resume— กลับมาใช้ →active
เพิกถอน → deactivate linked SKU
UC-3.4 Main Flow: Owner เพิกถอน → emit
SharedSkuRevoked→ deactivate linked SKU ทุก consumer (< 5s — ผ่านbackoffice/skusPUT /avalible/:idstyle) → order ค้าง honoredException: dropship ค้าง → honor จนจบ
PUT /shared-skus/:grant_id/revoke
Request
{ "reason": "หยุดแชร์สินค้า", "in_flight_policy": "honor" }
Response 202
{
"grant_id": "ssg_01H9G...",
"status": "revoked",
"linked_skus_deactivated": 2,
"in_flight_orders_honored": 3,
"effective_at": "2026-06-10T14:00:00Z"
}
Note: deactivate linked SKU < 5s · dropship ค้าง → honor จนจบ
พักชั่วคราว → paused
State machine:
active ↔ paused— sync จะข้าม grant ที่ paused
PUT /shared-skus/:grant_id/pause
Request: ไม่มี body
Response 200
{ "grant_id": "ssg_01H9G...", "status": "paused", "linked_skus_deactivated": 2 }
กลับมาใช้ → active
PUT /shared-skus/:grant_id/resume
Request: ไม่มี body
Response 200
{ "grant_id": "ssg_01H9G...", "status": "active", "linked_skus_reactivated": 2 }
Errors: 409 INVALID_STATE (ไม่อยู่ paused)
Acceptance: grant active + A มี linked SKU → B เพิกถอน → linked SKU ขายต่อไม่ได้ภายใน 5 วินาที; order ค้าง honored · paused → resume → ขายได้อีกครั้ง
มีของขายมากขึ้น ก็ต้องส่งมากขึ้น — เรื่องที่ 4 ปิดฝั่งปฏิบัติการ: หาคนส่งให้เจอ ในราคาที่รับได้ พร้อมหลักฐานการส่งครบ
เรื่องที่ 4 — OM-4 Shipper Matching
4.0 ปัญหา & ภาพรวม OM-4
ปัญหา 4 — Carrier Access (เข้าไม่ถึงผู้ส่งที่มีอยู่แล้ว): ผู้ส่งอิสระกระจัดกระจาย แต่ละ supplier เข้าถึงไม่ได้ → fill-rate ต่ำ ต้นทุน last-mile สูง ตัวผู้ส่งเองก็หางานยาก
ทางแก้ (OM-4 Shipper Matching): ผู้ส่งลงทะเบียน, matching-engine กลาง match กับงานส่งทุก supplier — reuse external/logistics/delivery+/shipments + tccrouting (claim route_planner_id) · carrier = actor/identity ใหม่ (OI-5)
4.1 UC-4.1 — Carrier Onboarding
OM-4 · P1 · M ·
carrier
- Actors: Carrier, Operator
- Pre: มีช่องสมัคร + Shipper App
- Post: carrier
active - Main: สมัคร (type, area, vehicle, capacity, rate card, KYC, payout) →
pending→ ตรวจ KYCverified→active - Exception: KYC fail →
suspended - NFR: เก็บ KYC ปลอดภัย, audit
APIs ในหัวข้อนี้
POST /carriers/register— สมัคร carrier →pendingPUT /carriers/:id/verify— operator ตรวจ KYC →verified/activeGET /carriers/:id— รายละเอียด carrierGET /carriers— รายการ carrier (operator, paged)PUT /carriers/:id— แก้ rate card / area / capacity
สมัคร carrier → pending
UC-4.1 Main Flow: สมัคร (type, area, vehicle, capacity, rate card, KYC, payout) →
pending→ operator ตรวจ KYC →verified→activeAuth: public endpoint + OTP ยืนยันเบอร์โทร (สมัครก่อนมี token) — เมื่อ
activeแล้วจึงออก carrier token ใช้กับAuthorizationCarrierToken()(OI-5)
type:individual|fleet|3pl· NFR: เก็บ KYC ปลอดภัย + audit
POST /carriers/register
Auth: public endpoint + OTP ยืนยันเบอร์โทร (สมัครก่อนมี token) — เมื่อ active แล้วจึงออก carrier token ใช้กับ AuthorizationCarrierToken() (OI-5)
Request
{
"type": "individual",
"service_areas": ["TH-C", "TH-N"],
"vehicle": { "type": "van", "capacity_kg": 800 },
"rate_card": { "base": 50, "per_km": 8 },
"kyc": { "national_id": "1234567890123", "documents": ["https://.../id_card.jpg"] },
"payout": { "method": "bank_transfer", "account": "xxx-x-xxxxx-x", "bank": "KBANK" }
}
Response 201
{
"carrier_id": "car_01H9H...",
"type": "individual",
"service_areas": ["TH-C", "TH-N"],
"status": "pending",
"created_date": "2026-06-10T07:00:00Z"
}
Errors: 400 VALIDATION_ERROR (ข้อมูลไม่ครบ)
operator ตรวจ KYC → verified/active
UC-4.1:
decision: pass→active(พร้อมรับงาน) ·fail→suspended+ เหตุผล (Exception flow)State machine:
pending → verified / suspended·verified → active
PUT /carriers/:id/verify
Request
{ "decision": "pass", "verified_by": "operator_07", "note": "เอกสารครบ" }
Response 200
{ "carrier_id": "car_01H9H...", "status": "active", "verified_by": "operator_07", "rating": 5.0, "verified_at": "2026-06-10T07:30:00Z" }
Response 422 (KYC fail)
{ "status_code": 422, "message": "KYC_FAILED", "data": { "carrier_id": "car_01H9H...", "status": "suspended", "reason": "เอกสารไม่ชัด" } }
รายละเอียด carrier
GET /carriers/:id
Response 200
{
"carrier_id": "car_01H9H...",
"type": "individual",
"service_areas": ["TH-C", "TH-N"],
"vehicle": { "type": "van", "capacity_kg": 800 },
"rate_card": { "base": 50, "per_km": 8 },
"rating": 4.7,
"status": "active",
"created_date": "2026-06-10T07:00:00Z"
}
Errors: 404 NOT_FOUND
รายการ carrier (operator, paged)
สิทธิ์: operator เท่านั้น — ไม่ใช่ operator →
403 FORBIDDEN
GET /carriers
Query: status type service_area page page_size
Response 200
{
"total": 30, "page": 1, "page_size": 50,
"data": [ { "carrier_id": "car_01H9H...", "type": "individual", "service_areas": ["TH-C"], "rating": 4.7, "status": "active" } ]
}
Errors: 403 FORBIDDEN (operator เท่านั้น)
แก้ rate card / area / capacity
PUT /carriers/:id
Request
{ "service_areas": ["TH-C", "TH-N", "TH-NE"], "rate_card": { "base": 55, "per_km": 8 } }
Response 200
{ "carrier_id": "car_01H9H...", "service_areas": ["TH-C","TH-N","TH-NE"], "rate_card": { "base": 55, "per_km": 8 }, "updated_date": "2026-06-15T07:00:00Z" }
Acceptance: ข้อมูลครบ + KYC ถูกต้อง → operator อนุมัติ → active รับงานได้ · KYC ไม่ผ่าน → suspended + เหตุผล
4.2 UC-4.2 — Create Delivery Job
OM-4 · P1 · M ·
delivery_job
- Actors: Seller, Engine
- Pre: ออเดอร์ต้องส่ง (อาจต่อจาก UC-1.1 packed)
- Post: job
created - Main: ดึง pickup/dropoff/package/window → สร้าง job +
DeliveryJobCreated - Alternate: รวมหลายออเดอร์
- Exception: ที่อยู่ไม่ครบ → ขอเติม
- NFR: idempotent ต่อ shipment
APIs ในหัวข้อนี้
POST /delivery/jobs— สร้างงานส่ง →createdGET /delivery/jobs— รายการงานส่ง (paged)PUT /delivery/jobs/:id/cancel— ยกเลิกงานส่ง (ก่อน pickup)
สร้างงานส่ง → created
UC-4.2 Main Flow: trigger จาก OM-1
packedหรือ seller — ดึง pickup/dropoff/package/window → สร้าง job + emitDeliveryJobCreated(orch.delivery.created.v1) → เข้า matching (UC-4.3)reuse
external/logistics/shipmentsเป็นฐานข้อมูลส่ง Alternate: รวมหลายออเดอร์ใน job เดียว · NFR: idempotent ต่อ shipment
POST /delivery/jobs
trigger จาก OM-1 packed หรือ seller; reuse external/logistics/shipments เป็นฐานข้อมูลส่ง
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{
"source_order_ids": ["ord_A_001"],
"owner_supplier_id": "supplier_A",
"pickup": { "address": "คลัง B ลาดกระบัง", "geo": [13.7, 100.5], "window": "2026-06-11T09:00-12:00" },
"dropoff": { "address": "ร้านปลายทาง พระโขนง", "geo": [13.8, 100.6] },
"package": { "weight_kg": 5, "dimensions": "30x20x15", "cod_amount": 0 }
}
Response 201
{
"job_id": "job_01H9B...",
"source_order_ids": ["ord_A_001"],
"owner_supplier_id": "supplier_A",
"status": "created",
"created_date": "2026-06-10T12:05:00Z"
}
Errors
{ "status_code": 422, "message": "INCOMPLETE_ADDRESS", "data": { "missing": ["dropoff.geo"] } }
Emits: DeliveryJobCreated
รายการงานส่ง (paged)
GET /delivery/jobs
Query: status owner_supplier_id assigned_carrier_id page page_size
Response 200
{
"total": 12, "page": 1, "page_size": 50,
"data": [ { "job_id": "job_01H9B...", "status": "assigned", "assigned_carrier_id": "car_01H9H...", "created_date": "2026-06-10T12:05:00Z" } ]
}
ยกเลิกงานส่ง (ก่อน pickup)
UC-4.2/4.3 Exception: ยกเลิกได้เมื่อ
created/offered/assigned(ก่อน pickup) — ปิด offer ค้างทั้งหมด
picked_upแล้ว →409 INVALID_STATE→ ใช้ return flow (UC-5.2) แทน
PUT /delivery/jobs/:id/cancel
Request
{ "reason": "ออเดอร์ถูกยกเลิก" }
Response 200
{ "job_id": "job_01H9B...", "status": "cancelled", "offers_closed": 3, "canceled_date": "2026-06-10T12:30:00Z" }
Errors: 409 INVALID_STATE (picked_up แล้ว → ใช้ return flow §5.2)
Acceptance: ออเดอร์พร้อมส่ง + ที่อยู่ครบ → created + เข้า matching · ที่อยู่ไม่ครบ → ขอข้อมูลเพิ่ม
4.3 UC-4.3 — Match Carrier
OM-4 · P1 · XL ·
delivery_job,delivery_job_offer(อ้าง tccrouting)
- Actors: Matching Engine, Carriers
- Pre: job created; มี carrier active ในพื้นที่
- Post(success): job
assigned - Minimal: ไม่มีใครรับ →
escalated - Trigger:
DeliveryJobCreated - Main: คัด candidate (area∩, capacity, rating, ราคา; tccrouting) → strategy broadcast/auction/direct → offer+expiry → accept
assigned+ ปิด offer อื่น → ส่งต่อ UC-4.4 - Alternate: auction → bids → ผู้ชนะ; หลาย accept → คนแรก (optimistic lock)
- Exception: expired → ถัดไป; ไม่มีใครรับ → retry ราคาสูงขึ้น →
escalated; ยกเลิกก่อน pickup → คืน match - NFR: matching < 2s, กัน double-assign
APIs ในหัวข้อนี้
POST /delivery/jobs/:id/match— เริ่ม matching (หรือ auto จาก event)GET /carriers/me/offers— offer ที่ค้างของ carrier (AuthorizationCarrierToken)POST /delivery/offers/:offer_id/accept— รับงาน (first wins, optimistic lock)POST /delivery/offers/:offer_id/decline— ปฏิเสธ offerPOST /delivery/jobs/:id/bids— เสนอราคา (auction)PUT /delivery/jobs/:id/assign— operator assign ตรง (ทางออกของescalated)
เริ่ม matching (หรือ auto จาก event)
UC-4.3 Main Flow: คัด candidate (service area ∩, capacity, rating, ราคา — ใช้
tccroutingestimate route) → strategybroadcast/auction/direct→ ส่ง offer + expiry → carrier accept →assigned+ ปิด offer อื่น → ส่งต่อ UC-4.4Exception: offer expired → ถัดไป · ไม่มีใครรับ → retry ราคาสูงขึ้น →
escalated(ทางออก: operatorPUT /delivery/jobs/:id/assign) NFR: matching < 2s · กัน double-assign (optimistic lock)
POST /delivery/jobs/:id/match
Request
{ "strategy": "broadcast", "offer_ttl_sec": 120 }
Response 202
{
"job_id": "job_01H9B...",
"status": "offered",
"strategy": "broadcast",
"offers_sent": 5,
"candidate_carrier_ids": ["car_01H9H...", "car_01H9I..."],
"offer_expires_at": "2026-06-10T12:10:00Z"
}
Response 409 (ไม่มี candidate → escalated)
{ "status_code": 409, "message": "NO_CARRIER_AVAILABLE", "data": { "job_id": "job_01H9B...", "status": "escalated", "retry_count": 2 } }
offer ที่ค้างของ carrier (AuthorizationCarrierToken)
UC-4.3: carrier เปิด Shipper App ดู offer ที่ยังไม่หมดเวลา — ใช้
AuthorizationCarrierToken()(OI-5) ไม่ใช่ OrchestrationToken
GET /carriers/me/offers
Response 200
{
"total": 2, "page": 1, "page_size": 50,
"data": [
{ "offer_id": "off_01H9J...", "job_id": "job_01H9B...", "offer_type": "broadcast", "pickup": { "address": "...", "geo": [13.7,100.5] }, "dropoff": { "address": "...", "geo": [13.8,100.6] }, "estimated_fee": 90, "expires_at": "2026-06-10T12:10:00Z" }
]
}
รับงาน (first wins, optimistic lock)
UC-4.3: หลาย carrier accept เกือบพร้อมกัน → คนแรกได้งาน (optimistic lock) → job
assigned+ ปิด offer อื่น + emitOfferAcceptedคนหลัง →
409 DOUBLE_ASSIGN· offer หมดเวลา →409 OFFER_EXPIRED
POST /delivery/offers/:offer_id/accept
Request: ไม่มี body
Response 200
{ "offer_id": "off_01H9J...", "job_id": "job_01H9B...", "status": "accepted", "assigned_carrier_id": "car_01H9H...", "job_status": "assigned" }
Errors
{ "status_code": 409, "message": "DOUBLE_ASSIGN", "data": { "job_id": "job_01H9B...", "assigned_to": "car_01H9I..." } }
409 OFFER_EXPIRED
ปฏิเสธ offer
POST /delivery/offers/:offer_id/decline
Request: ไม่มี body (หรือ { "reason": "ไกลเกินไป" })
Response 200
{ "offer_id": "off_01H9J...", "status": "declined" }
เสนอราคา (auction)
UC-4.3 Alternate (auction): carriers เสนอราคา → engine เลือก ต่ำสุดที่ผ่านเกณฑ์ → assign
carrier_idต้องตรงกับ token (AuthorizationCarrierToken) — ไม่ตรง →403 FORBIDDEN
POST /delivery/jobs/:id/bids
Request
{ "carrier_id": "car_01H9H...", "bid_amount": 85 }
Response 201
{ "bid_id": "bid_01H...", "job_id": "job_01H9B...", "carrier_id": "car_01H9H...", "bid_amount": 85, "status": "submitted" }
Errors: 409 OFFER_EXPIRED (ปิดประมูลแล้ว)
operator assign ตรง (ทางออกของ escalated)
UC-4.3: เมื่อ job
escalated(ไม่มีใครรับครบ retry) — operator assign carrier ตรง พร้อมagreed_fee→assignedState machine:
escalated → assigned (operator direct assign)
PUT /delivery/jobs/:id/assign
Request
{ "carrier_id": "car_01H9I...", "agreed_fee": 110, "assigned_by": "operator_07" }
Response 200
{ "job_id": "job_01H9B...", "status": "assigned", "assigned_carrier_id": "car_01H9I...", "assigned_by": "operator_07" }
Errors: 403 FORBIDDEN · 409 INVALID_STATE (assigned แล้ว) · 422 CARRIER_NOT_ELIGIBLE (ไม่ active / นอกพื้นที่ / เกิน capacity)
Acceptance: หลาย carrier ในพื้นที่ + broadcast + A รับก่อน → assigned ให้ A + ปิด offer อื่น · auction → ต่ำสุดที่ผ่านเกณฑ์ · ทุก offer หมดเวลา + ครบ retry → escalated · A,B รับเกือบพร้อมกัน → รายเดียวได้งาน (กัน double-assign)
4.4 UC-4.4 — Execute & Track Delivery
OM-4 · P1 · M · อ้าง shipment tracking (reuse
external/logistics/delivery)
- Actors: Carrier, Seller, Engine
- Pre: job assigned
- Post: delivered (หรือ failed→re-attempt) +
SettlementAccrued - Trigger: carrier เริ่มงาน
- Main: picked_up→in_transit→delivered (Shipper App) →
DeliveryStatusChangedrelay + update tracking → delivered → settle (จ่าย carrier, เก็บ supplier, หัก commission) - Exception: failed → re-attempt/คืน match; ปฏิเสธรับ → return flow (§5.2)
- Rules: BR-07
- NFR: tracking near-real-time, เก็บ POD
APIs ในหัวข้อนี้
PUT /delivery/jobs/:id/transition— อัปเดตสถานะการส่ง (Shipper App)GET /delivery/jobs/:id/tracking— ติดตามการส่ง (near-real-time + timeline)
อัปเดตสถานะการส่ง (Shipper App)
UC-4.4 Main Flow:
picked_up→in_transit→delivered(Shipper App) → emitDeliveryStatusChangedrelay กลับออเดอร์ + update tracking →delivered→ settle (จ่าย carrier, เก็บ supplier, หัก commission — BR-07) +SettlementAccrued
toเงื่อนไข picked_up/in_transitตาม state machine deliveredต้องแนบ podไม่งั้น422 POD_REQUIREDfailed→ re-attempt / คืน matching · ผู้รับปฏิเสธรับ → return flow (UC-5.2) NFR: tracking near-real-time · เก็บ POD
PUT /delivery/jobs/:id/transition
Request (delivered)
{ "to": "delivered", "pod": { "type": "signature", "url": "https://.../pod.jpg", "received_by": "คุณสมชาย" }, "geo": [13.8, 100.6] }
Request (failed)
{ "to": "failed", "reason": "ผู้รับไม่อยู่", "geo": [13.8, 100.6] }
Response 200
{
"job_id": "job_01H9B...",
"status": "delivered",
"pod": { "type": "signature", "url": "https://.../pod.jpg", "received_by": "คุณสมชาย" },
"settlement_accrued": true,
"delivered_at": "2026-06-11T14:30:00Z"
}
Errors: 409 INVALID_TRANSITION · 422 POD_REQUIRED (delivered แต่ไม่มี POD)
Emits: DeliveryStatusChanged
ติดตามการส่ง (near-real-time + timeline)
UC-4.4: timeline + ตำแหน่งปัจจุบัน + ETA — relay จาก
DeliveryStatusChanged
GET /delivery/jobs/:id/tracking
Response 200
{
"job_id": "job_01H9B...",
"status": "in_transit",
"assigned_carrier_id": "car_01H9H...",
"timeline": [
{ "status": "assigned", "at": "2026-06-10T12:08:00Z" },
{ "status": "picked_up", "at": "2026-06-11T09:30:00Z", "geo": [13.7, 100.5] },
{ "status": "in_transit", "at": "2026-06-11T10:00:00Z", "geo": [13.75, 100.55] }
],
"current_geo": [13.75, 100.55],
"eta": "2026-06-11T14:00:00Z"
}
Acceptance: assigned + picked_up..delivered → relay กลับออเดอร์ + settlement ค่าส่ง · failed → re-attempt หรือคืน matching · delivered → เก็บ POD
ของถึงมือผู้รับครบทุกเส้นทางแล้ว — เรื่องที่ 5 ปิดสิ่งที่ทุกเรื่องค้างไว้: เงินต้อง settle, ของตีกลับต้องย้อนตามสายเดิม และทุกระบบต้องรู้ความเคลื่อนไหวผ่าน event
เรื่องที่ 5 — Settlement, Reverse & Events
5.1 Settlement & Billing
Entity settlement_ledger · Clearing House เดียวกับ Chapter 13/13a (double-entry, insert-only, reversal-only, multilateral netting) · sync bplus/vsms/certu ของเดิม
รัน settlement หลัก = UC-F4; ส่วนนี้คือ ledger query + UC-5.1 Dispute & Adjust ที่เดิมมี endpoint แต่ยังไม่ถูกเขียนเป็น UC
UC-5.1 — Dispute & Adjust Settlement
อธิบาย use case · Cross-cutting · P1 · M · entity settlement_ledger
- Actors: Supplier (เปิดข้อพิพาท), Operator (ตัดสิน), Settlement Engine · Pre: มี ledger entry
accrued/invoiced· Post: entrydisputed→accrued(uphold/adjust) หรือ reversal(void) + กันออกจากรอบจนกว่าจะปิด - Trigger: supplier ไม่เห็นด้วยกับยอด · Main: เปิด dispute → entry
disputed+excluded_from_run→ operator พิจารณา →uphold/adjust/void→ ถ้า void สร้าง reversal entry → เข้ารอบถัดไป - Exception: dispute หลังออก invoice แล้ว → ปรับผ่าน reversal เท่านั้น (ledger insert-only) · Rules: BR-07/08 · NFR: ทุกการแก้ทิ้ง audit trail, zero-sum หลังปรับ
- Acceptance: เปิด dispute → entry หลุดจาก settlement run ปัจจุบัน · operator
adjust→ ยอดใหม่กลับเป็นaccruedรอรอบถัดไป ·void→ reversal กลับทิศ ยอดสุทธิเป็นศูนย์
รายการ ledger (paged)
Entity
settlement_ledger— Clearing House (double-entry, insert-only, reversal-only, multilateral netting) · syncbplus/vsms/certuของเดิมStatus flow:
accrued→invoiced→settled(+disputedแยกออกจากรอบ)
GET /orchestration/settlement/ledger
Query: collaboration_id status period page page_size (status: accrued→invoiced→settled+disputed)
Response 200
{
"total": 142, "page": 1, "page_size": 50,
"data": [
{ "entry_id": "le_01H...", "collaboration_id": "collab_01H8X...", "payer_supplier_id": "supplier_A", "payee_supplier_id": "supplier_B", "payee_carrier_id": null, "amount": 35.0, "fee_type": "fulfillment", "status": "accrued", "source_txn": "fa_01H9A...", "created_date": "2026-06-10T13:00:00Z" }
]
}
รัน settlement (UC-F4)
UC-F4 Main Flow: รวบ ledger
accruedของ period → multilateral netting → zero-sum guard → ออกเอกสาร invoice/credit note ผ่าน ERP webhook (bplus/vsms/certu) →settledIdempotent ต่อรอบ (
settlement_cycle_id + party + source_txn) — Idempotency-Key บังคับ · zero-sum guard + shadow reconciliation ·exclude_disputed: trueกัน entry ที่มีข้อพิพาทออก (UC-5.1)
POST /orchestration/settlement/runs
Headers: Idempotency-Key (idempotent ต่อรอบ)
Request
{ "period": "2026-06", "parties": ["supplier_A", "supplier_B"], "exclude_disputed": true }
Response 202
{
"run_id": "srun_01H...",
"period": "2026-06",
"status": "processing",
"entries_count": 142,
"net_summary": [
{ "party": "supplier_A", "net": -1250.0 },
{ "party": "supplier_B", "net": 1180.0 },
{ "party": "platform", "net": 70.0 }
],
"excluded_disputed": 3
}
Note: idempotent ต่อรอบ (settlement_cycle_id + party + source_txn) · zero-sum guard + shadow reconciliation
รายการ run (paged)
GET /orchestration/settlement/runs
Query: period status page page_size
Response 200
{
"total": 6, "page": 1, "page_size": 50,
"data": [ { "run_id": "srun_01H...", "period": "2026-06", "status": "settled", "entries_count": 142 } ]
}
สถานะ run + เอกสารที่ออก
State machine:
processing → invoiced → settled·processing → failedเอกสารออกผ่าน ERP webhook (HMAC-signedX-Signature: sha256=..., idempotent ด้วยevent_id)
GET /orchestration/settlement/runs/:run_id
Response 200
{
"run_id": "srun_01H...",
"period": "2026-06",
"status": "settled",
"entries_count": 142,
"documents": [ { "type": "invoice", "party": "supplier_A", "ref": "INV-2026-06-001", "erp": "bplus" } ],
"net_summary": [ { "party": "supplier_A", "net": -1250.0 } ]
}
เปิดข้อพิพาท (กันออกจากรอบ)
UC-5.1 Main Flow: supplier ไม่เห็นด้วยกับยอด → เปิด dispute → entry
disputed
excluded_from_run(หลุดจาก settlement run ปัจจุบัน) → รอ operator พิจารณาPre: entry อยู่สถานะ
accrued/invoiced· Rules: BR-07/08 · NFR: ทุกการแก้ทิ้ง audit trail
PUT /orchestration/settlement/entries/:id/dispute
Request
{ "reason": "ค่า fulfillment ไม่ตรงตกลง", "opened_by": "supplier_A" }
Response 200
{ "entry_id": "le_01H...", "status": "disputed", "excluded_from_run": true }
ปิดข้อพิพาท
UC-5.1: operator ตัดสิน
resolutionผล upholdคงยอดเดิม → กลับเป็น accruedรอรอบถัดไปadjustแก้ยอดใหม่ → accruedvoidยกเลิก entry → สร้าง reversal entry กลับทิศ (ledger insert-only) ยอดสุทธิเป็นศูนย์ Exception: dispute หลังออก invoice → ปรับผ่าน reversal เท่านั้น · NFR: zero-sum หลังปรับ
PUT /orchestration/settlement/entries/:id/resolve-dispute
Request
{ "resolution": "adjust", "adjusted_amount": 30.0, "resolved_by": "operator_07" }
Response 200
{ "entry_id": "le_01H...", "status": "accrued", "amount": 30.0, "resolved_by": "operator_07" }
5.2 Reverse Logistics
§6.6, BR-09: คืนย้อนตามสายเดิม + settlement กลับทิศ · reuse ordering/returns·backoffice/returns (returnsku)
UC-5.2 — Return & Refund (Reverse Flow)
อธิบาย use case · Cross-cutting · P1 · L · entity return_cases
- Actors: Buyer/Seller A (เปิดคืน), Counterparty (B stockist / owner / distributor ตาม OM), Operator · Pre: order ส่งถึง/รับของแล้ว · Post:
refunded+ settlement reversal กลับทิศ (BR-09); สินค้าคืนตามสายเดิม - Trigger: ลูกค้า/ผู้ขายขอคืน · Main: เปิดเคส
requested→ counterparty อนุมัติapproved→ ขนคืนตามสาย OM (in_return→received) → คืนเงินrefunded+ สร้าง reversal entry - Alternate: ปฏิเสธ →
rejected; dropship/OM-1 ค้าง → คืนถึงเจ้าของจริง (B) ไม่ใช่ A · Exception: ของไม่ถึงปลายทางคืน → escalate operator · Rules: BR-09 (settlement กลับทิศตาม OM ต้นทาง) · NFR: reversal idempotent, map กับ entry เดิม - Acceptance: order OM-1 delivered → เปิดคืน → B อนุมัติ → received → refunded + reversal entry ผูกกับ entry เดิม · เกินกำหนด →
rejected
เปิด return case → requested
UC-5.2 Main Flow: ลูกค้า/ผู้ขายขอคืน → เปิดเคส
requested→ counterparty อนุมัติ → ขนคืนตามสายเดิม (in_return→received) → คืนเงินrefunded+ reversal entry (BR-09)
om:stockist|consolidate|share_sku|shipper— กำหนดว่าคืนตามสาย OM ไหน Alternate: dropship/OM-1 ค้าง → คืนถึงเจ้าของจริง (B) ไม่ใช่ A Exception: ของไม่ถึงปลายทางคืน → escalate operator · reuseordering/returns·backoffice/returns(returnsku)
POST /orchestration/returns
Headers: Authorization: Bearer <jwt> · Idempotency-Key (required) · X-Request-ID
Request
{ "source_order_id": "ord_A_001", "om": "stockist", "reason": "damaged", "items": [ { "sku_id": "sku_123", "qty": 1 } ] }
Response 201
{
"return_case_id": "ret_01H...",
"source_order_id": "ord_A_001",
"om": "stockist",
"status": "requested",
"items": [ { "sku_id": "sku_123", "qty": 1 } ],
"created_date": "2026-06-13T10:00:00Z"
}
อนุมัติคำขอคืน → approved
UC-5.2: counterparty (B stockist / owner / distributor ตาม OM) อนุมัติ — ระบุปลายทางคืน
PUT /orchestration/returns/:id/approve
Request
{ "approved_by": "supplier_B", "note": "รับคืนได้" }
Response 200
{ "return_case_id": "ret_01H...", "status": "approved", "return_to_supplier_id": "supplier_B" }
Errors: 409 INVALID_STATE
ปฏิเสธคำขอคืน → rejected
UC-5.2 Alternate: เช่น เกินกำหนดรับคืน — ต้องอยู่
requestedเท่านั้น
PUT /orchestration/returns/:id/reject
Request
{ "rejected_by": "supplier_B", "reason": "เกินกำหนดรับคืน" }
Response 200
{ "return_case_id": "ret_01H...", "status": "rejected", "reason": "เกินกำหนดรับคืน" }
Errors: 409 INVALID_STATE (ไม่อยู่ requested)
เปลี่ยนสถานะ (in_return →received→refunded)
UC-5.2: เดินสถานะตามสาย — เมื่อ
refundedระบบสร้าง settlement reversal entry กลับทิศ (BR-09) ผูกกับ entry เดิม + emitReturnRefundedNFR: reversal idempotent, map กับ entry เดิม State machine:
requested → approved → in_return → received → refunded
PUT /orchestration/returns/:id/transition
Request
{ "to": "refunded", "refund_amount": 120.0 }
Response 200
{
"return_case_id": "ret_01H...",
"status": "refunded",
"refund_amount": 120.0,
"reversal_entry_id": "le_rev_01H...",
"updated_date": "2026-06-14T10:00:00Z"
}
Note: สร้าง settlement reversal entry กลับทิศ (BR-09)
Errors: 409 INVALID_TRANSITION
5.3 Webhooks & Domain Events
Event Envelope (§6.2) — ใช้ Outbox+CDC (Debezium) + Kafka (ของใหม่ที่ orchestration เพิ่ม; flow-api เดิมไม่มี event bus)
{ "event_id":"uuid","event_type":"FulfillmentAssigned","schema_version":"1.0",
"occurred_at":"2026-06-10T08:30:00Z","producer":"orchestration-service",
"tenant_id":"supplier_A","partition_key":"ord_A_001",
"correlation_id":"saga_01H...","causation_id":"evt_prev","idempotency_key":"...","payload":{} }
- Topic:
<domain>.<entity>.<event>.v<n>(orch.fulfillment.assigned.v1); DLQ<topic>.dlq; retry<topic>.retry.5s/.retry.1m event_typePascalCase, topic lowercase dotted; at-least-once + idempotent consumer; payload PII-free (reference id)
Domain Event Catalog (§6.9)
| Event | Topic | Producer | Consumer | partition_key |
|---|---|---|---|---|
| CollaborationActivated / Revoked | orch.collaboration.activated/revoked.v1 | orch | grant/tenant svcs | collaboration_id |
| SkuMappedToCatalog | orch.catalog.mapped.v1 | orch | OM-2/3 | sku_id |
| SaleOrderCreated | order.sale.created.v1 | order-service | orch | order_id |
| FulfillmentAssigned | orch.fulfillment.assigned.v1 | orch | stockist app | order_id |
| StockReserved / StockReserveFailed | inv.stock.reserved/failed.v1 | inventory-service | orch (saga) | order_id |
| FulfillmentStatusChanged | orch.fulfillment.status.v1 | stockist | order (relay) | order_id |
| CampaignClosed / CombinedPOPlaced | orch.consolidation.closed/po_placed.v1 | orch | organizer/distributor | campaign_id |
| InboundReceived / AllocationCreated | orch.consolidation.inbound/allocated.v1 | inventory/orch | orch/seller | campaign_id |
| SharedSkuPublished / Updated / Revoked | orch.sharedsku.published/updated/revoked.v1 | orch/product | consumers | grant_id / source_sku_id |
| DeliveryJobCreated | orch.delivery.created.v1 | orch | matching | job_id |
| CarrierOffered / OfferAccepted / OfferExpired | orch.delivery.offered/accepted/expired.v1 | matching | carrier app | job_id |
| DeliveryStatusChanged | orch.delivery.status.v1 | carrier | order (relay) | job_id |
| ReturnRequested / ReturnRefunded | orch.return.requested/refunded.v1 | orch | tenant svcs | order_id |
| SettlementAccrued | orch.settlement.accrued.v1 | orch | settlement | collaboration_id |
Webhook (Settlement ↔ ERP)
settlement-service → B-Plus/VSMS/Certu webhook + retry, ลงนาม HMAC (X-Signature: sha256=...), payload reference id, ฝั่งรับ idempotent (event_id)
จบส่วน Operating Models — จากนี้คือส่วนอ้างอิงสำหรับเปิดใช้ระหว่างทำงาน
ส่วนที่ 3 — ภาคผนวก Reference (G–L)
ส่วนนี้ไม่ได้ออกแบบให้อ่านเรียงหน้า แต่ไว้เปิดหา — สถานะที่อนุญาต (G), ตารางสรุป endpoint (H), โครงสร้างข้อมูล (I), งานที่ค้าง (J), แผนส่งมอบ (K) และประวัติการแก้ไข (L)
G. State Machine Reference
อ้าง §6.8 — สถานะที่ API คืน/รับ ต้องตรง transition ที่อนุญาต (มิฉะนั้น 409)
| Entity | สถานะ |
|---|---|
| Collaboration | pending → active / rejected / expired · active ↔ suspended · active/suspended → revoked |
| Fulfillment Assignment | assigned → accepted / rejected / cancelled · accepted → picking → packed → shipped → delivered |
| Consolidation Campaign | open → closed · closed → ordered(MOQ met) / cancelled(not met) · ordered → receiving → settled |
| Shared SKU Grant | active ↔ paused · active/paused → revoked |
| Delivery Job | created → offered · offered → assigned / created(declined·expired) · created → escalated · escalated → assigned (operator direct assign) · assigned → picked_up → in_transit → delivered · in_transit → failed → created(re-attempt) · created/offered/assigned → cancelled (ก่อน pickup) |
| Carrier | pending → verified / suspended · verified → active · active ↔ suspended |
| Return Case | requested → approved → in_return → received → refunded · requested → rejected |
| Settlement Run | processing → invoiced → settled · processing → failed |
| Catalog Item | active ↔ needs_review · active → merged (reversible — merged_into) |
transition ที่เกิดจาก scheduler (ไม่มี endpoint ตรง): collaboration
pending → expired(เกิน accept timeout /valid_to), offeroffered → expired(เกินexpires_at), campaignopen → closed(ถึงclose_at) — ตรวจทุก ≤ 1 นาที + emit event ตามปกติ
H. Endpoint Summary Matrix
ฐาน: https://ttmart-gateway.flow-solution.co/api/v1/orchestration
| เรื่อง | UC | Method & Path (ตัดฐาน) | สถานะผล |
|---|---|---|---|
| Foundation | F1 | POST /collaborations · POST /collaborations/:id/accept · /decline · PUT /collaborations/:id/approve · GET /collaborations(/:id) · GET /grants(/:id) | 201/200 |
| Foundation | F2 | PUT /collaborations/:id/revoke · /suspend · /resume | 202/200 |
| Foundation | F3 | POST /catalog/map · /map/batch · /unmap · GET /catalog/map/batch/:job_id · PUT /catalog/items/:id/resolve · GET /catalog/items/:id · /review-queue | 200/202 |
| Foundation | F4 | POST /settlement/runs | 202 |
| OM-1 | 1.1 | POST /fulfillment/assignments · GET /fulfillment/assignments(/:id) · PUT .../:id/reassign · /cancel | 202/200 |
| OM-1 | 1.2 | GET /fulfillment/assignments/:id/status | 200 |
| OM-1 | 1.3 | GET /fulfillment/queue · POST .../:id/accept · /reject · PUT .../:id/transition | 200/202 |
| OM-2 | 2.1 | POST /consolidation/campaigns | 201 |
| OM-2 | 2.2 | POST /consolidation/campaigns/:id/commitments · PUT/DELETE /consolidation/commitments/:id | 201/200 |
| OM-2 | 2.3 | PUT /consolidation/campaigns/:id/close · GET .../combined-po | 202/200 |
| OM-2 | 2.4 | POST /consolidation/campaigns/:id/inbound · GET .../allocations | 202/200 |
| OM-3 | 3.1 | POST /shared-skus/publish · GET /shared-skus/catalog · /grants | 201/200 |
| OM-3 | 3.2 | POST /shared-skus/:grant_id/import | 201 |
| OM-3 | 3.3 | GET /shared-skus/:grant_id/sync-status · POST .../resync | 200/202 |
| OM-3 | 3.4 | PUT /shared-skus/:grant_id/revoke · /pause · /resume | 202/200 |
| OM-4 | 4.1 | POST /carriers/register · PUT /carriers/:id(/verify) · GET /carriers(/:id) | 201/200 |
| OM-4 | 4.2 | POST /delivery/jobs · GET /delivery/jobs · PUT /delivery/jobs/:id/cancel | 201/200 |
| OM-4 | 4.3 | POST /delivery/jobs/:id/match · POST /delivery/offers/:offer_id/accept · /decline · POST /delivery/jobs/:id/bids · PUT /delivery/jobs/:id/assign · GET /carriers/me/offers | 202/200/201 |
| OM-4 | 4.4 | PUT /delivery/jobs/:id/transition · GET /delivery/jobs/:id/tracking | 200/202 |
| Settle | — | GET /settlement/ledger · GET /settlement/runs(/:run_id) · PUT /settlement/entries/:id/dispute · /resolve-dispute | 200/202 |
| Reverse | — | POST /returns · PUT /returns/:id/approve · /reject · /transition | 201/200 |
| ระบบ | — | GET /health · POST /auth/delegation-tokens | 200 |
I. DB Schema ของ Orchestration Layer
ออกแบบให้ ตรง convention DB จริงของ flow-api (ตรวจจาก GORM models เช่น skus, inventory_movements, inventory_adjustment):
| convention (จาก code จริง) | ค่า |
|---|---|
| Engine | MySQL (GORM) |
| Primary key | id varchar(36) (UUID string) — gorm:"column:id;primaryKey" |
| Tenant column | supplier_id varchar(36) + index (single-owner เดิม) |
| Timestamps | created_date / updated_date (autoCreateTime/autoUpdateTime) — ไม่ใช่ created_at |
| Audit columns | created_by / updated_by varchar(36) |
| Cancel pattern | canceled_date datetime null / canceled_by varchar(36) null |
| Status | int หรือ MySQL enum('A','B',...) |
| Table name | snake_case พหูพจน์ ผ่าน TableName() |
| Schema | แยก schema orchestration ใน instance เดิม (ADR-07) · ไม่มี FK ข้าม boundary → อ้าง (supplier_id, <entity>_id) |
I.1 ERD (ภาพรวมความสัมพันธ์)
I.2 Foundation tables
-- ข้อตกลงความร่วมมือข้าม tenant (consent primitive)
CREATE TABLE orchestration.collaborations (
id VARCHAR(36) NOT NULL PRIMARY KEY,
type ENUM('stockist','consolidate','share_sku','fulfillment_matching') NOT NULL,
initiator_supplier_id VARCHAR(36) NOT NULL,
counterparty_supplier_id VARCHAR(36) NOT NULL,
scope JSON NOT NULL, -- { sku_ids[], actions[], resource_filter{} }
terms JSON NULL, -- { fee_model, fee_value, sla{} }
status ENUM('pending','active','suspended','revoked','rejected','expired') NOT NULL DEFAULT 'pending',
valid_from DATETIME NULL,
valid_to DATETIME NULL,
requires_operator_approval BOOLEAN NOT NULL DEFAULT FALSE,
approved_by VARCHAR(36) NULL,
status_reason VARCHAR(255) NULL, -- เหตุผล decline/suspend/revoke ล่าสุด
in_flight_policy ENUM('honor','compensate') NULL, -- ใช้ตอน revoke
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
updated_by VARCHAR(36) NULL,
INDEX idx_collab_initiator (initiator_supplier_id),
INDEX idx_collab_counterparty (counterparty_supplier_id),
INDEX idx_collab_status (status)
);
-- สิทธิ์แบบละเอียด (ใครทำ action อะไร กับ resource ใด)
CREATE TABLE orchestration.collaboration_grants (
id VARCHAR(36) NOT NULL PRIMARY KEY,
collaboration_id VARCHAR(36) NOT NULL,
grantee_supplier_id VARCHAR(36) NOT NULL, -- ผู้ได้รับสิทธิ์ทำแทน
on_behalf_of_supplier_id VARCHAR(36) NOT NULL, -- เจ้าของ resource
actions JSON NOT NULL, -- ["inventory:reserve", ...]
resource_filter JSON NULL,
status ENUM('active','revoked') NOT NULL DEFAULT 'active',
revoked_date DATETIME NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_grant_collab (collaboration_id),
INDEX idx_grant_status (status)
);
-- Master Catalog golden record (ต่อยอด skus.master_sku_id)
CREATE TABLE orchestration.catalog_items (
id VARCHAR(36) NOT NULL PRIMARY KEY, -- = master_sku_id
gtin VARCHAR(64) NULL,
title VARCHAR(255) NOT NULL,
brand VARCHAR(100) NULL,
attributes JSON NULL,
status ENUM('active','needs_review','merged') NOT NULL DEFAULT 'active',
merged_into VARCHAR(36) NULL, -- reversible merge
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
UNIQUE KEY uq_catalog_gtin (gtin)
);
-- map SKU ของแต่ละ tenant → catalog_item (ไม่มี FK ข้าม schema)
CREATE TABLE orchestration.sku_catalog_maps (
id VARCHAR(36) NOT NULL PRIMARY KEY,
supplier_id VARCHAR(36) NOT NULL,
sku_id VARCHAR(36) NOT NULL, -- อ้าง flow.skus.id (validate app-layer)
master_sku_id VARCHAR(36) NOT NULL, -- → catalog_items.id
match_method ENUM('deterministic_gtin','probabilistic','manual','created') NOT NULL,
created_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
UNIQUE KEY uq_map_supplier_sku (supplier_id, sku_id),
INDEX idx_map_master (master_sku_id)
);
-- คิว mapping กำกวมรอ steward review (UC-F3 needs_review — backing store ของ GET /catalog/review-queue)
CREATE TABLE orchestration.catalog_review_queue (
id VARCHAR(36) NOT NULL PRIMARY KEY,
supplier_id VARCHAR(36) NOT NULL,
sku_id VARCHAR(36) NOT NULL,
candidates JSON NOT NULL, -- [{master_sku_id, title, score}]
status ENUM('pending','resolved') NOT NULL DEFAULT 'pending',
resolved_by VARCHAR(36) NULL,
resolved_action ENUM('link','create','merge') NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
UNIQUE KEY uq_review_supplier_sku (supplier_id, sku_id),
INDEX idx_review_status (status)
);
I.3 OM-1 Stockist
CREATE TABLE orchestration.fulfillment_assignments (
id VARCHAR(36) NOT NULL PRIMARY KEY,
collaboration_id VARCHAR(36) NOT NULL,
sale_order_id VARCHAR(36) NOT NULL, -- อ้าง flow order (owner = A)
owner_supplier_id VARCHAR(36) NOT NULL, -- A (seller)
stockist_supplier_id VARCHAR(36) NOT NULL, -- B (fulfiller)
warehouse_id VARCHAR(36) NULL,
status ENUM('assigned','accepted','rejected','picking','packed','shipped','delivered','cancelled') NOT NULL DEFAULT 'assigned',
routing_strategy ENUM('nearest','cheapest','load_balanced') NOT NULL DEFAULT 'nearest',
correlation_id VARCHAR(36) NULL, -- saga id
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
canceled_date DATETIME NULL,
canceled_by VARCHAR(36) NULL,
INDEX idx_fa_owner (owner_supplier_id),
INDEX idx_fa_stockist (stockist_supplier_id),
INDEX idx_fa_order (sale_order_id),
INDEX idx_fa_status (status)
);
I.4 OM-2 Consolidate Purchase
CREATE TABLE orchestration.consolidation_campaigns (
id VARCHAR(36) NOT NULL PRIMARY KEY,
organizer_supplier_id VARCHAR(36) NOT NULL,
master_sku_id VARCHAR(36) NOT NULL,
distributor_id VARCHAR(36) NOT NULL,
moq INT NOT NULL,
tiers JSON NOT NULL, -- [{min_qty, unit_price}]
open_at DATETIME NOT NULL,
close_at DATETIME NOT NULL,
status ENUM('open','closed','ordered','receiving','settled','cancelled') NOT NULL DEFAULT 'open',
combined_po_id VARCHAR(36) NULL, -- → flow purchase_orders.id
total_qty INT NOT NULL DEFAULT 0,
received_qty INT NOT NULL DEFAULT 0, -- UC-2.4 (Σ allocation = received_qty)
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_camp_status (status),
INDEX idx_camp_master (master_sku_id)
);
CREATE TABLE orchestration.consolidation_commitments (
id VARCHAR(36) NOT NULL PRIMARY KEY,
campaign_id VARCHAR(36) NOT NULL,
supplier_id VARCHAR(36) NOT NULL,
sku_id VARCHAR(36) NOT NULL,
qty INT NOT NULL,
status ENUM('committed','withdrawn','allocated') NOT NULL DEFAULT 'committed',
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
UNIQUE KEY uq_commit (campaign_id, supplier_id, sku_id),
INDEX idx_commit_campaign (campaign_id)
);
CREATE TABLE orchestration.consolidation_allocations (
id VARCHAR(36) NOT NULL PRIMARY KEY,
campaign_id VARCHAR(36) NOT NULL,
supplier_id VARCHAR(36) NOT NULL,
allocated_qty INT NOT NULL,
shortfall_qty INT NOT NULL DEFAULT 0,
inbound_ref VARCHAR(36) NULL, -- inbound ที่สร้าง on-behalf
created_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_alloc_campaign (campaign_id),
INDEX idx_alloc_supplier (supplier_id)
);
I.5 OM-3 Share SKU
CREATE TABLE orchestration.shared_sku_grants (
id VARCHAR(36) NOT NULL PRIMARY KEY,
collaboration_id VARCHAR(36) NOT NULL,
owner_supplier_id VARCHAR(36) NOT NULL, -- B
sku_ids JSON NOT NULL,
share_mode ENUM('catalog_only','sell_on_behalf','dropship') NOT NULL,
price_policy JSON NOT NULL, -- { type, value, version }
sync_fields JSON NOT NULL, -- ["title","image",...]
audience JSON NOT NULL, -- { type, supplier_ids[] | pool }
status ENUM('active','paused','revoked') NOT NULL DEFAULT 'active',
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_ssg_owner (owner_supplier_id),
INDEX idx_ssg_status (status)
);
-- linked SKU = row จริงในสโคป A (ADR-06) — orchestration เก็บ "สายโยง" ส่วน row ขายจริงอยู่ flow.skus
CREATE TABLE orchestration.linked_skus (
id VARCHAR(36) NOT NULL PRIMARY KEY,
shared_sku_grant_id VARCHAR(36) NOT NULL,
consumer_supplier_id VARCHAR(36) NOT NULL, -- A
source_supplier_id VARCHAR(36) NOT NULL, -- B
source_sku_id VARCHAR(36) NOT NULL,
linked_sku_id VARCHAR(36) NOT NULL, -- → flow.skus.id ที่สร้างในสโคป A
master_sku_id VARCHAR(36) NOT NULL,
status ENUM('active','deactivated') NOT NULL DEFAULT 'active',
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_linked_consumer (consumer_supplier_id),
INDEX idx_linked_grant (shared_sku_grant_id)
);
I.6 OM-4 Shipper Matching
CREATE TABLE orchestration.carriers (
id VARCHAR(36) NOT NULL PRIMARY KEY, -- actor ใหม่ (ไม่ใช่ supplier)
type ENUM('individual','fleet','3pl') NOT NULL,
service_areas JSON NOT NULL,
vehicle JSON NULL, -- { type, capacity_kg }
rate_card JSON NULL, -- { base, per_km }
kyc JSON NULL, -- เก็บ ref/encrypted (PDPA)
payout JSON NULL,
rating DECIMAL(3,2) NULL,
status ENUM('pending','verified','active','suspended') NOT NULL DEFAULT 'pending',
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_carrier_status (status)
);
CREATE TABLE orchestration.delivery_jobs (
id VARCHAR(36) NOT NULL PRIMARY KEY,
source_order_ids JSON NOT NULL,
owner_supplier_id VARCHAR(36) NOT NULL,
fulfillment_assignment_id VARCHAR(36) NULL, -- เมื่อต่อจาก OM-1 packed
pickup JSON NOT NULL,
dropoff JSON NOT NULL,
package JSON NOT NULL,
status ENUM('created','offered','assigned','picked_up','in_transit','delivered','failed','escalated','cancelled') NOT NULL DEFAULT 'created',
assigned_carrier_id VARCHAR(36) NULL,
pod JSON NULL, -- proof of delivery
correlation_id VARCHAR(36) NULL, -- saga id (ต่อจาก OM-1)
version INT NOT NULL DEFAULT 0, -- optimistic lock (กัน double-assign UC-4.3)
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_job_status (status),
INDEX idx_job_owner (owner_supplier_id),
INDEX idx_job_carrier (assigned_carrier_id)
);
CREATE TABLE orchestration.delivery_job_offers (
id VARCHAR(36) NOT NULL PRIMARY KEY,
delivery_job_id VARCHAR(36) NOT NULL,
carrier_id VARCHAR(36) NOT NULL,
offer_type ENUM('broadcast','auction','direct') NOT NULL,
bid_amount DECIMAL(12,2) NULL, -- auction
status ENUM('offered','accepted','declined','expired') NOT NULL DEFAULT 'offered',
expires_at DATETIME NOT NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
INDEX idx_offer_job (delivery_job_id),
INDEX idx_offer_carrier (carrier_id),
INDEX idx_offer_status (status)
);
I.7 Settlement, Reverse, Saga & Cross-cutting
CREATE TABLE orchestration.settlement_ledger (
id VARCHAR(36) NOT NULL PRIMARY KEY,
collaboration_id VARCHAR(36) NULL,
payer_supplier_id VARCHAR(36) NOT NULL,
payee_supplier_id VARCHAR(36) NULL, -- null = carrier (ใช้ payee_carrier_id)
payee_carrier_id VARCHAR(36) NULL,
amount DECIMAL(12,2) NOT NULL,
fee_type ENUM('fulfillment','commission','delivery','group_buy','platform','vat','reversal') NOT NULL,
status ENUM('accrued','invoiced','settled','disputed') NOT NULL DEFAULT 'accrued',
source_txn VARCHAR(36) NOT NULL, -- fa_/job_/campaign_ id
settlement_run_id VARCHAR(36) NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
UNIQUE KEY uq_ledger_idem (settlement_run_id, payer_supplier_id, source_txn), -- idempotency ต่อรอบ
INDEX idx_ledger_collab (collaboration_id),
INDEX idx_ledger_status (status)
);
CREATE TABLE orchestration.settlement_runs (
id VARCHAR(36) NOT NULL PRIMARY KEY,
period VARCHAR(7) NOT NULL, -- 'YYYY-MM'
status ENUM('processing','invoiced','settled','failed') NOT NULL DEFAULT 'processing',
net_summary JSON NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_run_period (period)
);
CREATE TABLE orchestration.return_cases (
id VARCHAR(36) NOT NULL PRIMARY KEY,
source_order_id VARCHAR(36) NOT NULL,
om ENUM('stockist','consolidate','share_sku','shipper') NOT NULL,
reason VARCHAR(255) NULL,
items JSON NOT NULL,
status ENUM('requested','approved','rejected','in_return','received','refunded') NOT NULL DEFAULT 'requested',
refund_amount DECIMAL(12,2) NULL,
reversal_entry_id VARCHAR(36) NULL, -- → settlement_ledger.id (BR-09)
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
created_by VARCHAR(36) NOT NULL,
INDEX idx_return_order (source_order_id),
INDEX idx_return_status (status)
);
-- timeline กลางทุก entity (assignment/job/return) — backing store ของ field `timeline` ใน API
CREATE TABLE orchestration.status_histories (
id VARCHAR(36) NOT NULL PRIMARY KEY,
entity_type VARCHAR(64) NOT NULL, -- 'fulfillment_assignment','delivery_job','return_case',...
entity_id VARCHAR(36) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
note VARCHAR(255) NULL,
geo JSON NULL, -- delivery tracking point
actor VARCHAR(36) NULL, -- supplier/carrier/operator id
created_date DATETIME NOT NULL,
INDEX idx_hist_entity (entity_type, entity_id, created_date)
);
-- saga state (recover หลัง crash)
CREATE TABLE orchestration.saga_instances (
id VARCHAR(36) NOT NULL PRIMARY KEY, -- = correlation_id
saga_type VARCHAR(64) NOT NULL, -- 'stockist_fulfillment', ...
current_step VARCHAR(64) NOT NULL,
status ENUM('running','completed','compensating','saga_dead') NOT NULL DEFAULT 'running',
payload JSON NOT NULL,
created_date DATETIME NOT NULL,
updated_date DATETIME NOT NULL,
INDEX idx_saga_status (status)
);
-- transactional outbox (เขียนพร้อม business row ใน 1 tx → Debezium publish)
CREATE TABLE orchestration.outbox_events (
id VARCHAR(36) NOT NULL PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(36) NOT NULL, -- = partition_key
event_type VARCHAR(64) NOT NULL, -- PascalCase
topic VARCHAR(128) NOT NULL, -- orch.<entity>.<event>.vN
payload JSON NOT NULL,
correlation_id VARCHAR(36) NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_date DATETIME NOT NULL,
INDEX idx_outbox_unpub (published, created_date)
);
-- dedup สำหรับ idempotent consumer + Idempotency-Key
CREATE TABLE orchestration.processed_events (
idempotency_key VARCHAR(128) NOT NULL PRIMARY KEY,
result_ref VARCHAR(36) NULL,
created_date DATETIME NOT NULL
);
-- audit ทุก cross-tenant access (immutable, retention ≥ 1 ปี)
CREATE TABLE orchestration.orchestration_audit (
id VARCHAR(36) NOT NULL PRIMARY KEY,
actor_supplier_id VARCHAR(36) NULL,
acting_for VARCHAR(36) NULL,
on_behalf_of VARCHAR(36) NULL,
grant_id VARCHAR(36) NULL,
action VARCHAR(128) NOT NULL,
resource VARCHAR(255) NULL,
decision ENUM('allow','deny') NOT NULL,
correlation_id VARCHAR(36) NULL,
created_date DATETIME NOT NULL,
INDEX idx_audit_actor (actor_supplier_id),
INDEX idx_audit_created (created_date)
);
I.8 GORM model ตัวอย่าง (ให้ตรง pattern flow-api)
// orchestration-service/pkg/model/collaborationmodel/collaboration.go
type Collaboration struct {
ID string `gorm:"column:id;primaryKey" json:"id"`
Type string `gorm:"column:type" json:"type"`
InitiatorSupplierID string `gorm:"column:initiator_supplier_id;index" json:"initiator_supplier_id"`
CounterpartySupplierID string `gorm:"column:counterparty_supplier_id;index" json:"counterparty_supplier_id"`
Scope datatypes.JSON `gorm:"column:scope" json:"scope"`
Terms datatypes.JSON `gorm:"column:terms" json:"terms"`
Status string `gorm:"column:status;default:pending" json:"status"`
ValidFrom *time.Time `gorm:"column:valid_from" json:"valid_from"`
ValidTo *time.Time `gorm:"column:valid_to" json:"valid_to"`
CreatedDate time.Time `gorm:"column:created_date;autoCreateTime" json:"created_date"`
UpdatedDate time.Time `gorm:"column:updated_date;autoUpdateTime" json:"updated_date"`
CreatedBy string `gorm:"column:created_by" json:"created_by"`
UpdatedBy string `gorm:"column:updated_by" json:"updated_by"`
}
func (Collaboration) TableName() string { return "collaborations" }