Project link — piece 1: project column and filter on document lists
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Sales invoice, purchase invoice and trade document (quote/order) lists show which project each document is for and can be filtered by project.
Architecture: A project is an analytic account on the project axis, carried as shares JSONB on each document line. One helper in platform/accounting/analytic derives a document’s project from its lines (one distinct account → that project; several → multiple; none → empty) and builds the “has a line on project X” filter fragment. Each list endpoint calls it. No schema change, no stored header column.
Tech Stack: Go (gin, pgx), OpenAPI → oapi-codegen, React/TypeScript, react-query, vitest.
Spec: docs/superpowers/specs/2026-10-10-project-link-design.md (PL1, PL2). Parent: 2026-10-06-project-usage-design.md (PU1).
Global Constraints
Section titled “Global Constraints”- No new table or column; a document’s project is derived, never stored (PL1).
api/openapi.yamlfirst; regenerate Go (internal/gen/api/api.gen.go) and TS (web/src/lib/api/schema.d.ts) with the repo’s existing generate command (seeMakefile/package.json; a drift test fails if they are stale). Changes are additive (receivables list is a public API,x-public-api).- Modules never import modules; the helper lives in
internal/platform/accounting/analytic. - Table and column names reach SQL only through the helper’s allowlisted
LineSourcevalues, never from a request. - Every absence assertion in a test is preceded by a presence assertion on the same fixture (AGENTS.md).
- ms/zh strings: add en/ms/zh keys and rows in
docs/review/{ms,zh}-review.csv; native review stays pending. - Run only the packages you touch; full
go test ./...once before the final commit, with-p 1. - Export column (PL2’s third clause) is NOT in this plan: no invoice/quote export kind exists yet. It lands with those kinds in the remaining-exports plan.
- Commit trailer:
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>.
File Structure
Section titled “File Structure”- Create
internal/platform/accounting/analytic/docproject.go(+docproject_test.go):LineSource,DocProject,DocProjects,ProjectsOf,(LineSource).HasProject. - Modify per list:
internal/platform/{receivables/document.go, payables/invoice.go, trade/document.go}(filter field),internal/modules/{receivables/document_handlers.go, payables/invoice_handlers.go, trade/handlers.go}(query param + DTO fields),api/openapi.yaml, generated files. - Modify web:
web/src/features/{receivables/InvoicesPage.tsx+api.ts, payables/InvoicesPage.tsx+api.ts, trade/DocumentsPage.tsx+api.ts}, sharedweb/src/features/projects/ProjectCell.tsx+useProjectOptionshook (Task 5), localesreceivables.json,payables.json,trade.json,common.jsonif a shared label is used.
Task 1: Analytic helper
Section titled “Task 1: Analytic helper”Files:
- Create:
internal/platform/accounting/analytic/docproject.go - Test:
internal/platform/accounting/analytic/docproject_test.go
Interfaces:
-
Produces:
type LineSource struct{ table, fk string } // unexported fields: callers use the vars belowvar (SalesInvoiceLines = LineSource{"sales_invoice_line", "invoice_id"}PurchaseInvoiceLines = LineSource{"purchase_invoice_line", "invoice_id"}TradeLines = LineSource{"trade_line", "document_id"})type DocProject struct { ID uuid.UUID; Code, Name string }// Single is set when every project share on the document's lines is the same// account; Multiple when two or more distinct project accounts appear. Both// zero when no line carries a project.type DocProjects struct { Single *DocProject; Multiple bool }func ProjectsOf(ctx context.Context, tx pgx.Tx, src LineSource, ids []uuid.UUID) (map[uuid.UUID]DocProjects, error)// HasProject returns a SQL boolean expression true when document header// alias hdr has a line sharing to the account in placeholder param// (e.g. "$15::uuid"): hdr must expose org_id and id.func (s LineSource) HasProject(hdr, param string) string -
Step 1: Write the failing test. Using
testdb.Startand the package’s existing test helpers (readplan_test.goto see how an org, the seededprojectaxis and accounts are created), insert threesales_invoice_line-like fixtures is heavy; instead test through a minimal approach: create the org + project axis + accounts A and B + a non-project axis account C, then insert header+lines for the three cases. If inserting a fullsales_invoiceis impractical (many NOT NULL columns), usetrade_line/purchase_invoice_linewhichever the package tests already seed; readinternal/platform/receivables/*_test.gofor aseedInvoice-style helper and, if the analytic test package cannot import it, put this test ininternal/platform/receivables/instead and say so in the commit. Cases (assert each map entry):- doc1: lines share to A and A →
Single.Code == A,Multiple == false - doc2: lines share to A and B →
Single == nil,Multiple == true - doc3: lines with
shares = []→ zero value - doc4: line shares only to C (other axis) → zero value (proves the
projectaxis filter) - presence first:
require.Len(t, got, 4)and doc1’sSinglenon-nil before the absence checks. HasProject: a querySELECT id FROM <hdr> h WHERE <HasProject("h","$2::uuid")>with A returns doc1 and doc2 only; with B returns doc2 only; presence asserted (non-empty) first.
- doc1: lines share to A and A →
-
Step 2: Run, verify FAIL —
go test ./internal/platform/accounting/analytic/ -run DocProject -count=1→ undefinedProjectsOf. -
Step 3: Implement.
ProjectsOfemptyids→ empty map, no query. Query (org fromtenancy, same as neighbours inanalytic.go):SELECT l.<fk>, a.id, a.code, a.nameFROM <table> lCROSS JOIN LATERAL jsonb_to_recordset(l.shares) AS s(analytic_account_id uuid)JOIN analytic_account a ON a.org_id = l.org_id AND a.id = s.analytic_account_idJOIN analytic_axis x ON x.org_id = a.org_id AND x.id = a.axis_id AND x.key = 'project'WHERE l.org_id = $1 AND l.<fk> = ANY($2)GROUP BY l.<fk>, a.id, a.code, a.nameFold rows per document in Go: first account →
Single; a different second →Single=nil, Multiple=true.HasProject:return fmt.Sprintf(`EXISTS (SELECT 1 FROM %s l WHERE l.org_id = %s.org_id AND l.%s = %s.idAND l.shares @> jsonb_build_array(jsonb_build_object('analytic_account_id', %s::uuid)))`, s.table, hdr, s.fk, hdr, param)(
@>over the array of objects matches any line with that account; confirm the stored key name isanalytic_account_idinshareJSON.) -
Step 4: Run, verify PASS, plus
go vet ./internal/platform/accounting/analytic/. -
Step 5: Watch it fail once. Temporarily drop the
x.key = 'project'join condition; doc4 case must go red with a readable message; restore. -
Step 6: Commit —
feat(analytic): derive a document's project from its line shares.
Task 2: Sales invoices — API, filter, DTO
Section titled “Task 2: Sales invoices — API, filter, DTO”Files:
- Modify:
api/openapi.yaml(/receivables/invoicesGET params at ~line 10741;SalesDocumentschema at ~26041),internal/platform/receivables/document.go(DocumentFilter,ListDocuments),internal/modules/receivables/document_handlers.go(listDocuments,documentDTO,toDocumentDTO), generated files. - Test:
internal/modules/receivables/document_handlers_test.go.
Interfaces:
-
Consumes:
analytic.SalesInvoiceLines,analytic.ProjectsOf,(LineSource).HasProject. -
Produces: query param
project_id(uuid); response fields on everySalesDocument:project: {id, code, name} | nullandmultiple_projects: boolean(both always present;null/falsewhen none). Shared schemaDocumentProject{id: uuid, code: string, name: string}incomponents/schemas, reused by Tasks 3–4. -
Step 1: Write failing handler tests (mirror the existing list tests in
document_handlers_test.go): create three sales invoices through the existing helpers — lines on project A, on A and B, and none.GET /receivables/invoices→ doc1 hasproject.code == A,multiple_projects == false; doc2 hasproject == null,multiple_projects == true; doc3 null/false (presence: assert the list has 3 rows first).GET …?project_id=<A>returns exactly doc1 and doc2 (assert ≥1 row, then that doc3 is absent).?project_id=not-a-uuid→ 422 validation. -
Step 2: Run, verify FAIL.
-
Step 3: Implement. OpenAPI first: param
{ name: project_id, in: query, schema: {type: string, format: uuid}, description: "Only documents with a line charged to this project." };DocumentProjectschema; addproject(nullable: true,allOf: [$ref DocumentProject]) andmultiple_projectstoSalesDocumentrequiredand properties. Regenerate.DocumentFilter.ProjectID *uuid.UUID; inListDocumentsadd the next free$Nargument andAND ($N::uuid IS NULL OR+analytic.SalesInvoiceLines.HasProject("h", "$N::uuid")+)— mind the existing “unreferenced parameter is an error” note and the conditional$14sort parameter: append the project arg after the sort arg is decided and compute$Nfromlen(args)+1, asgrpdoes. Handler parsesproject_id(400/422 on bad uuid followingcustomerParam’s style), and after the page loads callsProjectsOf(ctx, tx, analytic.SalesInvoiceLines, ids)once for the page;toDocumentDTOgains the two fields (list only; detail view may leave them zero — the spec’s schema says always present, so set them in the single-document handler too using the same call). -
Step 4: Run
go test ./internal/modules/receivables/ ./internal/platform/receivables/ ./internal/app/ -count=1(the app package holds the openapi/route drift tests). -
Step 5: Commit —
feat(receivables): project and project filter on the sales invoice list.
Task 3: Purchase invoices — API, filter, DTO
Section titled “Task 3: Purchase invoices — API, filter, DTO”Same shape as Task 2.
Files: api/openapi.yaml (payables invoices list + PurchaseInvoice list schema), internal/platform/payables/invoice.go (InvoiceFilter, ListInvoices, around lines 157 and 296), internal/modules/payables/invoice_handlers.go (listInvoices at ~443 and DTO), generated files; test internal/modules/payables/invoice_handlers_test.go.
Interfaces: Consumes analytic.PurchaseInvoiceLines, DocumentProject schema from Task 2. Produces project_id query param and project, multiple_projects response fields on the payables invoice DTO.
- Step 1: failing tests as Task 2 (three invoices: A, A+B, none; filter by A; bad uuid → the handler’s existing validation status, see how
supplier_idis rejected at line ~455). - Step 2: run, verify FAIL.
- Step 3: implement as Task 2:
InvoiceFilter.ProjectID, filter fragment withanalytic.PurchaseInvoiceLines.HasProject(<header alias used in ListInvoices>, ...), handler parse +ProjectsOfonce per page. - Step 4:
go test ./internal/modules/payables/ ./internal/platform/payables/ ./internal/app/ -count=1. - Step 5: commit —
feat(payables): project and project filter on the purchase invoice list.
Task 4: Trade documents (quotes, orders) — API, filter, DTO
Section titled “Task 4: Trade documents (quotes, orders) — API, filter, DTO”Files: api/openapi.yaml (trade documents list + document schema), internal/platform/trade/document.go (Filter, List at ~109/214), internal/modules/trade/handlers.go (list handler at ~172, documentDTO at ~47), generated files; test internal/modules/trade/handlers_test.go.
Interfaces: Consumes analytic.TradeLines. trade_line has withdrawn_at: a withdrawn line must not count toward a document’s project. Extend ProjectsOf/HasProject with that: add a liveCol string field to LineSource ("" for sales/purchase, "withdrawn_at" for trade) and append AND l.<liveCol> IS NULL when set; extend Task 1’s test with a trade-line case proving a withdrawn line is ignored (watch it fail without the clause).
- Step 1: failing tests as Task 2 using the trade quote helpers in
handlers_test.go, including a withdrawn A line on an otherwise-B document →project.code == B. - Step 2: run, verify FAIL.
- Step 3: implement:
Filter.ProjectID(queryproject_id),AND ($9::uuid IS NULL OR <HasProject("h","$9::uuid")>)with the next free number (the query currently uses$1–$8), pass the arg, handler + DTO fields. - Step 4:
go test ./internal/platform/accounting/analytic/ ./internal/modules/trade/ ./internal/platform/trade/ ./internal/app/ -count=1. - Step 5: commit —
feat(trade): project and project filter on quotes and orders.
Task 5: Web — shared cell, project filter, three lists
Section titled “Task 5: Web — shared cell, project filter, three lists”Files:
- Create:
web/src/features/projects/ProjectCell.tsx,web/src/features/projects/useProjectOptions.ts(look inweb/src/features/projects/first: a hook listing project accounts probably exists — reuse it and skip creating the file). - Modify:
web/src/features/receivables/{InvoicesPage.tsx,api.ts},web/src/features/payables/{InvoicesPage.tsx,api.ts},web/src/features/trade/{DocumentsPage.tsx,api.ts}, locales{en,ms,zh}/{receivables,payables,trade}.json,docs/review/{ms,zh}-review.csv. - Test:
web/src/features/projects/__tests__/ProjectCell.test.tsx, plus one assertion per page in the existing__tests__for each list.
Interfaces: Consumes the project / multiple_projects fields and project_id param from Tasks 2–4 (types come from regenerated schema.d.ts).
-
Produces:
<ProjectCell project={doc.project} multiple={doc.multiple_projects} />rendersCODE(title = name), the localized “Multiple” chip, or–. -
Step 1: Failing tests.
ProjectCell: single → shows code; multiple → shows the translated multiple label; none →–. Each list page test: with an MSW handler returning three documents, the Project column showsPRJ-A, “Multiple”,–; choosing a project in the filter sendsproject_id(assert on the captured request URL), after asserting the unfiltered request was made first. -
Step 2:
pnpm --dir web vitest run <files>→ FAIL. -
Step 3: Implement. In each page add a
projectcolumn (hidden by default viameta.defaultHidden: true, matchingdateandoutstanding) and a projectComboboxin the advanced filter panel (draft/applied state likecustomer), passingproject_idto the list hook; addproject_idto the threeapi.tsquery types and to the “filtered” flag and page-reset effect deps. Keys:invoices.columns.project,invoices.filters.project(and trade/payables equivalents),commonor per-namespaceproject.multiple. Add en/ms/zh and the review-CSV rows. -
Step 4: run the three feature folders’ tests, then
pnpm --dir web typecheckandpnpm --dir web lint(one pre-existing warning inleave/ReasonDialog.tsxis known). -
Step 5: Check in the browser with the preview tools: open each list, toggle the Project column on, filter by a project. Report only what was seen.
-
Step 6: commit —
feat(web): project column and filter on invoice, bill and quote lists.
Task 6: Roadmap, drift and wrap-up
Section titled “Task 6: Roadmap, drift and wrap-up”Files: docs/ROADMAP.md (under “Project usage across modules”, add - [x] Project column and filter on sales/purchase/quote lists (project-link piece 1) with the spec link, and unticked lines for pieces 2–4), memory file table-export-status.md.
- Step 1: Run once:
go test ./... -p 1and the full web suite. Report failures verbatim. - Step 2: Update ROADMAP honestly (export column deferred; browser check done or not).
- Step 3: Commit —
docs: roadmap for project link piece 1.
Self-review notes
Section titled “Self-review notes”- Spec PL1 → Task 1; PL2 lists/filters → Tasks 2–5; PL2 export column deliberately deferred (Global Constraints). PL3–PL7 are later plans.
- Names consistent across tasks:
LineSource,SalesInvoiceLines,PurchaseInvoiceLines,TradeLines,ProjectsOf,HasProject,DocProjects, responseproject/multiple_projects, queryproject_id. - Known unknowns the implementer must confirm in code: the header alias in
ListInvoices; exact next$Nin each query; the stored share key name.