POS — กระบวนการทำงาน (Process flows)
สถาปัตยกรรมและวงจรชีวิตถูกสร้างอัตโนมัติจากโค้ด (dispatch, ค่า enum สถานะ CHECK, การเขียน SET status) ส่วนลำดับการทำงาน (sequences) และหมายเหตุเขียนขึ้นเอง
สถาปัตยกรรม (Architecture)
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
flowchart LR
C["Client"] --> A["Pages Fn pos<br/>bolt_session auth · company_id scope"]
A --> D["dispatch<br/>open_session · session_open · submit_order · sale · order · cash_movement · pay_in · pay_out …"]
D --> K["Kernel<br/>erp_document + journal<br/>atomic GL / stock"]
K --> DB[("D1 tables")]
D --> DB
DB -.-> T["pos_session<br/>pos_cash_count<br/>pos_order<br/>pos_order_line<br/>pos_payment<br/>pos_settlement_expectation<br/>pos_cash_movement"]
วงจรชีวิต (Lifecycle)
วงจรชีวิตเอกสารระดับ Kernel (erp_document.docstatus)
เอกสารทุกฉบับในโมดูลนี้ที่เคลื่อนไหวเงิน/สต็อกจะถูกโพสต์ผ่าน kernel ที่ใช้ร่วมกัน โดยเดินตามลำดับ docstatus ที่ตายตัว:
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
stateDiagram-v2
[*] --> Draft: createDocument (docstatus 0)
Draft --> Submitted: submitDocument (docstatus 1, journal posted)
Submitted --> Cancelled: cancelDocument (docstatus 2, reversed)
Submitted --> [*]
Cancelled --> [*]
pos_terminal.status
สถานะ: active (สถานะเริ่มต้น) · suspended · retired
pos_payment_method.status
สถานะ: active (สถานะเริ่มต้น) · retired
โฟลว์สำคัญ: submit_order (การโพสต์การขาย)
ทำงานที่ kernel ก่อน แล้วจึงตามด้วย D1 batch แบบ atomic เพียงชุดเดียว โดย kernel มีคุณสมบัติ idempotent บน operation_group = pos-order:<idempotency_key> ดังนั้นความพยายามที่ล้มเหลวจะไม่เขียนข้อมูลถาวรใดๆ เลย และการลองใหม่ (retry) จะเล่นซ้ำเอกสารเดิมแทนที่จะโพสต์ซ้ำซ้อน (double-posting)
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","actorBkg":"#2d2d30","actorBorder":"#569cd6","actorTextColor":"#d4d4d4","signalColor":"#9cdcfe","signalTextColor":"#d4d4d4","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa","labelBoxBkgColor":"#3a3d41","labelBoxBorderColor":"#dcdcaa"},"sequence":{"useMaxWidth":false}}}%%
sequenceDiagram
participant C as Terminal
participant P as Pages Fn (submitOrder)
participant D as D1 (pos tables)
participant K as Kernel (GL + Stock)
C->>P: POST submit_order (idempotency_key, session_id, lines, payments)
P->>D: replay? order WHERE idempotency_key AND state=posted
alt already posted
D-->>P: row
P-->>C: ok replay=true
else new
P->>D: load session (state=open) + profile
P->>D: resolveLineItem + tax_code; unit_cost = projectedAvcoCost (sale) / originalReturnCost (return)
Note over P: AVCO issue THROWS if location on-hand qty <= 0
Note over P: compute discounts, netTotal, vatTotal, cogsTotal
P->>P: normalizePayments (cash vs settlement); legs must sum to gross
P->>K: createDocument doctype=pos-sale (stockLines qty_delta=-qty, journal)
P->>K: submitDocument (post stock movement + GL journal atomically)
K-->>P: doc_no, kernel_document_id
P->>D: DB.batch { order(posted) + lines + payments + settlement_expectation }
alt UNIQUE(idempotency_key) race
D-->>P: constraint abort
P-->>C: ok replay=true (idempotent)
else committed
D-->>P: order row
P-->>C: ok order + submitted
end
end
หมายเหตุ
- วัตถุประสงค์: รองรับการขายฝั่งเคาน์เตอร์ การคืนสินค้าและการแลกเปลี่ยน รวมถึงการควบคุมลิ้นชักเงินสด (เปิด/ปิดกะ, pay-in/pay-out, การนับเงินสด) สำหรับ ERP ค้าปลีกแบบ multi-tenant การขายแต่ละครั้งจะโพสต์รายได้ (revenue), ภาษีขาย (VAT output), ต้นทุนขาย (COGS) และสินค้าคงคลัง (inventory) รวมอยู่ใน kernel journal ชุดเดียวที่ดุลกัน (balanced)
- Idempotency เป็นข้อบังคับ:
submit_orderต้องมีidempotency_key(offline_id) และตัวป้องกันการเล่นซ้ำ (replay guard) จะจับคู่เฉพาะstate = 'posted'เท่านั้น ในโค้ดปัจจุบันแถวคำสั่งซื้อ (order row) จะถูก insert ในสถานะpostedภายใน batch แบบ atomic ชุดเดียวเท่านั้น ดังนั้นความพยายามที่ล้มเหลวจะไม่ทิ้งแถว order ไว้เลย เงื่อนไขstate='posted'จึงทำหน้าที่ทั้งรองรับการเล่นซ้ำที่แท้จริง และปฏิเสธไม่ให้ถือว่าเศษข้อมูลที่ยังไม่เป็น posted เป็นความสำเร็จ (จึงไม่มีการขายผี/การขายที่หายไปเมื่อ retry แบบออฟไลน์) - ลำดับที่รักษาความปลอดภัยของเงิน (Money-safety ordering): การโพสต์ที่ kernel (สต็อก + GL) เกิดขึ้น ก่อน แถว POS ใดๆ จากนั้น order + lines + payments + settlement expectation จึงถูกทำเป็นทรานแซกชัน
env.DB.batchชุดเดียว โดยerp_pos_paymentเป็นแบบ append-only เท่านั้น (คำสั่ง DELETE ชดเชยจะถูกยกเลิกโดย triggererp_pos_payment_no_deleteยืนยันได้ใน migration0107_erp_pos.sql) จึงไม่มีการ rollback ด้วยมือ — การ retry จะเล่นซ้ำเอกสาร kernel ฉบับเดิม - การแยกการชำระเงิน (Payment split): การชำระด้วยเงินสดจะโพสต์ด้วย
transaction_kind = 'cash'ส่วนการชำระที่ไม่ใช่เงินสดจะ resolve ค่าsettlement_posting_group_codeของวิธีชำระนั้น และเมื่อsettlement_expected = 1จะตั้งค้างรับerp_pos_settlement_expectation(สถานะopen/matched/disputed, แยกต่อกะต่อวิธีชำระ, upsert เมื่อเกิด conflict) เพื่อให้ลูกหนี้จากบัตร/PromptPay ถูกกระทบยอด (reconcile) ในภายหลัง - การขายเกินสต็อกมีขอบเขต ไม่ใช่แบบไร้เงื่อนไข (Oversell is bounded): การขายผ่าน POS จะ ไม่ ถูกบล็อกเพียงเพราะยอดคงเหลือ (on-hand) น้อยกว่าจำนวนที่ขาย — kernel จะโพสต์การเคลื่อนไหวสต็อกและปล่อยให้ on-hand ติดลบได้ เพื่อแก้ไขด้วยการนับรอบ (cycle count) ภายหลัง อย่าเพิ่ม gate แบบตายตัว "ปริมาณที่ขาย > on-hand" แต่ บรรทัดการขายจะยังล้มเหลวด้วย
insufficient stock for AVCO issueเมื่อยอด on-hand ของ location นั้นเป็น<= 0เพราะprojectedAvcoCostไม่สามารถหาต้นทุนต่อหน่วยแบบ AVCO จากยอดคงเหลือที่ไม่เป็นบวกได้ ดังนั้นการขายเกินสต็อกจะทำได้ก็ต่อเมื่อเริ่มจากยอด on-hand ที่เป็นบวกเท่านั้น (ส่วนบรรทัดการคืนสินค้าจะคิดต้นทุนผ่านoriginalReturnCostเทียบกับบรรทัดการขายเดิมที่โพสต์ไปแล้ว) - ปิดกะ = คณิตศาสตร์ของ Z-report:
expected_cash = opening_float + cash sales + pay_in − pay_out − cash refunds(ยอดขายเงินสด/การคืนเงินสดรวมมาจากerp_pos_paymentที่tender_kind = 'cash'บนคำสั่งซื้อสถานะpostedของกะนั้น) โดยvariance = counted − expectedจะถูกเก็บพร้อมกับcash_countตอนปิด และ snapshotz_report_jsonบนกะนั้น อัตราภาษี/posting group จะถูก resolve จากerp_tax_code(ค่าเริ่มต้นVAT7) ส่วนส่วนลดจะถูกจำกัดเพดานตามprofile.policy_json(discount_cap_percent)