markpadmarkpad
Solution Blueprint·79 min read·15,780 words

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)OMBreadthImpactBlocking?เหตุผล
1Fulfillment ReachOM-1 Stockistสูงสุด — ทุกออเดอร์ที่ต้องส่งปลดบล็อกการขาย + ใช้คลัง/รถที่ว่างให้เกิดรายได้บล็อก — ส่งไม่ถึง = ขายไม่ได้แนวนอนสุด แตะทุกออเดอร์ + unlock asset ที่จมทั้งเครือข่าย
1bCarrier AccessOM-4 Shipperสูง — ทุกออเดอร์ขา last-mileลดต้นทุน last-mile + เพิ่ม fill-rateบล็อก OM-1 — packed แล้วไม่มีคนส่งenabler ของ OM-1 (packed → delivery job); แยกกันไม่ได้
2Product AssortmentOM-3 Share SKUกลาง-สูง — ต่อ SKU ที่แชร์เพิ่มยอด/ขยาย catalog เร็วadditive — ไม่บล็อก แต่เสียโอกาสโตgrowth lever; dropship reuse fulfillment OM-1
3Purchasing PowerOM-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-4Fulfillment + Carrierใหญ่สุดXL+XLdelivery backbone + dropship ของ OM-3
2 OM-3Assortmentใหญ่Lใช้ fulfillment เดิมต่อยอด
3 OM-2Purchasing PowerกลางL
4 Hardeningการเงิน/scale/complianceM-Lgo-live เต็ม

สารบัญ (Table of Contents)

ส่วนที่ 1 — รากฐานและกติกากลาง (A–F)

ส่วนที่ 2 — Foundation + Operating Models (เรื่องที่ 0–5)

ส่วนที่ 3 — ภาคผนวก Reference (G–L)


