ข้ามไปที่เนื้อหา

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 ชดเชยจะถูกยกเลิกโดย trigger erp_pos_payment_no_delete ยืนยันได้ใน migration 0107_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 ตอนปิด และ snapshot z_report_json บนกะนั้น อัตราภาษี/posting group จะถูก resolve จาก erp_tax_code (ค่าเริ่มต้น VAT7) ส่วนส่วนลดจะถูกจำกัดเพดานตาม profile.policy_json (discount_cap_percent)