Skip to content

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 erp or manage (read surface also accepts finance/accounting).
  • Ledger is kernel-owned, not module-owned: the module never writes GL/stock rows itself. It composes a balanced journal intent (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 to kernel.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_group of the form purchasing:<doctype>:<id>, so a retried submit cannot double-post. Cancel uses kernel.cancelDocumentIdempotent plus a docstatus 1→2 compare-and-set — only the CAS winner compensates received_qty/billed_qty (clamped with MAX(0, …)), so a crashed retry reconciles exactly once.
  • Three-way match is a real gate, not advisory: submitVendorBill compares billed qty against match_policy basis (ordered vs received) with qty_tolerance_pct, and price against the PO price with price_tolerance_pct / bill_amount_abs_tolerance. Any breach writes an erp_purchasing_match_exception row, sets match_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: submitLCV resolves 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 the landed_cost_clearing balance.
  • Gotchas seen in code: PO gets a provisional doc_no on create and a final one on submit (allocateDocNo(..., final)); supplier standing of blocked_po / blocked_rfq gates submission; RFQ award auto-groups winning quote lines by supplier into one PO each and flips quotation award_state to awarded_full/awarded_partial/lost; refreshPOStatuses derives receipt_status/billing_status purely from line-level qty aggregates, so those facets are always recomputed rather than incremented.