Skip to content

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

  • No new table or column; a document’s project is derived, never stored (PL1).
  • api/openapi.yaml first; regenerate Go (internal/gen/api/api.gen.go) and TS (web/src/lib/api/schema.d.ts) with the repo’s existing generate command (see Makefile/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 LineSource values, 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>.
  • 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}, shared web/src/features/projects/ProjectCell.tsx + useProjectOptions hook (Task 5), locales receivables.json, payables.json, trade.json, common.json if a shared label is used.

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 below
    var (
    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.Start and the package’s existing test helpers (read plan_test.go to see how an org, the seeded project axis and accounts are created), insert three sales_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 full sales_invoice is impractical (many NOT NULL columns), use trade_line/purchase_invoice_line whichever the package tests already seed; read internal/platform/receivables/*_test.go for a seedInvoice-style helper and, if the analytic test package cannot import it, put this test in internal/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 project axis filter)
    • presence first: require.Len(t, got, 4) and doc1’s Single non-nil before the absence checks.
    • HasProject: a query SELECT 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.
  • Step 2: Run, verify FAIL — go test ./internal/platform/accounting/analytic/ -run DocProject -count=1 → undefined ProjectsOf.

  • Step 3: Implement. ProjectsOf empty ids → empty map, no query. Query (org from tenancy, same as neighbours in analytic.go):

    SELECT l.<fk>, a.id, a.code, a.name
    FROM <table> l
    CROSS 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_id
    JOIN 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.name

    Fold 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.id
    AND 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 is analytic_account_id in shareJSON.)

  • 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/invoices GET params at ~line 10741; SalesDocument schema 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 every SalesDocument: project: {id, code, name} | null and multiple_projects: boolean (both always present; null/false when none). Shared schema DocumentProject {id: uuid, code: string, name: string} in components/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 has project.code == A, multiple_projects == false; doc2 has project == 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." }; DocumentProject schema; add project (nullable: true, allOf: [$ref DocumentProject]) and multiple_projects to SalesDocument required and properties. Regenerate. DocumentFilter.ProjectID *uuid.UUID; in ListDocuments add the next free $N argument and AND ($N::uuid IS NULL OR + analytic.SalesInvoiceLines.HasProject("h", "$N::uuid") + ) — mind the existing “unreferenced parameter is an error” note and the conditional $14 sort parameter: append the project arg after the sort arg is decided and compute $N from len(args)+1, as grp does. Handler parses project_id (400/422 on bad uuid following customerParam’s style), and after the page loads calls ProjectsOf(ctx, tx, analytic.SalesInvoiceLines, ids) once for the page; toDocumentDTO gains 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_id is rejected at line ~455).
  • Step 2: run, verify FAIL.
  • Step 3: implement as Task 2: InvoiceFilter.ProjectID, filter fragment with analytic.PurchaseInvoiceLines.HasProject(<header alias used in ListInvoices>, ...), handler parse + ProjectsOf once 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 (query project_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 in web/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} /> renders CODE (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 shows PRJ-A, “Multiple”, –; choosing a project in the filter sends project_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 project column (hidden by default via meta.defaultHidden: true, matching date and outstanding) and a project Combobox in the advanced filter panel (draft/applied state like customer), passing project_id to the list hook; add project_id to the three api.ts query types and to the “filtered” flag and page-reset effect deps. Keys: invoices.columns.project, invoices.filters.project (and trade/payables equivalents), common or per-namespace project.multiple. Add en/ms/zh and the review-CSV rows.

  • Step 4: run the three feature folders’ tests, then pnpm --dir web typecheck and pnpm --dir web lint (one pre-existing warning in leave/ReasonDialog.tsx is 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.

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 1 and 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.
  • 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, response project / multiple_projects, query project_id.
  • Known unknowns the implementer must confirm in code: the header alias in ListInvoices; exact next $N in each query; the stored share key name.