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.
1. What it is
Section titled “1. What it is”报销类型 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.
2. Decisions
Section titled “2. Decisions”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 onexpense_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: akindflag on the claim. -
D2 — It lives in the
expensesmodule:platform/expenses,modules/expenses, routes under/v1/expenses/purchase-requests. It reuses the fourexpenses.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 withpurchasereq.*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 tocommitted, in the same way accounting listens toaccrual. Rejected: reusingacknowledged. 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 toSignoffKinds. A definition of one of those kinds must have exactly onecommittedstate and noaccrualorpayoutstate. Every other kind must have nocommittedstate. 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 vacuousaccrual_firstposting order: with no posting states, that order constrains nothing. Rejected: a newnoneposting 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
committedstate 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 tocommitted. 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)andSetShares(shares)are called exactly as a claim line calls them, andpurchase_requestjoinsSignoffKinds, 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└─→ withdrawnState Semantic Terminal draftdraftno pending_reviewpendingno reviewedpendingno approvedcommittedno rejectedrejectedno closedcancelledyes withdrawncancelledyes Grants copy the claim graph:
reviewedgoes to the superior,approvedto the unit head.approvedis not terminal, because validator ruleterminal_exit_not_disputewould 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
committedstate, 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 exposespurchase_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_amountis 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_requestis 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 thisspend_requestand leaving the name to Phase 4.
3. Fields — purchase_request
Section titled “3. Fields — purchase_request”| 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.
4. Record language, audit, retention
Section titled “4. Record language, audit, retention”- Record language:
title,purposeandsupplier_namego throughexpenses.CheckRecordTextand named CHECKs (purchase_request_text_language, the same CJK regex asexpense_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.zhstays a UI locale only. - Audit: every create, edit, attach, detach and link is written with
audit.Writein the change’s own transaction. Decisions go to the immutableworkflow_decisionlog.Transitionruns beforeaudit.Write, as the Plan 3 caller constraint requires. - Retention:
REVOKE DELETEon both tables. Both go in the statutory list ofkernel/db/retention_test.go, next toexpense_claim. A draft guard trigger (theexpense_claim_is_draftpattern) 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.
5. Screens
Section titled “5. Screens”- 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_idset. 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
00250thread belongs to claims. Add it if users ask. - Status colour:
committedgets the same tone as an approved state insemanticTone.ts.
6. API (additive), migrations, tests
Section titled “6. API (additive), migrations, tests”- OpenAPI (
/v1/expenses/purchase-requests):GET(scopemine|to_act|all,state,open_commitment, cursor) andPOSTGETandPATCH /{id}(draft only,revisionfor optimistic concurrency, as on claims)POST /{id}/transitions(Idempotency-Key, as on claims)POST /{id}/documentsandDELETE /{id}/documents/{documentId}- on claims, additively:
purchase_request_idon create and patch, and a read-onlypurchase_request {id, reference, currency, estimated_amount, base_amount, over_amount}onExpenseClaim - every route goes through
httpx.Protectedand is pinned inroutes_test.go
- Migrations: the Figma batch 2 range is already registered.
00265:committedadded to theworkflow_state.semanticCHECK. The CHECK is recreated as the namedworkflow_state_semantic_known.00266:purchase_requestandpurchase_request_document, with RLS,org_isolation, named constraints, draft guards andREVOKE DELETE.00267:expense_claim.purchase_request_id, its FK and its uniqueness.00268–00269stay reserved.- Provisioning seeds the new kind through
RegisterSeededGraph. Existing orgs get it by re-runningPOST /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:
committedis accepted onpurchase_requestand refused onexpense_claim. An accrual is refused onpurchase_request.RequireSeededGraphValid. - No posting: a request goes draft → approved → closed and
journal_entrygains 0 rows. Presence first: an expense claim taken tocategory_verifiedunder the same count query gains more than 0 rows. - Schema: the record-language CHECK passes
enand refuses azhtitle. The draft guard lets an edit through in draft and refuses one after submission. RLS, retention andorg_iddrift 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
overin 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
reviewedfor 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”).
- Validator:
7. Tasks
Section titled “7. Tasks”| # | 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.
8. Not done (v1)
Section titled “8. Not done (v1)”- 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.