ส่วนที่ 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-apiorchestration ทำตาม
FrameworkGo + Fiber v2 (gofiber/fiber/v2), GORM/MySQL, Redis, MinIOเหมือนกัน
API Gatewayhttps://ttmart-gateway.flow-solution.cobase เดียวกัน
Base pathapp.Group("api/v1") (บางส่วน api/v2)api/v1/orchestration/...
Route group แบบ "ช่องทาง"ordering/ backoffice/ ssc/ vansales/ merchant/ pos/ consumer/ external/ public/เพิ่มช่องทางใหม่ orchestration/ + reuse external/logistics/*
Auth middlewareAuthorizationCustomerToken() / SupplierToken() / SscToken() / VanSaleToken() / MerchantToken() / ExternalToken()เพิ่ม AuthorizationOrchestrationToken() + delegation
Tenant scopingjwthandler.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 casingkebab-case (all-sub-categories, flash-sale-product) + บาง snake (product_groups, purchase_order) ; param :id :barcodekebab-case เป็นหลัก
VerbsGet/Post/Put/Delete/Patch ; Put /activate/:id /deactivate/:id สำหรับเปลี่ยนสถานะทำตาม pattern เดียวกัน
HealthGET /healthเหมือนกัน
ไม่มีในของเดิมIdempotency-Key / X-Request-ID / correlation idorchestration เพิ่มใหม่ (cross-tenant saga ต้องใช้)

ของเดิมที่ orchestration reuse ตรงๆ (พบใน route จริง):

  • external/logistics/purchase-orders, backoffice/purchase → ออก PO รวม (OM-2)
  • external/logistics/delivery, external/logistics/shipments, vansales/deliverydelivery/shipment (OM-4)
  • backoffice/returns, ordering/returns, vansales/returnsreverse logistics
  • master_sku_id (GetMasterSkuPaged/master-detail), pricetiersMaster Catalog (OM-2/3)
  • tccrouting library + claim route_planner_idmatching (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-apiorchestration
Authorization: Bearer <jwt>✓ ทุก protected route✓ (access / delegation token)
Idempotency-Key— (ไม่มี)ใหม่ ทุก POST/PUT ที่เปลี่ยนสถานะ cross-tenant
X-Request-IDใหม่ (เข้าสู่ correlation/audit)
X-On-Behalf-Oftenant ปลายทางของ 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 เดิม)

TokenMiddleware (เสนอ ตาม naming เดิม)claims
Orchestration accessAuthorizationOrchestrationToken()supplier_id, supplier_ids[], user_id, roles
Delegation token (ทำแทน)ตรวจใน on-behalf callacting_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)

  1. orchestrator ตรวจ collaboration active? + resource_filter + action ก่อนออก token
  2. 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)

CodeHTTPUC
GRANT_DENIED403ทุก cross-tenant
GRANT_REVOKED410F2, 3.4
SCOPE_INVALID422F1, 3.1
IDEMPOTENCY_KEY_REUSED409ทุก mutating
NEEDS_REVIEW409F3
CATALOG_NOT_MAPPED422F3, 2.2, 3.1
RESERVE_FAILED4091.1
SLA_TIMEOUT4081.1, 4.3
MOQ_NOT_MET4222.3
WINDOW_CLOSED4222.2, 2.3
PRICE_POLICY_VIOLATION4223.2
NO_CARRIER_AVAILABLE4094.3
OFFER_EXPIRED4094.3
DOUBLE_ASSIGN4094.3
KYC_FAILED4224.1
DISPUTED_EXCLUDED409F4
INVALID_STATE409ทุก state-changing (สถานะปัจจุบันไม่อนุญาต action นั้น)
INVALID_TRANSITION4091.3, 4.4, 5.2 (transition ข้ามขั้น — ดู §G)
INVALID_WINDOW4222.1
INCOMPLETE_ADDRESS4224.2
SKU_ALREADY_IMPORTED4093.2
NO_STOCKIST_AVAILABLE4091.1
POD_REQUIRED4224.4
CARRIER_NOT_ELIGIBLE4224.3
IDEMPOTENCY_KEY_REQUIRED400ทุก mutating ที่บังคับ key
VALIDATION_ERROR400ทุก endpoint (payload ไม่ครบ/ผิด type)
UNAUTHORIZED401ทุก protected route (token หมดอายุ/ไม่มี)
FORBIDDEN403role ไม่ตรง (เช่น operator-only)
NOT_FOUND404resource ไม่มี หรืออยู่นอก scope (ไม่ leak ว่ามีอยู่)
RATE_LIMITED429ทุก endpoint (Retry-After header)

โครงสร้างทุก UC: อธิบาย use caseAPI 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 ของ Aorder-service ordering/ordersdelegation(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_idservice
คืนสินค้า (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 + event CollaborationActivated
  • 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 ในหัวข้อนี้

  1. POST /api/v1/orchestration/collaborations — เสนอความร่วมมือ → pending
  2. POST /collaborations/:id/accept — counterparty ตอบรับ → active (+grant)
  3. POST /collaborations/:id/decline — ปฏิเสธ → rejected
  4. PUT /collaborations/:id/approve — operator อนุมัติ → active
  5. GET /collaborations — รายการ (paged)
  6. GET /collaborations/:id — รายละเอียด
  7. GET /grants/:id — รายละเอียด grant
  8. GET /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 + emit CollaborationActivated — ถ้าเข้าเกณฑ์ต้องอนุมัติ → รอ 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 ในหัวข้อนี้

  1. PUT /collaborations/:id/revoke — เพิกถอน → revoked
  2. PUT /collaborations/:id/suspend — พักชั่วคราว → suspended
  3. PUT /collaborations/:id/resume — กลับมาใช้งาน → active

เพิกถอน → revoked

UC-F2 main flow: เพิกถอน + เหตุผล → กำหนด effective + policy in-flight → invalidate grants < 5 วินาที (BR-04) → จัดการ in-flight (honor ทำต่อจนจบ / compensate ชดเชยกลับ) → emit CollaborationRevoked + 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 ในหัวข้อนี้

  1. POST /catalog/map — map SKU เดียวเข้า Master Catalog
  2. POST /catalog/map/batch — map แบบ batch (async)
  3. GET /catalog/map/batch/:job_id — สถานะ batch
  4. GET /catalog/items/:master_sku_id — golden record
  5. GET /catalog/review-queue — คิวที่ต้อง steward review (paged)
  6. PUT /catalog/items/:id/resolve — steward ตัดสิน (ผูก/merge, reversible+audit)
  7. POST /catalog/unmap — ถอด mapping (reversible + audit)

map SKU เดียวเข้า Master Catalog

UC-F3 main flow: รับ SKU → ค้น GTIN → unique ผูกของเดิม / ไม่พบ สร้าง catalog_item ใหม่ + ผูก → set master_sku_id + emit SkuMappedToCatalog

Exception: กำกวม (หลาย 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 invoicedsettled
  • 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 ในหัวข้อนี้

  1. POST /fulfillment/assignments — สร้าง assignment (saga entrypoint)
  2. GET /fulfillment/assignments/:id — รายละเอียด (full view: orchestrator/operator)
  3. GET /fulfillment/assignments — รายการ assignment (paged, มุมมองตาม role)
  4. PUT /fulfillment/assignments/:id/reassign — บังคับ reassign (operator)
  5. PUT /fulfillment/assignments/:id/cancel — ยกเลิก (compensation: release reservation)

สร้าง assignment (saga entrypoint)

UC-1.1 entrypoint: ปกติ orchestrator สร้างอัตโนมัติจาก event SaleOrderCreated; endpoint นี้สำหรับ replay/manual

Saga: 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 ในหัวข้อนี้

  1. 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 ในหัวข้อนี้

  1. GET /fulfillment/queue — คิวงานของ Stockist B (projection: order line + ที่อยู่)
  2. POST /fulfillment/assignments/:id/accept — รับงาน → confirm reservation
  3. POST /fulfillment/assignments/:id/reject — ปฏิเสธ → UC-1.1 reassign
  4. PUT /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

  • packedtrigger 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 ในหัวข้อนี้

  1. POST /consolidation/campaigns — เปิด campaign → open
  2. GET /consolidation/campaigns — รายการ (paged)
  3. 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 ในหัวข้อนี้

  1. POST /consolidation/campaigns/:id/commitments — commit volume
  2. PUT /consolidation/commitments/:id — แก้จำนวน (ก่อนปิด)
  3. 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 ในหัวข้อนี้

  1. PUT /consolidation/campaigns/:id/close — ปิด + ออก PO รวม
  2. 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 + emit CombinedPOPlaced

MOQ ไม่ถึง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-behalf inventory-service) → fulfill preorder → SettlementAccruedsettled
  • Alternate: รับไม่ครบ → pro-rata + shortfall
  • NFR (invariant): Σ allocation = ปริมาณรับจริง

APIs ในหัวข้อนี้

  1. POST /consolidation/campaigns/:id/inbound — บันทึกรับของ + แบ่ง allocation
  2. GET /consolidation/campaigns/:id/allocations — รายการ allocation

บันทึกรับของ + แบ่ง allocation

UC-2.4 main flow: รับของ → receiving → แบ่ง allocation ตามสัดส่วน commitment → สร้าง inbound ต่อ supplier (on-behalf inventory-service) → fulfill preorder → SettlementAccruedsettled

Invariant (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 ในหัวข้อนี้

  1. POST /shared-skus/publish — เผยแพร่ SKU ให้ผู้รับ → grant active
  2. GET /shared-skus/catalog — shared catalog ที่ consumer มีสิทธิ์เห็น (paged)
  3. 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 → grant active → emit SharedSkuPublished

Fieldค่า
share_modecatalog_only | sell_on_behalf | dropship
price_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 ในหัวข้อนี้

  1. 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/skus CreateSkuWithPrice on-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_VIOLATION NFR: 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 ในหัวข้อนี้

  1. GET /shared-skus/:grant_id/sync-status — สถานะการ sync ของ linked SKU ทุก consumer
  2. POST /shared-skus/:grant_id/resync — บังคับ resync sync_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 ในหัวข้อนี้

  1. PUT /shared-skus/:grant_id/revoke — เพิกถอน → deactivate linked SKU
  2. PUT /shared-skus/:grant_id/pause — พักชั่วคราว → paused
  3. PUT /shared-skus/:grant_id/resume — กลับมาใช้ → active

เพิกถอน → deactivate linked SKU

UC-3.4 Main Flow: Owner เพิกถอน → emit SharedSkuRevoked → deactivate linked SKU ทุก consumer (< 5s — ผ่าน backoffice/skus PUT /avalible/:id style) → order ค้าง honored

Exception: 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 → ตรวจ KYC verifiedactive
  • Exception: KYC fail → suspended
  • NFR: เก็บ KYC ปลอดภัย, audit

APIs ในหัวข้อนี้

  1. POST /carriers/register — สมัคร carrier → pending
  2. PUT /carriers/:id/verify — operator ตรวจ KYC → verified/active
  3. GET /carriers/:id — รายละเอียด carrier
  4. GET /carriers — รายการ carrier (operator, paged)
  5. 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 → verifiedactive

Auth: 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: passactive (พร้อมรับงาน) · failsuspended + เหตุผล (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 ในหัวข้อนี้

  1. POST /delivery/jobs — สร้างงานส่ง → created
  2. GET /delivery/jobs — รายการงานส่ง (paged)
  3. PUT /delivery/jobs/:id/cancel — ยกเลิกงานส่ง (ก่อน pickup)

สร้างงานส่ง → created

UC-4.2 Main Flow: trigger จาก OM-1 packed หรือ seller — ดึง pickup/dropoff/package/window → สร้าง job + emit DeliveryJobCreated (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 ในหัวข้อนี้

  1. POST /delivery/jobs/:id/match — เริ่ม matching (หรือ auto จาก event)
  2. GET /carriers/me/offers — offer ที่ค้างของ carrier (AuthorizationCarrierToken)
  3. POST /delivery/offers/:offer_id/accept — รับงาน (first wins, optimistic lock)
  4. POST /delivery/offers/:offer_id/decline — ปฏิเสธ offer
  5. POST /delivery/jobs/:id/bids — เสนอราคา (auction)
  6. PUT /delivery/jobs/:id/assign — operator assign ตรง (ทางออกของ escalated)

เริ่ม matching (หรือ auto จาก event)

UC-4.3 Main Flow: คัด candidate (service area ∩, capacity, rating, ราคา — ใช้ tccrouting estimate route) → strategy broadcast / auction / direct → ส่ง offer + expiry → carrier accept → assigned + ปิด offer อื่น → ส่งต่อ UC-4.4

Exception: offer expired → ถัดไป · ไม่มีใครรับ → retry ราคาสูงขึ้น → escalated (ทางออก: operator PUT /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 อื่น + emit OfferAccepted

คนหลัง → 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_feeassigned

State 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) → DeliveryStatusChanged relay + 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 ในหัวข้อนี้

  1. PUT /delivery/jobs/:id/transition — อัปเดตสถานะการส่ง (Shipper App)
  2. GET /delivery/jobs/:id/tracking — ติดตามการส่ง (near-real-time + timeline)

อัปเดตสถานะการส่ง (Shipper App)

UC-4.4 Main Flow: picked_upin_transitdelivered (Shipper App) → emit DeliveryStatusChanged relay กลับออเดอร์ + update tracking → delivered → settle (จ่าย carrier, เก็บ supplier, หัก commission — BR-07) + SettlementAccrued

toเงื่อนไข
picked_up / in_transitตาม state machine
deliveredต้องแนบ pod ไม่งั้น 422 POD_REQUIRED
failed→ 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: entry disputedaccrued(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) · sync bplus/vsms/certu ของเดิม

Status flow: accruedinvoicedsettled (+ disputed แยกออกจากรอบ)

GET /orchestration/settlement/ledger

Query: collaboration_id status period page page_size (status: accruedinvoicedsettled+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) → settled

Idempotent ต่อรอบ (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-signed X-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แก้ยอดใหม่ → accrued
voidยกเลิก 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_returnreceived) → คืนเงิน 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_returnreceived) → คืนเงิน refunded + reversal entry (BR-09)

om: stockist | consolidate | share_sku | shipper — กำหนดว่าคืนตามสาย OM ไหน Alternate: dropship/OM-1 ค้าง → คืนถึงเจ้าของจริง (B) ไม่ใช่ A Exception: ของไม่ถึงปลายทางคืน → escalate operator · reuse ordering/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_returnreceivedrefunded)

UC-5.2: เดินสถานะตามสาย — เมื่อ refunded ระบบสร้าง settlement reversal entry กลับทิศ (BR-09) ผูกกับ entry เดิม + emit ReturnRefunded

NFR: 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_type PascalCase, topic lowercase dotted; at-least-once + idempotent consumer; payload PII-free (reference id)

Domain Event Catalog (§6.9)

EventTopicProducerConsumerpartition_key
CollaborationActivated / Revokedorch.collaboration.activated/revoked.v1orchgrant/tenant svcscollaboration_id
SkuMappedToCatalogorch.catalog.mapped.v1orchOM-2/3sku_id
SaleOrderCreatedorder.sale.created.v1order-serviceorchorder_id
FulfillmentAssignedorch.fulfillment.assigned.v1orchstockist apporder_id
StockReserved / StockReserveFailedinv.stock.reserved/failed.v1inventory-serviceorch (saga)order_id
FulfillmentStatusChangedorch.fulfillment.status.v1stockistorder (relay)order_id
CampaignClosed / CombinedPOPlacedorch.consolidation.closed/po_placed.v1orchorganizer/distributorcampaign_id
InboundReceived / AllocationCreatedorch.consolidation.inbound/allocated.v1inventory/orchorch/sellercampaign_id
SharedSkuPublished / Updated / Revokedorch.sharedsku.published/updated/revoked.v1orch/productconsumersgrant_id / source_sku_id
DeliveryJobCreatedorch.delivery.created.v1orchmatchingjob_id
CarrierOffered / OfferAccepted / OfferExpiredorch.delivery.offered/accepted/expired.v1matchingcarrier appjob_id
DeliveryStatusChangedorch.delivery.status.v1carrierorder (relay)job_id
ReturnRequested / ReturnRefundedorch.return.requested/refunded.v1orchtenant svcsorder_id
SettlementAccruedorch.settlement.accrued.v1orchsettlementcollaboration_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สถานะ
Collaborationpending → active / rejected / expired · active ↔ suspended · active/suspended → revoked
Fulfillment Assignmentassigned → accepted / rejected / cancelled · accepted → picking → packed → shipped → delivered
Consolidation Campaignopen → closed · closed → ordered(MOQ met) / cancelled(not met) · ordered → receiving → settled
Shared SKU Grantactive ↔ paused · active/paused → revoked
Delivery Jobcreated → 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)
Carrierpending → verified / suspended · verified → active · active ↔ suspended
Return Caserequested → approved → in_return → received → refunded · requested → rejected
Settlement Runprocessing → invoiced → settled · processing → failed
Catalog Itemactive ↔ needs_review · active → merged (reversible — merged_into)

transition ที่เกิดจาก scheduler (ไม่มี endpoint ตรง): collaboration pending → expired (เกิน accept timeout / valid_to), offer offered → expired (เกิน expires_at), campaign open → closed (ถึง close_at) — ตรวจทุก ≤ 1 นาที + emit event ตามปกติ

H. Endpoint Summary Matrix

ฐาน: https://ttmart-gateway.flow-solution.co/api/v1/orchestration

เรื่องUCMethod & Path (ตัดฐาน)สถานะผล
FoundationF1POST /collaborations · POST /collaborations/:id/accept · /decline · PUT /collaborations/:id/approve · GET /collaborations(/:id) · GET /grants(/:id)201/200
FoundationF2PUT /collaborations/:id/revoke · /suspend · /resume202/200
FoundationF3POST /catalog/map · /map/batch · /unmap · GET /catalog/map/batch/:job_id · PUT /catalog/items/:id/resolve · GET /catalog/items/:id · /review-queue200/202
FoundationF4POST /settlement/runs202
OM-11.1POST /fulfillment/assignments · GET /fulfillment/assignments(/:id) · PUT .../:id/reassign · /cancel202/200
OM-11.2GET /fulfillment/assignments/:id/status200
OM-11.3GET /fulfillment/queue · POST .../:id/accept · /reject · PUT .../:id/transition200/202
OM-22.1POST /consolidation/campaigns201
OM-22.2POST /consolidation/campaigns/:id/commitments · PUT/DELETE /consolidation/commitments/:id201/200
OM-22.3PUT /consolidation/campaigns/:id/close · GET .../combined-po202/200
OM-22.4POST /consolidation/campaigns/:id/inbound · GET .../allocations202/200
OM-33.1POST /shared-skus/publish · GET /shared-skus/catalog · /grants201/200
OM-33.2POST /shared-skus/:grant_id/import201
OM-33.3GET /shared-skus/:grant_id/sync-status · POST .../resync200/202
OM-33.4PUT /shared-skus/:grant_id/revoke · /pause · /resume202/200
OM-44.1POST /carriers/register · PUT /carriers/:id(/verify) · GET /carriers(/:id)201/200
OM-44.2POST /delivery/jobs · GET /delivery/jobs · PUT /delivery/jobs/:id/cancel201/200
OM-44.3POST /delivery/jobs/:id/match · POST /delivery/offers/:offer_id/accept · /decline · POST /delivery/jobs/:id/bids · PUT /delivery/jobs/:id/assign · GET /carriers/me/offers202/200/201
OM-44.4PUT /delivery/jobs/:id/transition · GET /delivery/jobs/:id/tracking200/202
SettleGET /settlement/ledger · GET /settlement/runs(/:run_id) · PUT /settlement/entries/:id/dispute · /resolve-dispute200/202
ReversePOST /returns · PUT /returns/:id/approve · /reject · /transition201/200
ระบบGET /health · POST /auth/delegation-tokens200

I. DB Schema ของ Orchestration Layer

ออกแบบให้ ตรง convention DB จริงของ flow-api (ตรวจจาก GORM models เช่น skus, inventory_movements, inventory_adjustment):

convention (จาก code จริง)ค่า
EngineMySQL (GORM)
Primary keyid varchar(36) (UUID string) — gorm:"column:id;primaryKey"
Tenant columnsupplier_id varchar(36) + index (single-owner เดิม)
Timestampscreated_date / updated_date (autoCreateTime/autoUpdateTime) — ไม่ใช่ created_at
Audit columnscreated_by / updated_by varchar(36)
Cancel patterncanceled_date datetime null / canceled_by varchar(36) null
Statusint หรือ MySQL enum('A','B',...)
Table namesnake_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" }