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การละเมิดใด ๆ จะเขียน rowerp_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;refreshPOStatusesderivereceipt_status/billing_statusจากผลรวม qty ระดับ line ล้วน ๆ ดังนั้น facet เหล่านั้นจึงคำนวณใหม่ทุกครั้งแทนที่จะบวกเพิ่มสะสม