Skip to content

Purchase requests (采购申请) — design

Status: design only (Figma batch 2, project I). Audit row 7.6 (2026-09-25-figma-alignment-audit.md), Figma xjx0pz8JSVeCqP1kYg3elT node 157:10616 (the 报销类型 dropdown). The In Progress page (83:5479) has no purchase-request screens: the only “采购” there is a petty-cash row description. Everything below the dropdown is designed here, reusing the claim wizard, list and drawer. Extends: architecture spec §5 (workflow engine), §5.5 (semantics), §6 (claims), §11A.3–4; project approval design P1–P16. Roadmap: not on it yet. The plan adds a Phase 2 line when it lands.

报销类型 offers two choices: 费用报销, “I already paid” (today’s expense claim), and 采购申请, “I need approval before buying”. A purchase request asks for approval before any money is spent. When the purchase is made, the requester turns the approved request into an ordinary expense claim, linked to the request. A request never moves money and never posts.

Each decision gives the choice made, and the alternative, when another option was also reasonable.

  • D1 — A new workflow kind, purchase_request, with its own seeded definition and its own table. It is not a flag on expense_claim. Why: spec §5.2 property 3 says each record type gets its own graph. A flagged claim would carry accrual and payout states it must never enter, and it would need guards like the ones korre needed. Rejected: a kind flag on the claim.

  • D2 — It lives in the expenses module: platform/expenses, modules/expenses, routes under /v1/expenses/purchase-requests. It reuses the four expenses.claim.* permissions. Why: it is filed by the same people and approved by the same people. It shares categories, analytic shares, the record-text check, visibility and idempotency. A new module would copy all of that. Rejected: its own module with purchasereq.* permissions (as advances did), which would need a role-grant migration and duplicated plumbing.

  • D3 — A new semantic, committed: “approved to spend; nothing posts”. Why: the module has to know that a request is approved without reading a state name, because admins can rename and insert steps (§5.6). A semantic tag is how this codebase says what a state means. It also lets a later budget or encumbrance feature listen to committed, in the same way accounting listens to accrual. Rejected: reusing acknowledged. Its meaning is “the claimant confirmed receipt”, its colour would be wrong, and code testing for it would be misleading.

  • D4 — The validator enforces “commitment, not expense”. CommitmentKinds = ["purchase_request"], next to SignoffKinds. A definition of one of those kinds must have exactly one committed state and no accrual or payout state. Every other kind must have no committed state. Why: spec §5.6 tier 3 lets support move posting states around. This rule stops a request definition from ever reaching the Poster. It uses the vacuous accrual_first posting order: with no posting states, that order constrains nothing. Rejected: a new none posting order. It would change a DB CHECK, the validator and the engine, and add nothing that D4 does not already cover.

  • D5 — Nothing posts, and there is no commitment register table. A purchase request has no ledger entry and no encumbrance rows. Open commitments are derived: requests at the committed state with no submitted linked claim. This is one query, exposed as a list filter. Why: the ledger is append-only and holds accounting facts. A commitment is not one yet. A stored register would need reconciliation for no current reader. Phase 2 budgets can add one that reacts to committed. Rejected: memo or off-balance journal entries, which would put reversals in the ledger for every request that was never used.

  • D6 — Approvals reuse the engine unchanged, with bands, delegation, escalation, dispute and the decision log. SetAmount(base_amount) and SetShares(shares) are called exactly as a claim line calls them, and purchase_request joins SignoffKinds, so Plan 11’s project approvers and their bands (P16) work on its pending steps. Why: this is what the engine is for. The only change is the P12 allowlist, which P12 itself anticipated.

  • D7 — The seeded graph:

    draft → pending_review → reviewed → approved ─→ closed (reporter, "Close unused", reason)
    │ └────────────┴──→ rejected → draft
    └─→ withdrawn
    State Semantic Terminal
    draft draft no
    pending_review pending no
    reviewed pending no
    approved committed no
    rejected rejected no
    closed cancelled yes
    withdrawn cancelled yes

    Grants copy the claim graph: reviewed goes to the superior, approved to the unit head. approved is not terminal, because validator rule terminal_exit_not_dispute would forbid its “close” exit. Why the close edge: an approved request that is never used would otherwise stay an open commitment forever. Rejected: auto-expiry on the needed-by date. It needs a job and a policy, and nobody has asked for it.

  • D8 — One request becomes at most one claim. The link is expense_claim.purchase_request_id (nullable, composite FK, UNIQUE (org_id, purchase_request_id)). It is set on a draft claim and frozen by the existing claim draft guard. Why: this is the simplest link that can be audited, and most requests are one purchase. Rejected: several claims per request with a remaining balance. That needs partial-fulfilment arithmetic, and Phase 4’s document chain will generalise it.

  • D9 — Rules for linking. The claim’s claimant must be the request’s requester. The request must be at its committed state, row-locked (FOR UPDATE) when the claim is submitted, not only when it is linked. While the claim is a draft, the link can be changed or cleared. Once a linked claim has been submitted, the request’s close edge is refused (409, purchase_request_claimed). Why: checking at submission closes the race with a close that happens between linking and submitting.

  • D10 — A claim above the approved amount is flagged, never blocked. At submission, when the claim’s base total is greater than the request’s base_amount, the submit transition’s audit row records {approved, claimed, over}. The claim read exposes purchase_request.over_amount, and approvers see an amber note in the drawer. There is no tolerance setting. Why: the claim still goes through its own full approval, so a second human decides. This follows the possible-duplicate precedent (Plan 5, 00076). A tolerance percentage would be a configuration nobody has specified. Rejected: a hard block, or an org tolerance percentage. Either could be added later without a schema change to the link.

  • D11 — Comparisons use base amounts. A request’s base_amount is computed at each draft save, using the reference rate for the org’s date, and frozen at submission (§6.5, “computed once at a defined event”). The claim can be in another currency. FX drift can show up as an overrun, which is flagged (D10) and is not an error.

  • D12 — A request has one amount, not lines. A quotation that lists several items is attached as a document. Why: the approver approves a spend, not a basket. The claim that follows keeps its own lines. Rejected: request lines that mirror claim lines, which would double the form for no approval benefit.

  • D13 — The kind name purchase_request is claimed here. Phase 4’s PR → PO chain starts from this record by copying it forward, instead of creating a second PR type. Why: two things called purchase requests would confuse users and the vocabulary. Rejected: naming this spend_request and leaving the name to Phase 4.

