Skip to content

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.”

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:

  1. Lists and exports do not show or filter by project.
  2. The project must be picked line by line.
  3. CRM opportunities carry no project (PU7 deferred this).
  4. Petty cash spends and advances carry no project.

No project table and no project_id column on invoices or trade lines (PU1).

  • 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 return project {id, code, name} or multiple: true. Web lists get a Project column and filter. Export kinds for these lists register a project column (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 (CheckChargeable stays the authority server-side). No schema change.
  • PL4 — CRM opportunity → project (piece 3). Migration 01192: nullable crm_opportunity.project_account_id with 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 the project axis (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_id on 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_to income, 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. Migration 01193 adds a nullable project_account_id (composite FK to analytic_account) to petty_cash_spend; 01194 adds the same to advance_acquittal. Each is one expense line, so one account is enough. Saved through the same checks as an expense claim line (analytic.Plan against the expense account’s type, CheckChargeable with new flow names petty_cash_spend and advance_acquittal, added to chargeable.go with the existing drift guard). The posting rule passes a 100% share on that account as the expense line’s Distribution (as expenses/posting.go does with l.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.

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_test still finds every analytic.Plan( site with a gate.
  1. api/openapi.yaml first for each piece; regenerate Go and TS.
  2. ms/zh strings into docs/review/*-review.csv; native review pending as usual.
  3. Tick docs/ROADMAP.md per piece.

Whether “Multiple” should also list the projects on hover/expand. Default: no, the document detail already lists them per line.