Project link on sales, purchase, quotes, CRM, petty cash and advances — design
Status: draft 2026-10-10, approved in chat (scope: all four pieces; quote link: prefill only).
Builds on 2026-10-06-project-usage-design.md (PU1–PU9)
and, for piece 1’s export column, on 2026-10-09-table-export-design.md.
Owner need: “for sales, purchase or anything related to CRM, be able to link to a project so I know which project it is for.”
Finding
Section titled “Finding”A project is an analytic_account on the project axis (PU1). Sales invoices, purchase invoices,
trade documents (quotes and orders), assets and expense claim lines already carry a project as a
line-level shares entry, validated by analytic.Plan and gated by CheckChargeable. So the
link exists but is invisible at document level. Genuinely missing:
- Lists and exports do not show or filter by project.
- The project must be picked line by line.
- CRM opportunities carry no project (PU7 deferred this).
- Petty cash spends and advances carry no project.
No project table and no project_id column on invoices or trade lines (PU1).
Decisions
Section titled “Decisions”- PL1 — A document’s project is derived from its lines. One distinct account across all
lines’
project-axis shares → that project; several → “Multiple”; none → empty. Computed in the list query, never stored on the header, so it cannot drift from the lines. - PL2 — Lists and filters (piece 1). Receivables documents, payables invoices and trade
documents list endpoints gain
project(account id) filter and returnproject{id, code, name}ormultiple: true. Web lists get a Project column and filter. Export kinds for these lists register aprojectcolumn (default off). Additive API change; no schema change. - PL3 — One project per document (piece 2). The invoice/document dialogs get a header
“Project” picker. Choosing one rewrites every line’s
project-axis share to 100% on that account in the client; the server contract is unchanged. Editing a single line afterwards makes the picker read “Multiple”. The picker offers only accounts chargeable by that flow (CheckChargeablestays the authority server-side). No schema change. - PL4 — CRM opportunity → project (piece 3). Migration
01192: nullablecrm_opportunity.project_account_idwith composite FK(org_id, project_account_id)→analytic_account (org_id, id). Set/cleared through the opportunity create/patch, shown on the card and list. Must be an account on theprojectaxis (checked in Go via platform/analytic; CRM does not import another module). Retiring the project does not touch the opportunity. - PL5 — Quotes: prefill only. “Create quote” from an opportunity opens a new quote with the
project already picked (via PL3). No stored opportunity↔quote link. A retired or
non-chargeable project is dropped from the prefill; the quote’s normal checks stay in force.
Rejected:
opportunity_idon trade documents (cross-module schema for no current reader). - PL6 — Petty cash spends and advance acquittals (piece 4). Amended 2026-10-10 after
reading the posting rules. An advance itself posts Dr Employee Advances control / Cr cash:
balance-sheet accounts, which the project axis (
applies_toincome, expense) refuses. The expense is booked at acquittal (AdvanceAccrualRule: Dr the acquittal’s expense account). So the project belongs on the acquittal, not the advance. Migration01193adds a nullableproject_account_id(composite FK toanalytic_account) topetty_cash_spend;01194adds the same toadvance_acquittal. Each is one expense line, so one account is enough. Saved through the same checks as an expense claim line (analytic.Planagainst the expense account’s type,CheckChargeablewith new flow namespetty_cash_spendandadvance_acquittal, added tochargeable.gowith the existing drift guard). The posting rule passes a 100% share on that account as the expense line’sDistribution(asexpenses/posting.godoes withl.Shares); the cash and control legs carry none. Rows with no project post exactly as before. Needs its own plan and the heaviest tests because it changes posted lines. Rejected: a project on the advance (cannot post). - PL7 — Order. Piece 1 → 2 → 3 → 4; each ships on its own plan, the roadmap ticked per piece.
Not in scope
Section titled “Not in scope”project_profile and customer link, billable re-invoicing (PU8), project budgets, an
opportunity↔quote link, a project on payroll.
- Derivation (PL1): one project, several, none; the list returns each; filter by project returns exactly the documents with a line on it. Presence asserted before absence (fixture has a document on another project that the filter must exclude).
- PL3: web test that choosing the header project writes 100% shares on every line; editing one line flips to “Multiple”.
- PL4: FK rejects another org’s account; Go rejects a non-
project-axis account; patch clears it. - PL5: web test that “Create quote” prefills; a retired project is not prefilled.
- PL6: a spend with a project posts a line whose distribution is 100% to it; one without posts
byte-identical to before; a non-chargeable project is refused (watch the gate go red when
removed). Migration tests for ranges (extend
{1190,1199}use) and tenant isolation unchanged (no new tables). - Drift:
chargeable_guard_teststill finds everyanalytic.Plan(site with a gate.
Rollout
Section titled “Rollout”api/openapi.yamlfirst for each piece; regenerate Go and TS.- ms/zh strings into
docs/review/*-review.csv; native review pending as usual. - Tick
docs/ROADMAP.mdper piece.
Open question (default stated)
Section titled “Open question (default stated)”Whether “Multiple” should also list the projects on hover/expand. Default: no, the document detail already lists them per line.