Column Rule
id, org_id, reference PR-YYYY-NNNN from platform/numbering (claims use EC)
requester_member_id, instance_id composite FKs, as on expense_claim
title required, ≤ 200 characters, en/ms
purpose required, ≤ 1000 characters, en/ms. This is the justification the approver reads
category_id required. Pre-fills the claim, and budgets will need it later
supplier_name optional, ≤ 200 characters, en/ms
needed_by optional DATE. Informational only (D7)
currency, estimated_amount ^[A-Z]{3}$, NUMERIC(19,4) > 0, a string on the wire
fx_rate, fx_rate_source, base_amount as on claim lines. Frozen at submission (D11)
shares JSONB analytic.Share[], the same shape as lines. This is what 项目 / ChargeTo writes
revision, created_at, updated_at as on expense_claim

purchase_request_document (id, org_id, request_id, document_id, attached_by, attached_at, detached_by, detached_at) holds the quotations, shaped like expense_claim_line_document. A detach is recorded, never erased. Expenses registers a second documents.Binder, of kind purchase_request, and a quotation obeys the one-record rule. Why: a quote is not a receipt, so the claim must carry its own receipt.

  • Record language: title, purpose and supplier_name go through expenses.CheckRecordText and named CHECKs (purchase_request_text_language, the same CJK regex as expense_claim_title_language). Why: once a request is linked, it is the approval evidence behind a posted figure (§11A.4), so it is a record, not UI text. zh stays a UI locale only.
  • Audit: every create, edit, attach, detach and link is written with audit.Write in the change’s own transaction. Decisions go to the immutable workflow_decision log. Transition runs before audit.Write, as the Plan 3 caller constraint requires.
  • Retention: REVOKE DELETE on both tables. Both go in the statutory list of kernel/db/retention_test.go, next to expense_claim. A draft guard trigger (the expense_claim_is_draft pattern) freezes every column outside draft.
  • Visibility: the claim’s four cases (requester, read_all, can act now, is in the decision log), plus anyone who may read the linked claim. Why: the claim approver needs the quotation and the approved amount. Anyone else gets a 404, following R21.
  • Wizard, step 1: 报销类型 is a Select with a description on each option (费用报销 / 采购申请). The value is fixed once the draft is first saved, because the two choices create different records. It replaces today’s inert “receipt” select. Line type is not a user choice in this wizard. Request mode reuses 项目 / ChargeTo, 费用类别, title and purpose.
  • Wizard, step 2 in request mode (金额与报价): estimated amount with a currency select (7.10), supplier, needed-by date, and quotation upload through DocumentUploader. There is no payout step: request mode has three steps, info → amount and quotations → confirm.
  • Converting: an approved request’s drawer shows 转为报销 / Claim this purchase to the requester, while no claim is linked. It opens the wizard in claim mode with purchase_request_id set. Title, category, shares, supplier and currency are pre-filled on the client from the request; the server only validates the link (D9). Claim mode also has an optional 关联采购申请 select, listing the requester’s approved requests that are not yet claimed. Rejected: a server-side “convert” endpoint that copies fields. The client already has the values, and the claim draft endpoints already validate them.
  • List: the claims page gets a segmented 费用报销 | 采购申请 control, stored in the URL as ?type=purchase_request. The tabs stay the same (mine / to approve / all), and each type has its own table and its own API. The toggle shows a count badge for requests waiting on the caller, so a request to approve is not missed. Rejected: one mixed table, which would merge cursor pagination across two resources and mix amount semantics (estimated vs claimed). Also rejected: a separate sidebar item. Figma’s 申请 group has none, and the entry point is the same wizard.
  • Request drawer: it reuses the claim drawer’s shell. The banner reads “预计 RM x” (amber while pending, green once approved, red with the reason when rejected). It shows the facts, the quotations, the timeline, the TransitionBar with close / withdraw / resubmit, and, once linked, “已转为报销 EC-…” linking to the claim. The claim drawer gains a “采购申请 PR-…” fact and the D10 overrun note. Comments are not in v1: the 00250 thread belongs to claims. Add it if users ask.
  • Status colour: committed gets the same tone as an approved state in semanticTone.ts.
  • OpenAPI (/v1/expenses/purchase-requests):
    • GET (scope mine|to_act|all, state, open_commitment, cursor) and POST
    • GET and PATCH /{id} (draft only, revision for optimistic concurrency, as on claims)
    • POST /{id}/transitions (Idempotency-Key, as on claims)
    • POST /{id}/documents and DELETE /{id}/documents/{documentId}
    • on claims, additively: purchase_request_id on create and patch, and a read-only purchase_request {id, reference, currency, estimated_amount, base_amount, over_amount} on ExpenseClaim
    • every route goes through httpx.Protected and is pinned in routes_test.go
  • Migrations: the Figma batch 2 range is already registered.
    • 00265: committed added to the workflow_state.semantic CHECK. The CHECK is recreated as the named workflow_state_semantic_known.
    • 00266: purchase_request and purchase_request_document, with RLS, org_isolation, named constraints, draft guards and REVOKE DELETE.
    • 00267: expense_claim.purchase_request_id, its FK and its uniqueness.
    • 00268–00269 stay reserved.
    • Provisioning seeds the new kind through RegisterSeededGraph. Existing orgs get it by re-running POST /v1/org/provision (RUNBOOK §6.2), which goes in DEPLOY_NOTES.
  • Tests. Each absence below has its presence first, and each new guard is watched failing once.
    • Validator: committed is accepted on purchase_request and refused on expense_claim. An accrual is refused on purchase_request. RequireSeededGraphValid.
    • No posting: a request goes draft → approved → closed and journal_entry gains 0 rows. Presence first: an expense claim taken to category_verified under the same count query gains more than 0 rows.
    • Schema: the record-language CHECK passes en and refuses a zh title. The draft guard lets an edit through in draft and refuses one after submission. RLS, retention and org_id drift are picked up by the existing guards once the tables exist (each is listed).
    • Link: accepted for the requester’s own approved request. Refused for another member’s request, for a pending one, for one already linked, and for one closed between linking and submitting. Close is allowed with no link and refused (purchase_request_claimed) after a linked claim is submitted.
    • Overrun: a claim above the approved amount records over in the audit row. A claim equal to it does not, and that check runs after the above-amount case has proven the field is written.
    • Project approval: a request split across two projects waits at reviewed for each project’s approver, reusing the Plan 11 harness.
    • Visibility: the requester gets 200 and a stranger 404. The linked claim’s approver gets 200.
    • Web: switching 报销类型 changes the steps. Request mode has no payout step. Converting pre-fills the claim. The list toggle works. The drawer shows its banners. i18n keys exist in en, ms and zh (ms is genuine Malay: “Permohonan pembelian”, “Saya sudah membayar”, “Saya perlu kelulusan sebelum membeli”).
