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

Purchasing — ผังกระบวนการ (Process flows)

สถาปัตยกรรม & วงจรชีวิต ถูกสร้างอัตโนมัติจากโค้ด (dispatch, CHECK status enums, SET status writes) ส่วน sequence & หมายเหตุ เขียนกำกับเอง

สถาปัตยกรรม (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 purchasing<br/>bolt_session auth · company_id scope"]
  A --> D["dispatch<br/>create · submit · award · release · resolve · cancel · po · rfq"]
  D --> K["Kernel<br/>erp_document + journal<br/>atomic GL / stock"]
  K --> DB[("D1 tables")]
  D --> DB
  DB -.-> T["purchasing_purchase_order<br/>purchasing_po_line<br/>purchasing_rfq<br/>purchasing_rfq_supplier<br/>purchasing_rfq_line<br/>purchasing_supplier_quotation<br/>purchasing_supplier_quotation_line<br/>purchasing_rfq_award"]

วงจรชีวิต (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 --> [*]

purchasing_purchase_order.receipt_status

สถานะ: none (เริ่มต้น) · partial · full

purchasing_purchase_order.billing_status

สถานะ: none (เริ่มต้น) · partial · full

Flow สำคัญ: รับของและวางบิลอ้างอิง PO (three-way match)

นี่คือเส้นทางการเงินหลัก: การรับสินค้า (Goods Receipt) จะตั้งพักมูลค่าสต็อกเข้าบัญชีพักปรับปรุง GR/NI จากนั้น Vendor Bill จะทำการจับคู่แบบ 3 ทาง (3-way match) และจะปลดบัญชีพักพร้อมบันทึก AP / VAT / WHT ก็ต่อเมื่อผ่านการจับคู่เท่านั้น

%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","secondaryTextColor":"#d4d4d4","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","actorBkg":"#2d2d30","actorBorder":"#569cd6","actorTextColor":"#d4d4d4","signalColor":"#9cdcfe","signalTextColor":"#d4d4d4","noteBkgColor":"#3a3d41","labelBoxBkgColor":"#3a3d41","labelBoxBorderColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"sequence":{"useMaxWidth":false},"state":{"useMaxWidth":false},"er":{"useMaxWidth":false}}}%%
sequenceDiagram
  participant C as Client
  participant F as Purchasing Fn
  participant DB as D1 tables
  participant K as Kernel GL
  participant S as Stock subledger

  C->>F: POST grn/:id/submit
  F->>DB: grnRows (join po_line, po)
  F->>F: check over_receipt tolerance
  F->>K: createDocument + submitDocument (idem purchasing:grn:id)
  K->>S: stock +qty at unit_price
  K->>K: DR stock  CR grni_clearing
  F->>DB: po_line.received_qty += base_qty
  F->>DB: refreshPOStatuses -> receipt_status
  F-->>C: ok, kernel_document_id

  C->>F: POST vendor-bill/:id/submit
  F->>DB: billRows (join po_line for prices)
  F->>F: 3-way match: qty vs received, price vs PO
  alt tolerance breached
    F->>DB: insert match_exception
    F->>DB: match_state = exception_pending
    F-->>C: ok false, exceptions (no GL)
  else matched
    F->>K: submitDocument (idem purchasing:vendor-bill:id)
    K->>K: DR grni_clearing DR/CR PPV DR vat_input
    K->>K: CR accounts_payable CR wht_payable
    F->>DB: po_line.billed_qty += qty, match_state = matched
    F->>DB: refreshPOStatuses -> billing_status
    F-->>C: ok, kernel_document_id
  end

หมายเหตุ

  • วัตถุประสงค์: procure-to-pay สำหรับ ERP ไทยแบบ multi-tenant — RFQ/ใบเสนอราคา → award → PO → รับสินค้า → วางบิลผู้ขาย รวมถึง blanket order, การเบิกส่วนประกอบงาน subcontract และใบ landed-cost ทุก request จะถูก scope ตามบริษัท (company-scoped) และต้องมี capability erp หรือ manage (ฝั่งอ่านยังยอมรับ finance/accounting ด้วย)
  • บัญชีแยกประเภทเป็นของ kernel ไม่ใช่ของโมดูล: โมดูลไม่เคยเขียน row ของ GL/สต็อกเอง แต่จะประกอบ intent ของ journal ที่สมดุล (เช่น GRN: DR stock / CR grni_clearing; บิล: DR grni_clearing, DR/CR purchase_price_variance, DR vat_input / CR accounts_payable, CR wht_payable) แล้วส่งต่อให้ kernel.submitDocument บัญชีพักปรับปรุง GR/NI คือสิ่งที่เชื่อมการตั้งพักตอนรับของ (receipt-time accrual) เข้ากับการเคลียร์ตอนวางบิล (bill-time settlement) เพื่อให้ GL ผูกกลับไปตรงกับ subledger เสมอ
  • Idempotency / ความปลอดภัยเชิงการเงิน: ทุกการโพสต์จะพก idempotency_key / operation_group ในรูปแบบ purchasing:<doctype>:<id> ดังนั้น submit ที่ถูก retry จึงไม่สามารถโพสต์ซ้ำได้ ส่วน cancel ใช้ kernel.cancelDocumentIdempotent ร่วมกับ compare-and-set ของ docstatus 1→2 — มีเพียงผู้ชนะ CAS เท่านั้นที่จะชดเชย received_qty/billed_qty (clamp ด้วย MAX(0, …)) ดังนั้น retry ที่ crash จะกระทบยอดให้ถูกต้องเพียงครั้งเดียวเป๊ะ
  • Three-way match เป็นด่านจริง ไม่ใช่แค่คำแนะนำ: submitVendorBill เปรียบเทียบจำนวนที่วางบิลกับ basis ของ match_policy (ตามที่สั่งซื้อ vs ที่รับจริง) ด้วย qty_tolerance_pct และเปรียบเทียบราคากับราคา PO ด้วย price_tolerance_pct / bill_amount_abs_tolerance การละเมิดใด ๆ จะเขียน row erp_purchasing_match_exception ตั้งค่า match_state = 'exception_pending' และ return โดยไม่มีการโพสต์ GL ใด ๆ GR/NI จะถูกเคลียร์ที่ basis ราคา PO ที่ตั้งพักไว้เสมอ ส่วนผลต่างราคาจะถูก capitalise เข้า Purchase Price Variance (สมมาตรทั้งสองทิศทาง)
  • Landed cost = การตีมูลค่าสต็อกใหม่แบบมูลค่าล้วน: submitLCV แปลง allocation ไปยัง GRN line จริง จากนั้นตีมูลค่าใหม่ให้แต่ละ pool (item, location) หนึ่งครั้ง ผ่านคู่จำนวนที่สุทธิเป็นศูนย์ (net-zero qty pair) (เบิกออกที่ต้นทุนเฉลี่ย แล้วรับกลับจำนวนเท่าเดิมที่ต้นทุนสูงขึ้น) เพื่อให้ GL เท่ากับ subledger สำหรับ pool ที่ขายหมดแล้ว (on_hand qty ≤ 0) จะลงเป็นค่าใช้จ่ายเข้า COGS แทน แทนที่จะทิ้งยอดค้างใน landed_cost_clearing
  • Gotcha ที่พบในโค้ด: PO จะได้ doc_no ชั่วคราวตอน create และได้เลขสุดท้ายตอน submit (allocateDocNo(..., final)); standing ของ supplier เป็น blocked_po / blocked_rfq จะกั้นการ submit; การ award RFQ จะจัดกลุ่ม line ใบเสนอราคาที่ชนะโดยอัตโนมัติตาม supplier รวมเป็น PO ละหนึ่งใบ และพลิก award_state ของใบเสนอราคาเป็น awarded_full/awarded_partial/lost; refreshPOStatuses derive receipt_status/billing_status จากผลรวม qty ระดับ line ล้วน ๆ ดังนั้น facet เหล่านั้นจึงคำนวณใหม่ทุกครั้งแทนที่จะบวกเพิ่มสะสม