Purchasing — Process flows
Architecture & lifecycle auto-generated from code (dispatch, CHECK status enums, SET status writes). Sequences & notes are authored.
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 document lifecycle (erp_document.docstatus)
Every money/stock-moving document in this module posts through the shared kernel, walking a fixed 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
States: none (initial) · partial · full
purchasing_purchase_order.billing_status
States: none (initial) · partial · full
Key flow: Receive and bill against a PO (three-way match)
This is the core money path: a Goods Receipt accrues stock into a GR/NI clearing account, then a Vendor Bill runs the 3-way match and, only if it passes, relieves the clearing and books 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
Notes
- Purpose: procure-to-pay for a multi-tenant Thai ERP — RFQ/quotation → award → PO → goods receipt → vendor bill, plus blanket orders, subcontract component issues, and landed-cost vouchers. Every request is company-scoped and requires capability
erpormanage(read surface also acceptsfinance/accounting). - Ledger is kernel-owned, not module-owned: the module never writes GL/stock rows itself. It composes a balanced
journalintent (e.g. GRN:DR stock / CR grni_clearing; bill:DR grni_clearing, DR/CR purchase_price_variance, DR vat_input / CR accounts_payable, CR wht_payable) and hands it tokernel.submitDocument. The GR/NI clearing account is what links receipt-time accrual to bill-time settlement so GL always ties back to the subledger. - Idempotency / money-safety: every posting carries an
idempotency_key/operation_groupof the formpurchasing:<doctype>:<id>, so a retried submit cannot double-post. Cancel useskernel.cancelDocumentIdempotentplus adocstatus 1→2compare-and-set — only the CAS winner compensatesreceived_qty/billed_qty(clamped withMAX(0, …)), so a crashed retry reconciles exactly once. - Three-way match is a real gate, not advisory:
submitVendorBillcompares billed qty againstmatch_policybasis (ordered vs received) withqty_tolerance_pct, and price against the PO price withprice_tolerance_pct/bill_amount_abs_tolerance. Any breach writes anerp_purchasing_match_exceptionrow, setsmatch_state = 'exception_pending', and returns without any GL posting. GR/NI is always cleared at the accrued PO-price basis; the price delta capitalises to Purchase Price Variance (symmetric both directions). - Landed cost = value-only stock revaluation:
submitLCVresolves allocations to real GRN lines, then revalues each(item, location)pool once via a net-zero qty pair (draw down at avg cost, re-receive same qty at higher cost) so GL equals subledger. A pool already sold out (on_hand qty ≤ 0) is expensed to COGS instead of stranding thelanded_cost_clearingbalance. - Gotchas seen in code: PO gets a provisional
doc_noon create and a final one on submit (allocateDocNo(..., final)); supplierstandingofblocked_po/blocked_rfqgates submission; RFQ award auto-groups winning quote lines by supplier into one PO each and flips quotationaward_statetoawarded_full/awarded_partial/lost;refreshPOStatusesderivesreceipt_status/billing_statuspurely from line-level qty aggregates, so those facets are always recomputed rather than incremented.