# Task Depends on
T1 Engine: posting.Committed, the vocabulary codegen, the OpenAPI WorkflowSemantic and Semantic enums, the workflow editor’s label (en, ms, zh), 00265, validator D4, purchase_request added to SignoffKinds —
T2 Domain: 00266, the seeded graph (D7), create, edit and read with the draft guard, FX and base amount, SetAmount and SetShares, the binder, visibility, record text T1
T3 HTTP: OpenAPI paths, handlers, transitions with the close guard, pinned routes T2
T4 Claim link: 00267, link rules D9, overrun D10, the additive claim read and write fields T2
T5 Web wizard: the 报销类型 select, request mode steps and quotations, converting with pre-fill, the 关联采购申请 select T3, T4 (OpenAPI)
T6 Web list and drawer: type toggle and badge, request table, request drawer, the claim drawer’s link fact and overrun note, committed tone T3, T4 (OpenAPI)
T7 End to end (request → approve → convert → claim shows the link and the overrun), roadmap line, DEPLOY_NOTES (re-provision) T5, T6

Parallel groups: [T1 → T2], then [T3 ∥ T4], then [T5 ∥ T6], then [T7]. T3 and T4 each commit their OpenAPI slice first, so the web tasks can start from the generated client while the handlers are still being built.

  • Comments on requests.
  • Auto-expiry.
  • Several claims per request.
  • A tolerance setting.
  • A stored commitment register.
  • Categories that require a request before a claim.

Each one is additive to this model.