Phase 4 O1 — Document Chain and Item Master Implementation Plan
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: Build the generic document chain (platform/trade) that procurement and sales will share, the item master (platform/inventory) it draws items, units and locations from, and the new committed workflow semantic that requisitions, orders, quotations and sales orders end their approval in.
Architecture: One trade_document / trade_line pair for all nine chain kinds (OD-5), linked line to line by source_line_id. The database is the authority for the chain’s invariants: a trade_line_guard trigger refuses a link outside the allowed pairs, across parties, from an unapproved or closed source, or past the source line’s open quantity (it locks the source line FOR UPDATE, so two children of one line serialize), and the composite foreign key refuses a link across organizations. platform/trade is a thin Go service over that schema: create/update/list/get, a transition wrapper around workflow.Transition, copy-forward (Draft), open quantity, and manual close. platform/inventory holds items (names and descriptions as translation rows), the uom and item_category registries, per-item alternate units with a factor to base, and locations, with an HTTP surface and screens. Nothing in O1 posts to the ledger: committed means “approved to act on; nothing posts”, and the receipt/delivery/return/adjustment kinds are declared here but their workflows and postings belong to O2/O3.
Tech Stack: Go 1.x, pgx v5, Postgres 16 (goose migrations), gin, oapi-codegen, React + TanStack Router/Query + i18next (web), vitest + msw.
Spec: docs/superpowers/specs/2026-09-30-operations-design.md (sections O1.1, O1.2, and the O1 drift-test bullet). Read it with this plan. Architecture: docs/superpowers/specs/2026-09-20-erp-architecture-design.md §5.5 (semantics), §19 Phase 4.
Global Constraints
Section titled “Global Constraints”- Migration ranges: O1 chain
00700–00709; O1 item master00710–00719. Reserved (declared in Task 1, used by other lanes): O200720–00739, O300740–00769, O400770–00789. - A float never touches money.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire. Unit price isNUMERIC(19,4)(spec O1.1). - Quantities are
NUMERIC(19,6)in Postgres,money.Ratein Go (the typesales_invoice_line.quantityalready uses), a string on the wire, at most six decimal places.item_uomfactor to base isNUMERIC(19,6)(spec O1.2). - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolation. The two registry tables (uom,item_category) have a nullableorg_id(a seeded global row) and are declared indb/org_id_nullable.txt. - Every constraint is named (
CONSTRAINT <table>_<rule>);TestEveryConstraintIsNamedfails on a generated name. - Every function pins its search path (
SET search_path = pg_catalog, public, pg_temp) and schema-qualifies what it reads (public.). NoSECURITY DEFINER. - Retention: every new table has
DELETErevoked fromerp_appand is listed ininternal/kernel/db/retention_test.go’samendablelist. Items, units, categories and locations are retired (is_active), never deleted; trade documents are cancelled through their workflow, never deleted; a draft’s replaced line is withdrawn (withdrawn_at). - Vocabulary values are strings (
kind,type,tracking, semantic). - Every user-facing name is a translation row: item name and description, location name, unit name, category name (
translation,entity_kind=item,location,uom,item_category; fieldnameordescription), en/ms/zh. accounting/reacts to semantic tags, never to state names.committedposts nothing:posting.Event.PostsToLedger()is false for it, and cancelling from it reverses nothing.- A claimant never picks their own approver. The seeded grants name the requester’s superior (requisition) or the OWNER seat (order, sales order); the engine resolves them.
- The contract is
api/openapi.yaml. Endpoints are declared there first;make generatemust leave the tree clean (internal/gen/drift_test.go,web/src/drift/schema.test.ts,internal/kernel/vocab/vocab_test.go). - Module boundaries (binding on O2 and O3):
platform/inventorynever importsplatform/trade.platform/tradeimportsplatform/inventory, and never importsplatform/payablesorplatform/receivables(O3’s three-way match will have those importtrade). Posting a trade line’s stock effect lives ontrade’s side (O2/O3), callinginventory. - Drift tests: presence before absence, each watched failing once (AGENTS.md). While iterating, run only the touched packages; run
go test ./...once, in Task 11. - Lock order (added to
docs/LOCK_ORDER.mdin Task 11): in a one-member organizationorganizationFOR UPDATE(workflow.LockSoleOwner) → trade headerFOR UPDATE→ on a cancel or close, its live linesFOR UPDATEinidorder →workflow.Transition→ audit. A child line’s insert locks only its source line. - Commits: one per task, the message ending with a blank line and
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Merging and pushing are the owner’s.
Rulings (where the spec is silent)
Section titled “Rulings (where the spec is silent)”- O1-1 Numbering at create. A document is numbered when it is created (drafts included),
PREFIX-YYYY-NNNNvianumbering.Allocate(scope = kind, period = calendar year of creation). Prefixes:PRrequisition,POorder,GRNgoods receipt,PRNpurchase return,QTquotation,SOsales order,DOdelivery order,SRNsales return,ADJstock adjustment. A withdrawn draft keeps its number (it is visible as withdrawn, so there is no gap to explain). These are not statutory numbers. - O1-2 Document date is the creation day (
clock.Today), never caller-supplied; the caller suppliesdue_date(needed-by, delivery date, valid-until), which may not precede it. - O1-3 Header currency and FX rate follow AP/AR’s rules (base currency ⇒ rate 1; foreign ⇒ rate required). The rate prices
net_base, the amount approval bands compare. O3 converts a receipt at the receipt date’s rate (spec O3.1), not this one. - O1-4 A live child is a child line that is not withdrawn and whose document’s current state is not
cancelled-semantic. Drafts and rejected documents count, so two drafts cannot over-draw one line; a withdrawn or cancelled child gives its quantity back (spec “Reversal”). - O1-5 Auto-close is derived, not stored.
Document.Fulfilledis true when the document has lines and every live line’s open quantity is zero;Document.Closed()isClosedAt != nil || Fulfilled.closed_at/close_reason/closed_byrecord the manual close only. A stored auto-close would need a second writer to reopen it when a child is cancelled. - O1-6 A source must be approved and open: its state’s semantic is not
draft,pending,rejected,cancelledordispute, its line is not withdrawn, and the document is not manually closed. A child line carries its source line’s item (text line ⇒ text line). - O1-7 Lines are immutable except for withdrawal; a draft edit withdraws and re-inserts (AP’s Ruling P16 pattern). A line with live children cannot be withdrawn, and a document with live children cannot be cancelled — close it instead.
- O1-8 Units are UN/ECE Recommendation 20 codes (
^[A-Z0-9]{2,3}$, the shapesales_invoice_line.unit_codealready takes). Ten globals are seeded (C62, KGM, GRM, LTR, MTR, BX, PK, SET, HUR, DAY); an organization adds its own. A text line (no item) has factor 1.trade.Draftkeeps the source line’s unit when the open base quantity is a whole multiple of its factor at six places, and falls back to the item’s base unit otherwise, at the unit price ÷ factor (money.Amount.MulDiv, rounded half away from zero to 4 places). - O1-9 Item accounts. A stock item names an inventory account (active, postable asset, no control kind) and a COGS account (expense); a service or non-stock item names neither. Sales (income) and purchase (expense) accounts are optional defaults. An item’s type, base unit and tracking are fixed once a trade line names it (
item_fixedtrigger; O2 extends the trigger tostock_move). - O1-10 Permissions.
inventory.item.readandinventory.item.managecover items, units, categories and locations (all master data). Granted to Owner (every registered key) and Finance.inventory.adjustment.*andinventory.transfer.*are O2’s;procurement.*andsales.*are O3’s. - O1-11 The import kind is
items(plural, likecustomersandsuppliers). Create only; alternate units are added on the item screen. - O1-12 Seeded graphs. Requisition: draft → pending_approval → approved (
committed), approved by the requester’s superior. Order and sales order: the same shape, approved by the OWNER seat (sales order’s committed state is labelled “Confirmed”). Quotation: one step, draft → issued (committed) by its preparer (spec O3.2). A committed requisition, order or sales order is cancelled by the OWNER seat; a quotation by its preparer.accrual_firstis vacuous (no posting states), as the purchase-request spec’s D4 argued. - O1-13 New optional modules default on (no
module_installrow means on).procurementandsaleshave no routes until O3. - O1-14 Receipt, delivery, returns and adjustment are declared (schema,
tradekind constants, numbering prefixes, allowed pairs), but their workflow graphs and postings are O2’s (receipt accrual on a newgrnicontrol kind; delivery and adjustment on a neweffectsemantic) and O3’s. Until a lane registers a kind’s graph,trade.Createfor it returnsworkflow.ErrNoDefinition. - O1-18 Sole-owner exception (controller ruling, all Phase 4 lanes). The four commitment kinds opt into Ruling O-3a with
workflow.AllowSoleOwnerException(kind, true): in an organization with one active member, that member submits and approves their own requisition, order or sales order, and cancels a committed one, with a mandatory reason (blank ⇒workflow.ErrSoleOwnerReasonRequired), recorded asauthority_via = 'sole_owner_exception'on the decision andsole_owner_exception+ reason on the trade audit row (workflow.SoleOwnerAudit).soleOwnerEdgegainscommitted: the edge into a committed state is covered underissue, and the reason-required cancel out of one undervoid(it reverses nothing).trade.Transitiontakesworkflow.LockSoleOwnerfirst. O2 edits the samesoleOwnerEdgelines foreffect(its Task 1); whichever lands second merges both conditions. - O1-15 Invoice link. The database checks that an AP line points at a
goods_receiptline (invoice) orpurchase_returnline (credit note) of the same supplier, and an AR line at adelivery_orderline (invoice) orsales_returnline (credit note) of the same customer. Quantity matching and threadingTradeLineIDthroughpayables/receivablesGo inputs are O3’s. - O1-16 Trade shares are checked with
analytic.Planagainstexpense(purchase kinds, adjustments) orincome(sales kinds), without enforcing required axes: the posting line’s own account decides that in O2/O3. - O1-17 Web. The item master lives in
web/src/features/inventory, in theaccountingnav group until O3 adds an operations group.
Interfaces O2 and O3 consume
Section titled “Interfaces O2 and O3 consume”All in github.com/mcom/erp/internal/platform/.... Stable after this plan lands.
// package tradeconst ( KindRequisition = "purchase_requisition" KindOrder = "purchase_order" KindReceipt = "goods_receipt" KindPurchaseReturn = "purchase_return" KindQuotation = "quotation" KindSalesOrder = "sales_order" KindDelivery = "delivery_order" KindSalesReturn = "sales_return" KindAdjustment = "stock_adjustment" EntityDocument = "trade_document" // audit entity kind)var Kinds []stringfunc NextKind(source string) (target string, ok bool)func PartySide(kind string) string // "supplier", "customer" or ""func NeedsLocation(kind string) bool
type Line struct { ID uuid.UUID; LineNo int; ItemID *uuid.UUID; Description string Quantity money.Rate; UOMID uuid.UUID; UOMFactor money.Rate; QuantityBase money.Rate UnitPrice money.Amount; NetAmount money.Amount; TaxCodeID *uuid.UUID Shares []analytic.Share; SourceLineID *uuid.UUID; OpenQuantity money.Rate}type Document struct { ID uuid.UUID; Kind, Number string; PartyID *uuid.UUID; Currency string; FXRate money.Rate DocumentDate time.Time; DueDate *time.Time; LocationID *uuid.UUID NetTotal, NetBase money.Amount; ClosedAt *time.Time; CloseReason *string; ClosedBy *uuid.UUID Fulfilled bool; InstanceID, PreparedBy uuid.UUID; Revision int; CreatedAt, UpdatedAt time.Time State string; Semantic posting.Semantic; Lines []Line}func (d Document) Closed() booltype LineInput struct { ItemID *uuid.UUID; Description string; Quantity money.Rate; UOMID uuid.UUID UnitPrice money.Amount; TaxCodeID *uuid.UUID; Shares []analytic.Share; SourceLineID *uuid.UUID}type DocumentInput struct { Kind string; PartyID *uuid.UUID; Currency string; FXRate *money.Rate DueDate *time.Time; LocationID *uuid.UUID; Lines []LineInput}type Filter struct { Kind string; PartyID *uuid.UUID; State string; Open bool; Limit int; After *Cursor }type Cursor struct { CreatedAt time.Time; ID uuid.UUID }func (c Cursor) Encode() stringfunc DecodeCursor(s string) (Cursor, error)type TransitionInput struct { ID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in DocumentInput) (Document, error)func Update(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, in DocumentInput) (Document, error)func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Document, error)func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Document, *Cursor, error) // Lines left nilfunc Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Document, workflow.Decision, error)func Draft(ctx context.Context, tx pgx.Tx, sourceDocID uuid.UUID, targetKind string) (DocumentInput, error)func OpenQuantity(ctx context.Context, tx pgx.Tx, lineID uuid.UUID) (money.Rate, error)func Close(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, reason string) (Document, error)// Graph builders, registered from init(): RequisitionGraph, OrderGraph, QuotationGraph, SalesOrderGraph (ownerSeat uuid.UUID) workflow.Graph
// errors: ErrNotFound, ErrNotDraft, ErrNotPreparer, ErrPairNotAllowed, ErrNotApproved, ErrSourceClosed,// ErrPartyMismatch, ErrItemMismatch, ErrOverOpenQuantity, ErrSourceNotFound, ErrNothingOpen,// ErrHasChildren, ErrNotClosable, ErrAlreadyClosed, ErrBadCursor; *FieldError{Field, Rule, Detail};// *RevisionMismatchError{Current}
// package trade/tradetest (test fixture)type Env struct{ *expensestest.Env }func New(t *testing.T) *Envfunc (e *Env) Another(t *testing.T) *Envfunc (e *Env) As(member uuid.UUID) context.Contextfunc (e *Env) UOM(t *testing.T, key string) uuid.UUIDfunc (e *Env) Account(t *testing.T, code string) uuid.UUIDfunc (e *Env) Supplier(t *testing.T, code string) uuid.UUIDfunc (e *Env) Customer(t *testing.T, code string) uuid.UUIDfunc (e *Env) StockItem(t *testing.T, code string, alternates ...inventory.ItemUOMInput) inventory.Itemfunc (e *Env) Location(t *testing.T, code string) inventory.Locationfunc (e *Env) PublishStub(t *testing.T, kind string)func (e *Env) Create(t *testing.T, in trade.DocumentInput) trade.Documentfunc (e *Env) Move(t *testing.T, d trade.Document, by uuid.UUID, from, to string) trade.Documentfunc (e *Env) Commit(t *testing.T, d trade.Document) trade.Documentfunc TextLine(uom uuid.UUID, qty, price string) trade.LineInput
// package inventoryconst (TypeStock = "stock"; TypeService = "service"; TypeNonStock = "non_stock" TrackingNone = "none"; TrackingSerial = "serial"; TrackingBatch = "batch" EntityItem = "item"; EntityUOM = "uom"; EntityCategory = "item_category"; EntityLocation = "location")type Names map[string]stringtype UOM struct { ID uuid.UUID; Key string; Names Names; IsActive, Global bool }type Category struct { ID uuid.UUID; Key string; Names Names; IsActive bool }type ItemUOM struct { UOMID uuid.UUID; Factor money.Rate; IsActive bool }type ItemUOMInput struct { UOMID uuid.UUID; Factor money.Rate }type Item struct { ID uuid.UUID; Code string; Names, Descriptions Names; Type string; BaseUOMID uuid.UUID CategoryID, TaxCodeID, InventoryAccountID, COGSAccountID, SalesAccountID, PurchaseAccountID *uuid.UUID Tracking string; IsActive bool; CustomFields map[string]string; UOMs []ItemUOM; Revision int CreatedAt, UpdatedAt time.Time}type ItemInput struct { Code, Type, Tracking string; Names, Descriptions Names; BaseUOMID uuid.UUID CategoryID, TaxCodeID, InventoryAccountID, COGSAccountID, SalesAccountID, PurchaseAccountID *uuid.UUID IsActive bool; UOMs []ItemUOMInput; CustomFields map[string]string}type ItemFilter struct { Query, Type string; CategoryID *uuid.UUID; ActiveOnly bool; Fields map[string]string }type Location struct { ID uuid.UUID; Code string; Names Names; IsActive bool; Revision int; CreatedAt, UpdatedAt time.Time }type LocationInput struct { Code string; Names Names; IsActive bool }
func CreateItem(ctx context.Context, tx pgx.Tx, c clock.Clock, in ItemInput) (Item, error)func UpdateItem(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in ItemInput) (Item, error)func GetItem(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Item, error)func GetItemByCode(ctx context.Context, tx pgx.Tx, code string) (Item, error)func ListItems(ctx context.Context, tx pgx.Tx, f ItemFilter) ([]Item, error)func UOMFactor(ctx context.Context, tx pgx.Tx, itemID, uomID uuid.UUID) (money.Rate, error)func GetUOM(ctx context.Context, tx pgx.Tx, id uuid.UUID) (UOM, error)func ListUOMs(ctx context.Context, tx pgx.Tx) ([]UOM, error)func CreateUOM(ctx context.Context, tx pgx.Tx, c clock.Clock, key string, names Names) (UOM, error)func GetCategory(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Category, error)func ListCategories(ctx context.Context, tx pgx.Tx) ([]Category, error)func CreateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, key string, names Names) (Category, error)func UpdateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, names Names, active bool) (Category, error)func CreateLocation(ctx context.Context, tx pgx.Tx, c clock.Clock, in LocationInput) (Location, error)func UpdateLocation(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in LocationInput) (Location, error)func GetLocation(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Location, error)func ListLocations(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Location, error)// errors: ErrNotFound, ErrDuplicateCode, ErrItemInUse, ErrUOMNotForItem; *FieldError; *RevisionMismatchError
// package postingconst Committed Semantic = "committed"// package workflowvar CommitmentKinds = []string{"purchase_requisition", "purchase_order", "quotation", "sales_order"}SQL O2/O3 may call: trade_semantic(org uuid, instance uuid) text, trade_open_quantity(org uuid, line uuid) numeric. O2 replaces item_fixed_once_used() to also look at stock_move.
File Structure
Section titled “File Structure”Create
| Path | Responsibility |
|---|---|
db/migrations/00700_committed_semantic.sql |
committed joins workflow_state’s semantic CHECK |
db/migrations/00701_trade_document.sql |
trade_document, trade_line, trade_semantic, trade_open_quantity, draft and chain guards |
db/migrations/00702_invoice_trade_link.sql |
nullable trade_line_id on purchase_invoice_line and sales_invoice_line, kind/party guards |
db/migrations/00710_item_master.sql |
uom and item_category registries (with seeds), item, item_uom, location |
db/migrations/00711_trade_item_links.sql |
trade → item/uom/location foreign keys; item_fixed_once_used |
db/migrations/00712_item_custom_fields.sql |
item joins custom_field_entity_known |
db/migrations/00713_inventory_grants.sql |
Owner and Finance get inventory.item.* in existing organizations |
db/migrations/00714_import_items.sql |
items joins import_job_kind_known |
internal/platform/inventory/{doc,names,uom,category,item,location}.go |
item master service |
internal/platform/inventory/{schema,item,lookup,location,migration00713}_test.go |
its tests |
internal/platform/trade/{doc,graphs,document,transition,chain,cursor}.go |
chain service |
internal/platform/trade/tradetest/env.go |
fixture O2/O3 reuse |
internal/platform/trade/{graphs,document,transition,chain,draft,close,invoicelink}_test.go |
its tests |
internal/platform/workflow/committed_test.go |
the database accepts committed |
internal/platform/imports/{items,items_test}.go |
the items import kind |
internal/modules/inventory/{permissions,routes,http,errors,item_handlers,lookup_handlers,location_handlers,boot}.go |
HTTP surface |
internal/modules/inventory/{env,handlers,grants}_test.go |
its tests |
web/src/features/inventory/{api.ts,ItemsPage.tsx,ItemDialog.tsx,ItemDrawer.tsx,LookupsDialog.tsx,LocationsPage.tsx,routes.tsx} |
screens |
web/src/features/inventory/__tests__/inventory.test.tsx |
screen tests |
web/src/lib/i18n/locales/{en,ms,zh}/inventory.json |
screen copy |
Modify — constants.yaml, internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts (generated), internal/platform/posting/{posting,posting_test}.go, internal/platform/workflow/{model,validate,validate_test}.go, internal/kernel/db/{migrationranges,retention}_test.go, db/org_id_nullable.txt, internal/platform/customfield/customfield.go, internal/platform/moduleinstall/{moduleinstall,moduleinstall_test}.go, internal/platform/imports/imports.go, internal/app/{app,routes_test,seededgraphs_test}.go, cmd/api/main.go, cmd/{orgadmin,migrate}/{main,wiring_test}.go, api/openapi.yaml, generated internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts, web/src/features/workflow/graphModel.ts, web/src/components/workflow/{semanticTone,semanticTone.test}.ts, web/src/features/customfields/api.ts, web/src/app/{routes.ts,shell/nav.ts}, locale files workflowEditor, modules, team, customfields, imports, common (×3), docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md.
Task 1: The committed semantic, end to end
Section titled “Task 1: The committed semantic, end to end”Files:
- Modify:
constants.yaml(workflowSemantics), then regenerateinternal/kernel/vocab/vocab.gen.go,packages/contracts/vocab.ts - Modify:
internal/platform/posting/posting.go,internal/platform/posting/posting_test.go - Modify:
internal/platform/workflow/model.go,internal/platform/workflow/validate.go,internal/platform/workflow/validate_test.go - Create:
db/migrations/00700_committed_semantic.sql,internal/platform/workflow/committed_test.go - Modify:
internal/kernel/db/migrationranges_test.go - Modify:
api/openapi.yaml(schemasSemantic,WorkflowSemantic), regenerate - Modify:
web/src/features/workflow/graphModel.ts,web/src/components/workflow/semanticTone.ts,web/src/components/workflow/semanticTone.test.ts,web/src/lib/i18n/locales/{en,ms,zh}/workflowEditor.json
Interfaces:
-
Consumes: nothing new.
-
Produces:
posting.Committed Semantic = "committed";workflow.CommitmentKinds []string; validator codescommitted_count,posting_in_commitment,committed_not_commitment_kind(rule 3); DB constraintworkflow_state_semantic_known. -
Step 1: Declare the migration ranges
In internal/kernel/db/migrationranges_test.go, append to migrationRanges after the {670, 699, ...} entry:
{700, 709, "Phase 4 O1 document chain (2026-09-30-operations-design.md §O1.1)"}, {710, 719, "Phase 4 O1 item master (2026-09-30-operations-design.md §O1.2)"}, {720, 739, "Phase 4 O2 stock and costing (2026-09-30-operations-design.md §O2)"}, {740, 769, "Phase 4 O3 procurement and sales (2026-09-30-operations-design.md §O3)"}, {770, 789, "Phase 4 O4 fixed assets (2026-09-30-operations-design.md §O4)"},- Step 2: Write the failing validator test
Append to internal/platform/workflow/validate_test.go:
// commitment is purchase_order's shape (Phase 4 O1.1): draft -> pending ->// approved (committed), with a rejection and a cancel that reverse nothing.func commitment() workflow.Graph { return workflow.Graph{ Kind: "purchase_order", PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st("draft", posting.Draft, false), st("pending", posting.Pending, false), st("approved", posting.Committed, false), st("rejected", posting.Rejected, false), st("cancelled", posting.Cancelled, true), }, Edges: []workflow.EdgeSpec{ ed("draft", "pending", workflow.GateReporter), ed("pending", "approved", workflow.GateTarget), rejecting("pending", "rejected", workflow.GateAdvance, false), ed("rejected", "draft", workflow.GateReporter), rejecting("approved", "cancelled", workflow.GateTarget, false), }, Grants: []workflow.GrantSpec{anyone("approved"), anyone("cancelled")}, }}
// Phase 4 O1.1 and the purchase-request spec's D4: a commitment kind commits// exactly once and posts nothing; no other kind commits.func TestACommitmentKindCommitsOnceAndPostsNothing(t *testing.T) { require.Empty(t, workflow.ValidateGraph(commitment(), "MYR"), "PRESENCE: the commitment fixture is valid")
g := commitment() g.States[2].Semantic = posting.Pending require.Contains(t, codes(workflow.ValidateGraph(g, "MYR")), "committed_count")
g = commitment() g.States[2] = st("approved", posting.Accrual, false) vs := codes(workflow.ValidateGraph(g, "MYR")) require.Contains(t, vs, "posting_in_commitment") require.Contains(t, vs, "committed_count")
g = valid() g.States[3] = st("paid", posting.Committed, false) require.Contains(t, codes(workflow.ValidateGraph(g, "MYR")), "committed_not_commitment_kind")}- Step 3: Run it to verify it fails
Run: go test ./internal/platform/workflow/ -run TestACommitmentKindCommitsOnceAndPostsNothing -count=1
Expected: FAIL to compile — undefined: posting.Committed.
- Step 4: Add the semantic to the vocabulary and the posting seam
In constants.yaml, append to workflowSemantics after - custom:
- committedRun: go run ./tools/vocabgen
Expected: internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts now end their semantics lists with "committed".
In internal/platform/posting/posting.go, replace the line Custom Semantic = "custom" with:
Custom Semantic = "custom" // Committed is "approved to act on; nothing posts" (Phase 4 O1.1): a // requisition, order, quotation or sales order its approvers signed. // PostsToLedger is false for it, and cancelling from it reverses // nothing, because nothing was posted. Committed Semantic = "committed"In internal/platform/posting/posting_test.go, in TestConstantsMatchTheVocabulary, replace posting.Cancelled, posting.Custom} { with posting.Cancelled, posting.Custom, posting.Committed} {, and in TestPostsToLedger’s cases add after {posting.Event{Semantic: posting.Custom}, false},:
// Phase 4 O1.1: committing posts nothing, and neither does cancelling // a committed document that never posted. {posting.Event{Semantic: posting.Committed}, false}, {posting.Event{Semantic: posting.Cancelled}, false},- Step 5: Add the validator rule
In internal/platform/workflow/model.go, after AccrualOnlyKinds:
// CommitmentKinds end their approval in a committed state and post nothing// (Phase 4 O1.1; the purchase-request spec's D4 rule): exactly one// committed state, and no accrual or payout state. Literals, since workflow// may not import platform/trade; internal/app's// TestTheCommitmentKindsAreSeeded checks each is a seeded kind.var CommitmentKinds = []string{"purchase_requisition", "purchase_order", "quotation", "sales_order"}In internal/platform/workflow/validate.go, change the semantics map to:
var semantics = map[posting.Semantic]bool{ posting.Draft: true, posting.Pending: true, posting.Accrual: true, posting.Payout: true, posting.Acknowledged: true, posting.Dispute: true, posting.Rejected: true, posting.Cancelled: true, posting.Custom: true, posting.Committed: true,}In ValidateGraph, replace c.accrualOnly() with:
c.accrualOnly() c.commitment()and add after the accrualOnly method:
// Rule 3, for a commitment kind (Phase 4 O1.1): its approval ends in// exactly one committed state and it posts nothing, so the Poster never// hears of it. Every other kind has no committed state: a semantic nothing// listens to would mislead whoever reads the graph.func (c *checker) commitment() { commits := slices.Contains(CommitmentKinds, c.g.Kind) n := 0 for _, s := range c.g.States { switch { case s.Semantic == posting.Committed && !commits: c.add(3, "committed_not_commitment_kind", s.Key, "%s is not a commitment kind, so %s cannot be committed", c.g.Kind, s.Key) case s.Semantic == posting.Committed: n++ case commits && (s.Semantic == posting.Accrual || s.Semantic == posting.Payout): c.add(3, "posting_in_commitment", s.Key, "%s commits and posts nothing; %s is a %s", c.g.Kind, s.Key, s.Semantic) } } if commits && n != 1 { c.add(3, "committed_count", "", "%s commits exactly once; found %d committed states", c.g.Kind, n) }}- Step 6: Run the validator and posting tests
Run: go test ./internal/platform/workflow/ ./internal/platform/posting/ ./internal/kernel/vocab/ -count=1
Expected: PASS.
- Step 7: Write the failing database test
Create internal/platform/workflow/committed_test.go:
package workflow_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
// Phase 4 O1.1: the database's semantic CHECK accepts committed, and still// refuses a value no one listens to.func TestTheDatabaseAcceptsTheCommittedSemantic(t *testing.T) { w := workflowtest.NewWorld(t) w.MustTx(func(ctx context.Context, tx pgx.Tx) error { d, err := workflow.PublishGraph(ctx, tx, w.Clock, commitment(), nil) if err != nil { return err } s, ok := d.State("approved") require.True(t, ok) require.Equal(t, posting.Committed, s.Semantic) return nil })
// ABSENCE, after the presence above: a semantic outside the set is refused. _, err := w.Admin.Exec(context.Background(), `UPDATE workflow_state SET semantic = 'bogus' WHERE org_id = $1 AND state_key = 'approved'`, w.Org) require.ErrorContains(t, err, "workflow_state_semantic_known")}- Step 8: Run it to verify it fails
Run: go test ./internal/platform/workflow/ -run TestTheDatabaseAcceptsTheCommittedSemantic -count=1
Expected: FAIL — the publish returns a check violation on workflow_state_semantic_check.
- Step 9: Write the migration
Create db/migrations/00700_committed_semantic.sql:
-- +goose Up-- Phase 4 O1.1 (docs/superpowers/specs/2026-09-30-operations-design.md):-- committed, "approved to act on; nothing posts", ends the approval of a-- requisition, order, quotation or sales order. The inline CHECK of 00030-- is replaced by a named one (wave-2 contract §3).ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_check, ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom', 'committed'));
-- +goose Down-- Fails while any published state is committed: the code rolled back to-- does not know the semantic. See DEPLOY_NOTES.ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_check CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom'));- Step 10: Run the database tests
Run: go test ./internal/platform/workflow/ ./internal/kernel/db/ -run 'TestTheDatabaseAcceptsTheCommittedSemantic|TestEveryMigrationIsNumberedInItsOwnersRange|TestEveryConstraintIsNamed' -count=1
Expected: PASS.
- Step 11: Put the semantic in the contract and the web
In api/openapi.yaml, in both Semantic and WorkflowSemantic schemas, change the enum to:
enum: [draft, pending, accrual, payout, acknowledged, dispute, rejected, cancelled, custom, committed]Run: make generate
Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain committed.
In web/src/features/workflow/graphModel.ts, add 'committed', after 'custom', in SEMANTICS.
In web/src/components/workflow/semanticTone.ts, add after custom: 'blue',:
// Approved to act on, nothing posted (Phase 4 O1.1): the approved chip. committed: 'green',In web/src/components/workflow/semanticTone.test.ts, add committed: 'green', after custom: 'blue',.
In web/src/lib/i18n/locales/en/workflowEditor.json, replace "custom": "Custom step" (inside "semantic") with:
"custom": "Custom step", "committed": "Committed (approved; nothing posts)"In ms/workflowEditor.json replace "custom": "Langkah tersuai" with:
"custom": "Langkah tersuai", "committed": "Komited (diluluskan; tiada pengeposan)"In zh/workflowEditor.json replace "custom": "自定义步骤" with:
"custom": "自定义步骤", "committed": "已承诺(已批准,不记账)"- Step 12: Run the web checks
Run: pnpm -C web typecheck && pnpm -C web test -- semanticTone i18n schema
Expected: PASS (typecheck fails first if semanticTone’s Record<WorkflowSemantic, TagTone> lacks committed — that is the type-level drift guard).
- Step 13: Commit
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts internal/platform/posting \ internal/platform/workflow db/migrations/00700_committed_semantic.sql internal/kernel/db/migrationranges_test.go \ api/openapi.yaml internal/gen/api web/srcgit commit -m "Phase 4 O1: the committed semantic (approved to act on; nothing posts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: The chain’s schema (00701)
Section titled “Task 2: The chain’s schema (00701)”Files:
- Create:
db/migrations/00701_trade_document.sql - Modify:
internal/kernel/db/retention_test.go(amendable) - Test: the existing drift tests in
internal/kernel/db(TestOnlyOperationalTablesGrantDeleteToTheAppRole,TestEveryTableIsTenantIsolated,TestEveryConstraintIsNamed,TestEveryFunctionPinsItsSearchPath). The trigger’s behaviour is proven over the Go service in Task 6.
Interfaces:
-
Consumes:
workflow_instance,workflow_state,party_supplier,party_customer,tax_code,organization_member. -
Produces: tables
trade_document,trade_line; SQL functionstrade_semantic(p_org UUID, p_instance UUID) RETURNS TEXT,trade_open_quantity(p_org UUID, p_line UUID) RETURNS NUMERIC; constraint names the Go layer maps:trade_document_is_draft,trade_line_is_draft,trade_line_fixed,trade_line_has_children,trade_line_allowed_pair,trade_line_source_actionable,trade_line_source_open,trade_line_same_party,trade_line_same_item,trade_line_open_quantity,trade_line_source_fk.trade_line.uom_id,item_idandtrade_document.location_idget their foreign keys in Task 3 (00711), because their tables are created in the item-master range. -
Step 1: Write the migration
Create db/migrations/00701_trade_document.sql:
-- +goose Up
-- PHASE 4 O1.1 (docs/superpowers/specs/2026-09-30-operations-design.md): the-- document chain. One header/line pair for every chain kind (OD-5), linked-- line to line by source_line_id. The workflow state lives on the instance;-- the header's terms and the lines change only while it is a draft.CREATE TABLE trade_document ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), kind TEXT NOT NULL CONSTRAINT trade_document_kind_known CHECK (kind IN ( 'purchase_requisition', 'purchase_order', 'goods_receipt', 'purchase_return', 'quotation', 'sales_order', 'delivery_order', 'sales_return', 'stock_adjustment')), number TEXT NOT NULL, supplier_party_id UUID, customer_party_id UUID, currency TEXT NOT NULL CONSTRAINT trade_document_currency_code CHECK (currency ~ '^[A-Z]{3}$'), fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT trade_document_fx_rate_positive CHECK (fx_rate > 0), document_date DATE NOT NULL, due_date DATE, -- Its foreign key is added by 00711, once location exists. location_id UUID, net_total NUMERIC(19,4) NOT NULL DEFAULT 0, net_base NUMERIC(19,4) NOT NULL DEFAULT 0, -- The manual close only (Ruling O1-5); fulfilment is derived. closed_at TIMESTAMPTZ, close_reason TEXT, closed_by UUID, instance_id UUID NOT NULL, prepared_by UUID NOT NULL, revision INT NOT NULL DEFAULT 1 CONSTRAINT trade_document_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT trade_document_tenant_id UNIQUE (org_id, id), -- A line names its document's kind through this key, so the chain guard -- compares kinds without a join and a line cannot lie about its kind. CONSTRAINT trade_document_tenant_kind UNIQUE (org_id, id, kind), CONSTRAINT trade_document_number_once UNIQUE (org_id, kind, number), CONSTRAINT trade_document_instance_once UNIQUE (org_id, instance_id), CONSTRAINT trade_document_party_by_kind CHECK (CASE WHEN kind IN ('purchase_order', 'goods_receipt', 'purchase_return') THEN supplier_party_id IS NOT NULL AND customer_party_id IS NULL WHEN kind IN ('quotation', 'sales_order', 'delivery_order', 'sales_return') THEN customer_party_id IS NOT NULL AND supplier_party_id IS NULL ELSE supplier_party_id IS NULL AND customer_party_id IS NULL END), CONSTRAINT trade_document_location_by_kind CHECK ( (kind IN ('goods_receipt', 'purchase_return', 'delivery_order', 'sales_return', 'stock_adjustment')) = (location_id IS NOT NULL)), CONSTRAINT trade_document_due_after_date CHECK (due_date IS NULL OR due_date >= document_date), CONSTRAINT trade_document_close_shape CHECK ((closed_at IS NULL) = (close_reason IS NULL) AND (closed_at IS NULL) = (closed_by IS NULL)), CONSTRAINT trade_document_close_reason_present CHECK (close_reason IS NULL OR (btrim(close_reason) <> '' AND length(close_reason) <= 500)), CONSTRAINT trade_document_text_language CHECK (coalesce(close_reason, '') !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT trade_document_supplier_fk FOREIGN KEY (org_id, supplier_party_id) REFERENCES party_supplier (org_id, party_id), CONSTRAINT trade_document_customer_fk FOREIGN KEY (org_id, customer_party_id) REFERENCES party_customer (org_id, party_id), CONSTRAINT trade_document_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT trade_document_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id), CONSTRAINT trade_document_closed_by_fk FOREIGN KEY (org_id, closed_by) REFERENCES organization_member (org_id, id));CREATE INDEX trade_document_by_created ON trade_document (org_id, kind, created_at DESC, id DESC);
CREATE TABLE trade_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), document_id UUID NOT NULL, document_kind TEXT NOT NULL, line_no INT NOT NULL CONSTRAINT trade_line_no_range CHECK (line_no BETWEEN 1 AND 200), -- NULL: a text line (spec O1.1). Foreign keys for item_id and uom_id: 00711. item_id UUID, description TEXT NOT NULL CONSTRAINT trade_line_description_present CHECK (btrim(description) <> '' AND length(description) <= 500), quantity NUMERIC(19,6) NOT NULL, uom_id UUID NOT NULL, -- Base units in one of uom_id, frozen with the line (Ruling O1-8). uom_factor NUMERIC(19,6) NOT NULL DEFAULT 1 CONSTRAINT trade_line_uom_factor_positive CHECK (uom_factor > 0), quantity_base NUMERIC(19,6) GENERATED ALWAYS AS (round(quantity * uom_factor, 6)) STORED, unit_price NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT trade_line_unit_price_nonnegative CHECK (unit_price >= 0), net_amount NUMERIC(19,4) NOT NULL DEFAULT 0, tax_code_id UUID, shares JSONB NOT NULL DEFAULT '[]'::jsonb CONSTRAINT trade_line_shares_array CHECK (jsonb_typeof(shares) = 'array'), source_line_id UUID, withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT trade_line_tenant_id UNIQUE (org_id, id), -- Only a stock adjustment's line may be negative (spec O2.3: direction by sign). CONSTRAINT trade_line_quantity_sign CHECK (quantity > 0 OR (document_kind = 'stock_adjustment' AND quantity <> 0)), CONSTRAINT trade_line_description_language CHECK (description !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT trade_line_not_own_source CHECK (source_line_id IS DISTINCT FROM id), CONSTRAINT trade_line_document_fk FOREIGN KEY (org_id, document_id, document_kind) REFERENCES trade_document (org_id, id, kind), -- Same organization by construction: the key carries org_id (spec O1.1). CONSTRAINT trade_line_source_fk FOREIGN KEY (org_id, source_line_id) REFERENCES trade_line (org_id, id), CONSTRAINT trade_line_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id));CREATE UNIQUE INDEX trade_line_no_once ON trade_line (org_id, document_id, line_no) WHERE withdrawn_at IS NULL;CREATE INDEX trade_line_by_source ON trade_line (org_id, source_line_id) WHERE source_line_id IS NOT NULL;
ALTER TABLE trade_document ENABLE ROW LEVEL SECURITY;ALTER TABLE trade_document FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON trade_document USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE trade_line ENABLE ROW LEVEL SECURITY;ALTER TABLE trade_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON trade_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON trade_document, trade_line FROM erp_app;
-- The semantic of an instance's current state.-- +goose StatementBeginCREATE FUNCTION trade_semantic(p_org UUID, p_instance UUID) RETURNS TEXT LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT s.semantic FROM public.workflow_instance i JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE i.org_id = p_org AND i.id = p_instance$$;-- +goose StatementEnd
-- Open quantity (spec O1.1): the line's base quantity less every live-- child's (Ruling O1-4: not withdrawn, document not cancelled). NULL for no-- such line. The one definition the guard, the Go service and O2/O3 share.-- +goose StatementBeginCREATE FUNCTION trade_open_quantity(p_org UUID, p_line UUID) RETURNS NUMERIC LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT l.quantity_base - coalesce(( SELECT sum(c.quantity_base) FROM public.trade_line c JOIN public.trade_document d ON d.org_id = c.org_id AND d.id = c.document_id WHERE c.org_id = p_org AND c.source_line_id = p_line AND c.withdrawn_at IS NULL AND public.trade_semantic(d.org_id, d.instance_id) IS DISTINCT FROM 'cancelled'), 0) FROM public.trade_line l WHERE l.org_id = p_org AND l.id = p_line$$;-- +goose StatementEnd
-- +goose StatementBeginCREATE FUNCTION trade_document_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.trade_semantic(NEW.org_id, NEW.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'trade document % is not a draft', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'trade_document_is_draft'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER trade_document_draft_guard BEFORE UPDATE OF supplier_party_id, customer_party_id, currency, fx_rate, document_date, due_date, location_id, net_total, net_base ON trade_document FOR EACH ROW EXECUTE FUNCTION trade_document_draft_guard();
-- The chain guard (spec O1.1, Rulings O1-4, O1-6, O1-7). An UPDATE may only-- withdraw a draft's line that nothing draws from. An INSERT goes into a-- draft; with a source it must be an allowed pair, from an approved, open-- document of the same party, carrying the source's item, within the-- source's open quantity. The source line is locked first, so two children-- of one line serialize; each later statement takes a fresh snapshot and-- sees the first child once it commits.-- +goose StatementBeginCREATE FUNCTION trade_line_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE tgt_doc public.trade_document; src public.trade_line; src_doc public.trade_document;BEGIN SELECT * INTO tgt_doc FROM public.trade_document WHERE org_id = NEW.org_id AND id = NEW.document_id;
IF TG_OP = 'UPDATE' THEN IF ROW(NEW.document_id, NEW.document_kind, NEW.line_no, NEW.item_id, NEW.description, NEW.quantity, NEW.uom_id, NEW.uom_factor, NEW.unit_price, NEW.net_amount, NEW.tax_code_id, NEW.shares, NEW.source_line_id, NEW.created_at) IS DISTINCT FROM ROW(OLD.document_id, OLD.document_kind, OLD.line_no, OLD.item_id, OLD.description, OLD.quantity, OLD.uom_id, OLD.uom_factor, OLD.unit_price, OLD.net_amount, OLD.tax_code_id, OLD.shares, OLD.source_line_id, OLD.created_at) OR OLD.withdrawn_at IS NOT NULL THEN RAISE EXCEPTION 'trade line %: only its withdrawal may change, once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_fixed'; END IF; IF NEW.withdrawn_at IS NULL THEN RETURN NEW; END IF; IF public.trade_semantic(tgt_doc.org_id, tgt_doc.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'trade document % is not a draft: its lines cannot change', NEW.document_id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_is_draft'; END IF; IF EXISTS (SELECT 1 FROM public.trade_line c JOIN public.trade_document d ON d.org_id = c.org_id AND d.id = c.document_id WHERE c.org_id = OLD.org_id AND c.source_line_id = OLD.id AND c.withdrawn_at IS NULL AND public.trade_semantic(d.org_id, d.instance_id) IS DISTINCT FROM 'cancelled') THEN RAISE EXCEPTION 'trade line % has live children', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_has_children'; END IF; RETURN NEW; END IF;
IF public.trade_semantic(tgt_doc.org_id, tgt_doc.instance_id) IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'trade document % is not a draft: its lines cannot change', NEW.document_id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_is_draft'; END IF; IF NEW.source_line_id IS NULL THEN RETURN NEW; END IF;
SELECT * INTO src FROM public.trade_line WHERE org_id = NEW.org_id AND id = NEW.source_line_id FOR UPDATE; IF NOT FOUND THEN RETURN NEW; -- trade_line_source_fk refuses it END IF; SELECT * INTO src_doc FROM public.trade_document WHERE org_id = src.org_id AND id = src.document_id;
IF (src.document_kind, NEW.document_kind) NOT IN ( ('purchase_requisition', 'purchase_order'), ('purchase_order', 'goods_receipt'), ('goods_receipt', 'purchase_return'), ('quotation', 'sales_order'), ('sales_order', 'delivery_order'), ('delivery_order', 'sales_return')) THEN RAISE EXCEPTION 'a % line cannot draw from a % line', NEW.document_kind, src.document_kind USING ERRCODE = '23514', CONSTRAINT = 'trade_line_allowed_pair'; END IF; IF src.withdrawn_at IS NOT NULL OR public.trade_semantic(src_doc.org_id, src_doc.instance_id) IN ('draft', 'pending', 'rejected', 'cancelled', 'dispute') THEN RAISE EXCEPTION 'source line % is withdrawn or its document is not approved', src.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_source_actionable'; END IF; IF src_doc.closed_at IS NOT NULL THEN RAISE EXCEPTION 'source document % is closed', src_doc.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_source_open'; END IF; IF coalesce(src_doc.supplier_party_id, src_doc.customer_party_id) IS NOT NULL AND coalesce(src_doc.supplier_party_id, src_doc.customer_party_id) IS DISTINCT FROM coalesce(tgt_doc.supplier_party_id, tgt_doc.customer_party_id) THEN RAISE EXCEPTION 'source document % is for another party', src_doc.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_same_party'; END IF; IF NEW.item_id IS DISTINCT FROM src.item_id THEN RAISE EXCEPTION 'a line carries its source line''s item' USING ERRCODE = '23514', CONSTRAINT = 'trade_line_same_item'; END IF; IF public.trade_open_quantity(src.org_id, src.id) - round(NEW.quantity * NEW.uom_factor, 6) < 0 THEN RAISE EXCEPTION 'source line % has less open quantity than this line takes', src.id USING ERRCODE = '23514', CONSTRAINT = 'trade_line_open_quantity'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER trade_line_guard BEFORE INSERT OR UPDATE ON trade_line FOR EACH ROW EXECUTE FUNCTION trade_line_guard();
-- +goose DownDROP TABLE trade_line;DROP TABLE trade_document;DROP FUNCTION trade_line_guard();DROP FUNCTION trade_document_draft_guard();DROP FUNCTION trade_open_quantity(UUID, UUID);DROP FUNCTION trade_semantic(UUID, UUID);- Step 2: Run the retention drift test to watch it fail
Run: go test ./internal/kernel/db/ -run TestOnlyOperationalTablesGrantDeleteToTheAppRole -count=1
Expected: FAIL — trade_document and trade_line are named by no list.
- Step 3: Classify the tables
In internal/kernel/db/retention_test.go, in amendable, add after the "sales_invoice_line", entry:
// Phase 4 O1.1: the document chain. A document is cancelled through // its workflow, never deleted; a draft's replaced line is withdrawn // (Ruling O1-7). "trade_document", "trade_line",- Step 4: Run the schema drift tests
Run: go test ./internal/kernel/db/ -run 'TestOnlyOperationalTablesGrantDeleteToTheAppRole|TestEveryTableIsTenantIsolated|TestEveryConstraintIsNamed|TestEveryFunctionPinsItsSearchPath|TestEverySecurityDefinerIsAllowlistedAndNotPublic' -count=1
Expected: PASS.
- Step 5: Commit
git add db/migrations/00701_trade_document.sql internal/kernel/db/retention_test.gogit commit -m "Phase 4 O1: trade_document and trade_line with the chain guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: The item master’s schema (00710, 00711)
Section titled “Task 3: The item master’s schema (00710, 00711)”Files:
- Create:
db/migrations/00710_item_master.sql,db/migrations/00711_trade_item_links.sql - Create:
internal/platform/inventory/doc.go,internal/platform/inventory/schema_test.go - Modify:
db/org_id_nullable.txt,internal/kernel/db/retention_test.go
Interfaces:
-
Consumes:
trade_line,trade_document(Task 2). -
Produces: tables
uom(registry, ten global seeds with en/ms/zh names),item_category(registry),item,item_uom,location; foreign keystrade_line_item_fk,trade_line_uom_fk,trade_document_location_fk; triggeritem_fixed_once_usedraising constraintitem_fixed. Packageinventory’s constants, sentinel errors,FieldError,RevisionMismatchError,Names,orgOf. -
Step 1: Write the failing seed test
Create internal/platform/inventory/doc.go:
// Package inventory is the item master (Phase 4 O1.2,// docs/superpowers/specs/2026-09-30-operations-design.md): items, their units// of measure, categories and locations. O2 adds the stock ledger here.//// It never imports platform/trade. The chain reads items and units from// here, and the posting that turns a trade line into a stock move lives on// trade's side (O2/O3), calling this package.package inventory
import ( "context" "errors" "fmt"
"github.com/google/uuid"
"github.com/mcom/erp/internal/kernel/tenancy")
// Item types and tracking (spec O1.2).const ( TypeStock = "stock" TypeService = "service" TypeNonStock = "non_stock"
TrackingNone = "none" TrackingSerial = "serial" TrackingBatch = "batch")
// Entity kinds: an audit row's and a translation row's entity_kind.const ( EntityItem = "item" EntityUOM = "uom" EntityCategory = "item_category" EntityLocation = "location")
var ( ErrNotFound = errors.New("inventory: no such record") ErrDuplicateCode = errors.New("inventory: another record already has this code") ErrItemInUse = errors.New("inventory: a document already names this item, so its type, base unit and tracking are fixed") ErrUOMNotForItem = errors.New("inventory: this unit is not one of the item's units"))
// FieldError is one refusal about one input field, in the wire shape// `field=<name> rule=<code>: <sentence>` every module uses.type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail)}
func fieldErr(field, rule, detail string) error { return &FieldError{Field: field, Rule: rule, Detail: detail}}
// RevisionMismatchError: the record changed since the caller read it.type RevisionMismatchError struct{ Current int }
func (e *RevisionMismatchError) Error() string { return fmt.Sprintf("inventory: the record is at revision %d", e.Current)}
// Names are a translated text by locale (en, ms, zh).type Names map[string]string
func orgOf(ctx context.Context) (uuid.UUID, error) { id, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("inventory: %w", tenancy.ErrNoOrg) } return id, nil}Create internal/platform/inventory/schema_test.go:
package inventory_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
// Ruling O1-8: ten UN/ECE units are seeded as global registry rows, each// named in all three locales, so a new organization can price a line on// day one.func TestTheGlobalUnitsAreSeededInThreeLocales(t *testing.T) { h := testdb.Start(t) ctx := context.Background() var units int require.NoError(t, h.Admin.QueryRow(ctx, `SELECT count(*) FROM uom WHERE org_id IS NULL`).Scan(&units)) require.Equal(t, 10, units)
rows, err := h.Admin.Query(ctx, ` SELECT t.locale, t.value FROM uom u JOIN translation t ON t.entity_kind = 'uom' AND t.entity_id = u.id AND t.field = 'name' AND t.org_id IS NULL WHERE u.org_id IS NULL AND u.key = 'C62'`) require.NoError(t, err) defer rows.Close() names := map[string]string{} for rows.Next() { var loc, v string require.NoError(t, rows.Scan(&loc, &v)) names[loc] = v } require.NoError(t, rows.Err()) require.Equal(t, map[string]string{"en": "Unit", "ms": "Unit", "zh": "个"}, names)}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/inventory/ -run TestTheGlobalUnitsAreSeededInThreeLocales -count=1
Expected: FAIL — relation "uom" does not exist.
- Step 3: Write the item-master migration
Create db/migrations/00710_item_master.sql:
-- +goose Up
-- PHASE 4 O1.2 (docs/superpowers/specs/2026-09-30-operations-design.md): the-- item master. Names and descriptions are translation rows (en/ms/zh); every-- row is retired (is_active), never deleted.
-- Units of measure: the registry shape of 00005. A key is a UN/ECE-- Recommendation 20 code (Ruling O1-8), the shape sales_invoice_line.unit_code-- takes. org_id NULL is a seeded global (db/org_id_nullable.txt).CREATE TABLE uom ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID REFERENCES organization (id), key TEXT NOT NULL CONSTRAINT uom_key_shape CHECK (key ~ '^[A-Z0-9]{2,3}$'), is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ);CREATE UNIQUE INDEX uom_global_key ON uom (key) WHERE org_id IS NULL AND deleted_at IS NULL;CREATE UNIQUE INDEX uom_org_key ON uom (org_id, key) WHERE org_id IS NOT NULL AND deleted_at IS NULL;ALTER TABLE uom ENABLE ROW LEVEL SECURITY;ALTER TABLE uom FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON uom USING (org_id IS NULL OR org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Item categories (spec O1.2: platform/registry). No seeds.CREATE TABLE item_category ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID REFERENCES organization (id), key TEXT NOT NULL CONSTRAINT item_category_key_shape CHECK (key ~ '^[a-z][a-z0-9_]{0,39}$'), is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ);CREATE UNIQUE INDEX item_category_global_key ON item_category (key) WHERE org_id IS NULL AND deleted_at IS NULL;CREATE UNIQUE INDEX item_category_org_key ON item_category (org_id, key) WHERE org_id IS NOT NULL AND deleted_at IS NULL;ALTER TABLE item_category ENABLE ROW LEVEL SECURITY;ALTER TABLE item_category FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON item_category USING (org_id IS NULL OR org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE TABLE item ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT item_code_shape CHECK (code ~ '^[A-Z0-9][A-Z0-9._/-]{0,39}$'), type TEXT NOT NULL CONSTRAINT item_type_known CHECK (type IN ('stock', 'service', 'non_stock')), base_uom_id UUID NOT NULL REFERENCES uom (id), category_id UUID REFERENCES item_category (id), tax_code_id UUID, inventory_account_id UUID, cogs_account_id UUID, sales_account_id UUID, purchase_account_id UUID, tracking TEXT NOT NULL DEFAULT 'none' CONSTRAINT item_tracking_known CHECK (tracking IN ('none', 'serial', 'batch')), is_active BOOLEAN NOT NULL DEFAULT true, custom_fields JSONB NOT NULL DEFAULT '{}' CONSTRAINT item_custom_fields_object CHECK (jsonb_typeof(custom_fields) = 'object'), revision INT NOT NULL DEFAULT 1 CONSTRAINT item_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT item_tenant_id UNIQUE (org_id, id), CONSTRAINT item_code_once UNIQUE (org_id, code), -- Ruling O1-9: a stock item names its inventory and COGS accounts; a -- service or non-stock item names neither. CONSTRAINT item_accounts_by_type CHECK (CASE WHEN type = 'stock' THEN inventory_account_id IS NOT NULL AND cogs_account_id IS NOT NULL ELSE inventory_account_id IS NULL AND cogs_account_id IS NULL END), CONSTRAINT item_tracking_stock_only CHECK (type = 'stock' OR tracking = 'none'), CONSTRAINT item_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id), CONSTRAINT item_inventory_account_fk FOREIGN KEY (org_id, inventory_account_id) REFERENCES account (org_id, id), CONSTRAINT item_cogs_account_fk FOREIGN KEY (org_id, cogs_account_id) REFERENCES account (org_id, id), CONSTRAINT item_sales_account_fk FOREIGN KEY (org_id, sales_account_id) REFERENCES account (org_id, id), CONSTRAINT item_purchase_account_fk FOREIGN KEY (org_id, purchase_account_id) REFERENCES account (org_id, id));CREATE INDEX item_custom_fields ON item USING gin (custom_fields jsonb_path_ops);
-- An alternate unit and its factor to base (spec O1.2). The base unit is not-- a row here: its factor is 1 by definition.CREATE TABLE item_uom ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), item_id UUID NOT NULL, uom_id UUID NOT NULL REFERENCES uom (id), factor NUMERIC(19,6) NOT NULL CONSTRAINT item_uom_factor_positive CHECK (factor > 0), is_active BOOLEAN NOT NULL DEFAULT true, CONSTRAINT item_uom_once UNIQUE (org_id, item_id, uom_id), CONSTRAINT item_uom_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id));
-- Warehouses (spec O1.2: no bins).CREATE TABLE location ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT location_code_shape CHECK (code ~ '^[A-Z0-9][A-Z0-9._/-]{0,19}$'), is_active BOOLEAN NOT NULL DEFAULT true, revision INT NOT NULL DEFAULT 1 CONSTRAINT location_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT location_tenant_id UNIQUE (org_id, id), CONSTRAINT location_code_once UNIQUE (org_id, code));
ALTER TABLE item ENABLE ROW LEVEL SECURITY;ALTER TABLE item FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON item USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE item_uom ENABLE ROW LEVEL SECURITY;ALTER TABLE item_uom FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON item_uom USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE location ENABLE ROW LEVEL SECURITY;ALTER TABLE location FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON location USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON uom, item_category, item, item_uom, location FROM erp_app;
-- The global units (Ruling O1-8), named in en/ms/zh. Runs as the migration-- role, which bypasses RLS (00005's note on seeding globals).WITH u AS ( INSERT INTO uom (key, sort_order) VALUES ('C62', 1), ('KGM', 2), ('GRM', 3), ('LTR', 4), ('MTR', 5), ('BX', 6), ('PK', 7), ('SET', 8), ('HUR', 9), ('DAY', 10) RETURNING id, key)INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value)SELECT NULL, 'uom', u.id, 'name', n.locale, n.value FROM u JOIN (VALUES ('C62', 'en', 'Unit'), ('C62', 'ms', 'Unit'), ('C62', 'zh', '个'), ('KGM', 'en', 'Kilogram'), ('KGM', 'ms', 'Kilogram'), ('KGM', 'zh', '千克'), ('GRM', 'en', 'Gram'), ('GRM', 'ms', 'Gram'), ('GRM', 'zh', '克'), ('LTR', 'en', 'Litre'), ('LTR', 'ms', 'Liter'), ('LTR', 'zh', '升'), ('MTR', 'en', 'Metre'), ('MTR', 'ms', 'Meter'), ('MTR', 'zh', '米'), ('BX', 'en', 'Box'), ('BX', 'ms', 'Kotak'), ('BX', 'zh', '盒'), ('PK', 'en', 'Pack'), ('PK', 'ms', 'Pek'), ('PK', 'zh', '包'), ('SET', 'en', 'Set'), ('SET', 'ms', 'Set'), ('SET', 'zh', '套'), ('HUR', 'en', 'Hour'), ('HUR', 'ms', 'Jam'), ('HUR', 'zh', '小时'), ('DAY', 'en', 'Day'), ('DAY', 'ms', 'Hari'), ('DAY', 'zh', '天') ) AS n (key, locale, value) ON n.key = u.key;
-- +goose DownDELETE FROM translation WHERE entity_kind IN ('uom', 'item_category', 'item', 'location');DROP TABLE location;DROP TABLE item_uom;DROP TABLE item;DROP TABLE item_category;DROP TABLE uom;- Step 4: Write the links migration
Create db/migrations/00711_trade_item_links.sql:
-- +goose Up-- Phase 4 O1: the chain's references into the item master, which 00701-- could not name (its tables are created by 00710).ALTER TABLE trade_line ADD CONSTRAINT trade_line_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id), ADD CONSTRAINT trade_line_uom_fk FOREIGN KEY (uom_id) REFERENCES uom (id);ALTER TABLE trade_document ADD CONSTRAINT trade_document_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id);CREATE INDEX trade_line_by_item ON trade_line (org_id, item_id) WHERE item_id IS NOT NULL;
-- Ruling O1-9: once a document names an item, its type, base unit and-- tracking are what that document's quantities mean. O2 replaces this-- function to look at stock_move as well.-- +goose StatementBeginCREATE FUNCTION item_fixed_once_used() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF EXISTS (SELECT 1 FROM public.trade_line WHERE org_id = NEW.org_id AND item_id = NEW.id) THEN RAISE EXCEPTION 'item %: a document names it, so its type, base unit and tracking are fixed', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'item_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER item_fixed_once_used BEFORE UPDATE OF type, base_uom_id, tracking ON item FOR EACH ROW WHEN (NEW.type IS DISTINCT FROM OLD.type OR NEW.base_uom_id IS DISTINCT FROM OLD.base_uom_id OR NEW.tracking IS DISTINCT FROM OLD.tracking) EXECUTE FUNCTION item_fixed_once_used();
-- +goose DownDROP TRIGGER item_fixed_once_used ON item;DROP FUNCTION item_fixed_once_used();DROP INDEX trade_line_by_item;ALTER TABLE trade_document DROP CONSTRAINT trade_document_location_fk;ALTER TABLE trade_line DROP CONSTRAINT trade_line_uom_fk, DROP CONSTRAINT trade_line_item_fk;- Step 5: Run the seed test
Run: go test ./internal/platform/inventory/ -run TestTheGlobalUnitsAreSeededInThreeLocales -count=1
Expected: PASS.
- Step 6: Run the tenancy drift test to watch it fail
Run: go test ./internal/kernel/db/ -run 'TestEveryTableIsTenantIsolated|TestOnlyOperationalTablesGrantDeleteToTheAppRole' -count=1
Expected: FAIL — uom and item_category have a nullable org_id not declared in db/org_id_nullable.txt; the five tables are named by no retention list.
- Step 7: Declare the registries and classify the tables
Append to db/org_id_nullable.txt:
# uom and item_category (Phase 4 O1.2, 00710): registries of the# expense_category shape. A NULL org_id is a seeded global unit (C62, KGM,# ...) that every organization prices lines in; an organization adds its own# units and categories with its org_id set.uomitem_categoryIn internal/kernel/db/retention_test.go, in amendable, after the "trade_line", entry from Task 2:
// Phase 4 O1.2: the item master. Units, categories, items, their // alternate units and locations are retired (is_active), never // deleted: a posted line names them (Ruling O1-9). "uom", "item_category", "item", "item_uom", "location",- Step 8: Run the schema drift tests
Run: go test ./internal/kernel/db/ -run 'TestEveryTableIsTenantIsolated|TestOnlyOperationalTablesGrantDeleteToTheAppRole|TestEveryConstraintIsNamed|TestEveryFunctionPinsItsSearchPath|TestExemptionFilesNameRealTables|TestEveryMigrationIsNumberedInItsOwnersRange' -count=1
Expected: PASS.
- Step 9: Commit
git add db/migrations/00710_item_master.sql db/migrations/00711_trade_item_links.sql db/org_id_nullable.txt \ internal/kernel/db/retention_test.go internal/platform/inventorygit commit -m "Phase 4 O1: item master schema (units, categories, items, locations)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: The item master service (platform/inventory), with items as a custom-field entity (00712)
Section titled “Task 4: The item master service (platform/inventory), with items as a custom-field entity (00712)”Files:
- Create:
internal/platform/inventory/names.go,uom.go,category.go,item.go,location.go - Create:
db/migrations/00712_item_custom_fields.sql - Modify:
internal/platform/customfield/customfield.go(EntityItem,Entities) - Test:
internal/platform/inventory/item_test.go,internal/platform/inventory/lookup_test.go,internal/platform/inventory/location_test.go
Interfaces:
-
Consumes: Task 3’s schema and
doc.go;chart.GetAccount,chart.GetTaxCode,customfield.Resolve,registry.VisibleSQL,audit.Write. -
Produces: every
inventoryfunction and type in “Interfaces O2 and O3 consume”;customfield.EntityItem = "item". -
Step 1: Write the failing item tests
Create internal/platform/inventory/item_test.go:
package inventory_test
import ( "context" "errors" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/customfield" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory")
func uom(t *testing.T, e *expensestest.Env, key string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM uom WHERE org_id IS NULL AND key = $1`, key).Scan(&id)) return id}
func account(t *testing.T, e *expensestest.Env, code string) *uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.GetAccountByCode(ctx, tx, code) id = a.ID return err }) return &id}
// stock is a valid stock item: base C62, inventory 1560 (an asset with no// control kind), cost of sales 5100.func stock(t *testing.T, e *expensestest.Env, code string) inventory.ItemInput { t.Helper() return inventory.ItemInput{Code: code, Type: inventory.TypeStock, Names: inventory.Names{"en": "Copier paper A4", "ms": "Kertas fotostat A4", "zh": "A4 复印纸"}, BaseUOMID: uom(t, e, "C62"), InventoryAccountID: account(t, e, "1560"), COGSAccountID: account(t, e, "5100"), IsActive: true}}
func createItem(e *expensestest.Env, in inventory.ItemInput) (inventory.Item, error) { var it inventory.Item err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error it, err = inventory.CreateItem(ctx, tx, e.Clock, in) return err }) return it, err}
func fieldOf(err error) string { var fe *inventory.FieldError if errors.As(err, &fe) { return fe.Field + "/" + fe.Rule } return ""}
// Spec O1.2: names in three locales, a base unit and alternate units with a// factor to base, read back as they were written.func TestAnItemRoundTripsWithItsNamesAndUnits(t *testing.T) { e := expensestest.New(t) in := stock(t, e, "paper-a4") in.UOMs = []inventory.ItemUOMInput{{UOMID: uom(t, e, "BX"), Factor: money.MustParseRate("12")}} created, err := createItem(e, in) require.NoError(t, err) require.Equal(t, "PAPER-A4", created.Code, "codes are stored in capitals")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { it, err := inventory.GetItem(ctx, tx, created.ID) require.NoError(t, err) require.Equal(t, in.Names, it.Names) require.Len(t, it.UOMs, 1)
f, err := inventory.UOMFactor(ctx, tx, it.ID, uom(t, e, "BX")) require.NoError(t, err) require.Zero(t, money.MustParseRate("12").Cmp(f)) f, err = inventory.UOMFactor(ctx, tx, it.ID, uom(t, e, "C62")) require.NoError(t, err) require.Zero(t, money.OneRate().Cmp(f), "the base unit's factor is 1") _, err = inventory.UOMFactor(ctx, tx, it.ID, uom(t, e, "KGM")) require.ErrorIs(t, err, inventory.ErrUOMNotForItem) return nil })}
// Ruling O1-9: a stock item names its inventory and cost accounts; nothing// else does; only a stock item is tracked.func TestOnlyAStockItemNamesInventoryAccountsAndIsTracked(t *testing.T) { e := expensestest.New(t) _, err := createItem(e, stock(t, e, "PAPER-A4")) require.NoError(t, err, "PRESENCE: a complete stock item is accepted")
in := stock(t, e, "PAPER-A3") in.InventoryAccountID = nil _, err = createItem(e, in) require.Equal(t, "inventory_account_id/required", fieldOf(err))
in = stock(t, e, "INSTALL") in.Type = inventory.TypeService _, err = createItem(e, in) require.Equal(t, "inventory_account_id/stock_only", fieldOf(err))
in.InventoryAccountID, in.COGSAccountID = nil, nil in.Tracking = inventory.TrackingSerial _, err = createItem(e, in) require.Equal(t, "tracking/tracking_stock_only", fieldOf(err))
in.Tracking = "" in.SalesAccountID = account(t, e, "5100") _, err = createItem(e, in) require.Equal(t, "sales_account_id/account_usable", fieldOf(err), "a sales account is income")}
func TestItemCodesAreUniqueWithinAnOrganization(t *testing.T) { e := expensestest.New(t) _, err := createItem(e, stock(t, e, "PAPER-A4")) require.NoError(t, err) _, err = createItem(e, stock(t, e, "paper-a4")) require.ErrorIs(t, err, inventory.ErrDuplicateCode)
f := e.Another(t) _, err = createItem(f, stock(t, f, "PAPER-A4")) require.NoError(t, err, "another organization's code is its own")}
func TestAnUpdateReplacesTheUnitsAndChecksTheRevision(t *testing.T) { e := expensestest.New(t) in := stock(t, e, "PAPER-A4") in.UOMs = []inventory.ItemUOMInput{{UOMID: uom(t, e, "BX"), Factor: money.MustParseRate("12")}} it, err := createItem(e, in) require.NoError(t, err)
in.UOMs = []inventory.ItemUOMInput{{UOMID: uom(t, e, "PK"), Factor: money.MustParseRate("500")}} in.Names = inventory.Names{"en": "Copier paper A4, 80gsm"} var after inventory.Item e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { after, err = inventory.UpdateItem(ctx, tx, e.Clock, it.ID, it.Revision, in) return err }) require.Equal(t, it.Revision+1, after.Revision) require.Equal(t, inventory.Names{"en": "Copier paper A4, 80gsm"}, after.Names, "the other locales are replaced too") active := map[uuid.UUID]bool{} for _, u := range after.UOMs { active[u.UOMID] = u.IsActive } require.Equal(t, map[uuid.UUID]bool{uom(t, e, "BX"): false, uom(t, e, "PK"): true}, active, "a dropped unit is retired, never deleted")
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.UpdateItem(ctx, tx, e.Clock, it.ID, it.Revision, in) return err }) var stale *inventory.RevisionMismatchError require.ErrorAs(t, err, &stale) require.Equal(t, after.Revision, stale.Current)}
// Spec O1.2: items join the C2 entities.func TestAnItemCarriesItsCustomFields(t *testing.T) { e := expensestest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := customfield.Create(ctx, tx, e.Clock, e.Owner, customfield.Input{Entity: customfield.EntityItem, Key: "brand", Type: customfield.TypeText, Label: customfield.Names{"en": "Brand"}, Required: true, Active: true}) return err }) _, err := createItem(e, stock(t, e, "PAPER-A4")) var invalid *customfield.InvalidError require.ErrorAs(t, err, &invalid, "a required item field is enforced on create")
in := stock(t, e, "PAPER-A4") in.CustomFields = map[string]string{"brand": "Double A"} it, err := createItem(e, in) require.NoError(t, err) require.Equal(t, map[string]string{"brand": "Double A"}, it.CustomFields)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { found, err := inventory.ListItems(ctx, tx, inventory.ItemFilter{Fields: map[string]string{"brand": "Double A"}}) require.Len(t, found, 1) return err })}Create internal/platform/inventory/lookup_test.go:
package inventory_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory")
func keys(us []inventory.UOM) map[string]inventory.UOM { out := map[string]inventory.UOM{} for _, u := range us { out[u.Key] = u } return out}
// Registry rule 1: every organization sees the seeded units; an// organization's own unit is seen by it alone.func TestAnOrganizationsOwnUnitIsItsAlone(t *testing.T) { e := expensestest.New(t) f := e.Another(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { u, err := inventory.CreateUOM(ctx, tx, e.Clock, "ctn", inventory.Names{"en": "Carton", "ms": "Karton"}) require.NoError(t, err) require.Equal(t, "CTN", u.Key) require.False(t, u.Global) return nil }) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { us, err := inventory.ListUOMs(ctx, tx) require.NoError(t, err) k := keys(us) require.True(t, k["C62"].Global, "PRESENCE: the seeded unit is listed") require.Equal(t, "个", k["C62"].Names["zh"]) require.Contains(t, k, "CTN") return nil }) f.Tx(t, func(ctx context.Context, tx pgx.Tx) error { us, err := inventory.ListUOMs(ctx, tx) require.NoError(t, err) require.Contains(t, keys(us), "C62", "PRESENCE: the other organization lists the globals") require.NotContains(t, keys(us), "CTN") return nil }) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.CreateUOM(ctx, tx, e.Clock, "CTN", inventory.Names{"en": "Carton"}) return err }) require.ErrorIs(t, err, inventory.ErrDuplicateCode)}
func TestACategoryIsRenamedAndRetired(t *testing.T) { e := expensestest.New(t) var k inventory.Category e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error k, err = inventory.CreateCategory(ctx, tx, e.Clock, "stationery", inventory.Names{"en": "Stationery"}) return err }) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { after, err := inventory.UpdateCategory(ctx, tx, e.Clock, k.ID, inventory.Names{"en": "Office stationery", "ms": "Alat tulis pejabat"}, false) require.NoError(t, err) require.False(t, after.IsActive) require.Equal(t, "Alat tulis pejabat", after.Names["ms"]) return nil }) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.CreateCategory(ctx, tx, e.Clock, "Stationery!", inventory.Names{"en": "x"}) return err }) require.Equal(t, "key/key_shape", fieldOf(err))}Create internal/platform/inventory/location_test.go:
package inventory_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory")
func TestALocationRoundTripsAndItsCodeIsUnique(t *testing.T) { e := expensestest.New(t) in := inventory.LocationInput{Code: "kl-main", Names: inventory.Names{"en": "Kuala Lumpur warehouse", "zh": "吉隆坡仓库"}, IsActive: true} var l inventory.Location e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error l, err = inventory.CreateLocation(ctx, tx, e.Clock, in) return err }) require.Equal(t, "KL-MAIN", l.Code) require.Equal(t, "吉隆坡仓库", l.Names["zh"])
in.IsActive = false e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { after, err := inventory.UpdateLocation(ctx, tx, e.Clock, l.ID, l.Revision, in) require.NoError(t, err) require.False(t, after.IsActive) all, err := inventory.ListLocations(ctx, tx, false) require.Len(t, all, 1, "PRESENCE: the retired location is still listed when asked for all") active, err := inventory.ListLocations(ctx, tx, true) require.Empty(t, active) return err })
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.CreateLocation(ctx, tx, e.Clock, in) return err }) require.ErrorIs(t, err, inventory.ErrDuplicateCode)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/ -count=1
Expected: FAIL to compile — undefined: inventory.CreateItem (and the other functions).
- Step 3: Make items a custom-field entity
In internal/platform/customfield/customfield.go, replace the entity constants and Entities with:
// The entities that take user-defined fields (Ruling U-1; Phase 4 O1.2 adds// items).const ( EntityCustomer = "customer" EntitySupplier = "supplier" EntitySalesDocument = "sales_document" EntityPurchaseDocument = "purchase_document" EntityItem = "item")
// Entities is every entity, in display order.var Entities = []string{EntityCustomer, EntitySupplier, EntitySalesDocument, EntityPurchaseDocument, EntityItem}Create db/migrations/00712_item_custom_fields.sql:
-- +goose Up-- Phase 4 O1.2: items join the C2 entities that take user-defined fields.-- The values live on item.custom_fields (00710).ALTER TABLE custom_field DROP CONSTRAINT custom_field_entity_known, ADD CONSTRAINT custom_field_entity_known CHECK (entity IN ( 'customer', 'supplier', 'sales_document', 'purchase_document', 'item'));
-- +goose Down-- Fails while an item field is defined (a field is retired, never deleted).ALTER TABLE custom_field DROP CONSTRAINT custom_field_entity_known, ADD CONSTRAINT custom_field_entity_known CHECK (entity IN ( 'customer', 'supplier', 'sales_document', 'purchase_document'));- Step 4: Write the names helpers
Create internal/platform/inventory/names.go:
package inventory
import ( "context" "errors" "fmt" "slices" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/locale")
// trimNames drops blank locales and trims the rest.func trimNames(n Names) Names { out := Names{} for loc, v := range n { if t := strings.TrimSpace(v); t != "" { out[loc] = t } } return out}
// checkNames: English when required, only supported locales, at most max// bytes each. Names may be in any of the three locales' scripts: they are// translation rows, not record text.func checkNames(field string, n Names, required bool, max int) error { if required && n["en"] == "" { return fieldErr(field, "name_en_required", "an English name is required") } for loc, v := range n { if !slices.Contains(locale.Supported, loc) { return fieldErr(field, "locale_supported", fmt.Sprintf("locale %q is not supported", loc)) } if len(v) > max { return fieldErr(field, "length", fmt.Sprintf("at most %d bytes", max)) } } return nil}
// writeNames replaces the organization's translation rows of one field of// one record: a locale left out is removed.func writeNames(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, id uuid.UUID, field string, n Names) error { if _, err := tx.Exec(ctx, `DELETE FROM translation WHERE org_id = $1 AND entity_kind = $2 AND entity_id = $3 AND field = $4`, org, kind, id, field); err != nil { return fmt.Errorf("inventory: %s %s: %w", kind, field, err) } for loc, v := range n { if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, $2, $3, $4, $5, $6)`, org, kind, id, field, loc, v); err != nil { return fmt.Errorf("inventory: %s %s: %w", kind, field, err) } } return nil}
// loadNames reads every locale of field for ids: the organization's own row,// else a seeded global's (RLS shows translation rows of both and no other).func loadNames(ctx context.Context, tx pgx.Tx, kind, field string, ids []uuid.UUID) (map[uuid.UUID]Names, error) { out := make(map[uuid.UUID]Names, len(ids)) for _, id := range ids { out[id] = Names{} } if len(ids) == 0 { return out, nil } rows, err := tx.Query(ctx, ` SELECT DISTINCT ON (entity_id, locale) entity_id, locale, value FROM translation WHERE entity_kind = $1 AND field = $2 AND entity_id = ANY($3) ORDER BY entity_id, locale, (org_id IS NOT NULL) DESC`, kind, field, ids) if err != nil { return nil, fmt.Errorf("inventory: %s %s: %w", kind, field, err) } defer rows.Close() for rows.Next() { var id uuid.UUID var loc, v string if err := rows.Scan(&id, &loc, &v); err != nil { return nil, err } out[id][loc] = v } return out, rows.Err()}
// mapWriteErr turns the constraints a write can trip into this package's// sentinels.func mapWriteErr(err error) error { var pg *pgconn.PgError if errors.As(err, &pg) { switch pg.ConstraintName { case "item_code_once", "location_code_once", "uom_org_key", "item_category_org_key": return ErrDuplicateCode case "item_fixed": return ErrItemInUse } } return fmt.Errorf("inventory: %w", err)}- Step 5: Write units and categories
Create internal/platform/inventory/uom.go:
package inventory
import ( "context" "errors" "fmt" "regexp" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/registry")
// UOM is one unit of measure (spec O1.2: a platform/registry lookup).type UOM struct { ID uuid.UUID Key string Names Names IsActive bool // Global: seeded for every organization (Ruling O1-8); not editable. Global bool}
var uomKey = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
func GetUOM(ctx context.Context, tx pgx.Tx, id uuid.UUID) (UOM, error) { var u UOM var owner *uuid.UUID err := tx.QueryRow(ctx, `SELECT id, org_id, key, is_active FROM uom WHERE id = $1 AND `+registry.VisibleSQL("uom"), id). Scan(&u.ID, &owner, &u.Key, &u.IsActive) if errors.Is(err, pgx.ErrNoRows) { return UOM{}, ErrNotFound } if err != nil { return UOM{}, fmt.Errorf("inventory: uom: %w", err) } u.Global = owner == nil names, err := loadNames(ctx, tx, EntityUOM, "name", []uuid.UUID{u.ID}) u.Names = names[u.ID] return u, err}
// ListUOMs is every unit the organization sees; its own unit shadows a// global of the same key (registry rule 1).func ListUOMs(ctx context.Context, tx pgx.Tx) ([]UOM, error) { rows, err := tx.Query(ctx, ` SELECT id, org_id, key, is_active FROM ( SELECT DISTINCT ON (key) id, org_id, key, is_active, sort_order FROM uom WHERE `+registry.VisibleSQL("uom")+` ORDER BY key, (org_id IS NOT NULL) DESC) u ORDER BY sort_order, key`) if err != nil { return nil, fmt.Errorf("inventory: uoms: %w", err) } defer rows.Close() var out []UOM var ids []uuid.UUID for rows.Next() { var u UOM var owner *uuid.UUID if err := rows.Scan(&u.ID, &owner, &u.Key, &u.IsActive); err != nil { return nil, err } u.Global = owner == nil out = append(out, u) ids = append(ids, u.ID) } if err := rows.Err(); err != nil { return nil, err } names, err := loadNames(ctx, tx, EntityUOM, "name", ids) for i := range out { out[i].Names = names[out[i].ID] } return out, err}
// CreateUOM adds the organization's own unit.func CreateUOM(ctx context.Context, tx pgx.Tx, c clock.Clock, key string, names Names) (UOM, error) { org, err := orgOf(ctx) if err != nil { return UOM{}, err } key, names = strings.ToUpper(strings.TrimSpace(key)), trimNames(names) if !uomKey.MatchString(key) { return UOM{}, fieldErr("key", "key_shape", "a UN/ECE unit code of two or three capital letters or digits, such as CTN") } if err := checkNames("names", names, true, 60); err != nil { return UOM{}, err } var id uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO uom (org_id, key, sort_order) VALUES ($1, $2, 100) RETURNING id`, org, key).Scan(&id); err != nil { return UOM{}, mapWriteErr(err) } if err := writeNames(ctx, tx, org, EntityUOM, id, "name", names); err != nil { return UOM{}, err } u, err := GetUOM(ctx, tx, id) if err != nil { return UOM{}, err } return u, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityUOM, EntityID: id, Action: "create", After: u})}Create internal/platform/inventory/category.go:
package inventory
import ( "context" "errors" "fmt" "regexp" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/registry")
// Category is an item category (spec O1.2: a platform/registry lookup).type Category struct { ID uuid.UUID Key string Names Names IsActive bool}
var categoryKey = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
func GetCategory(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Category, error) { var k Category err := tx.QueryRow(ctx, `SELECT id, key, is_active FROM item_category WHERE id = $1 AND `+ registry.VisibleSQL("item_category"), id).Scan(&k.ID, &k.Key, &k.IsActive) if errors.Is(err, pgx.ErrNoRows) { return Category{}, ErrNotFound } if err != nil { return Category{}, fmt.Errorf("inventory: category: %w", err) } names, err := loadNames(ctx, tx, EntityCategory, "name", []uuid.UUID{k.ID}) k.Names = names[k.ID] return k, err}
func ListCategories(ctx context.Context, tx pgx.Tx) ([]Category, error) { rows, err := tx.Query(ctx, `SELECT id, key, is_active FROM item_category WHERE `+ registry.VisibleSQL("item_category")+` ORDER BY sort_order, key`) if err != nil { return nil, fmt.Errorf("inventory: categories: %w", err) } defer rows.Close() var out []Category var ids []uuid.UUID for rows.Next() { var k Category if err := rows.Scan(&k.ID, &k.Key, &k.IsActive); err != nil { return nil, err } out = append(out, k) ids = append(ids, k.ID) } if err := rows.Err(); err != nil { return nil, err } names, err := loadNames(ctx, tx, EntityCategory, "name", ids) for i := range out { out[i].Names = names[out[i].ID] } return out, err}
func CreateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, key string, names Names) (Category, error) { org, err := orgOf(ctx) if err != nil { return Category{}, err } key, names = strings.TrimSpace(key), trimNames(names) if !categoryKey.MatchString(key) { return Category{}, fieldErr("key", "key_shape", "lower-case letters, digits and underscores, starting with a letter") } if err := checkNames("names", names, true, 100); err != nil { return Category{}, err } var id uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO item_category (org_id, key) VALUES ($1, $2) RETURNING id`, org, key).Scan(&id); err != nil { return Category{}, mapWriteErr(err) } if err := writeNames(ctx, tx, org, EntityCategory, id, "name", names); err != nil { return Category{}, err } k, err := GetCategory(ctx, tx, id) if err != nil { return Category{}, err } return k, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityCategory, EntityID: id, Action: "create", After: k})}
// UpdateCategory renames or retires one of the organization's own// categories; a seeded global is no organization's to edit (registry rule 3).func UpdateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, names Names, active bool) (Category, error) { org, err := orgOf(ctx) if err != nil { return Category{}, err } before, err := GetCategory(ctx, tx, id) if err != nil { return Category{}, err } names = trimNames(names) if err := checkNames("names", names, true, 100); err != nil { return Category{}, err } tag, err := tx.Exec(ctx, `UPDATE item_category SET is_active = $3 WHERE org_id = $1 AND id = $2`, org, id, active) if err != nil { return Category{}, mapWriteErr(err) } if tag.RowsAffected() == 0 { return Category{}, ErrNotFound } if err := writeNames(ctx, tx, org, EntityCategory, id, "name", names); err != nil { return Category{}, err } after, err := GetCategory(ctx, tx, id) if err != nil { return Category{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityCategory, EntityID: id, Action: "update", Before: before, After: after})}- Step 6: Write items
Create internal/platform/inventory/item.go:
package inventory
import ( "context" "errors" "fmt" "regexp" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/customfield")
// ItemUOM is one alternate unit: Factor base units make one of it.type ItemUOM struct { UOMID uuid.UUID Factor money.Rate IsActive bool}
// Item is one item of the master (spec O1.2).type Item struct { ID uuid.UUID Code string Names Names Descriptions Names Type string BaseUOMID uuid.UUID CategoryID *uuid.UUID TaxCodeID *uuid.UUID InventoryAccountID *uuid.UUID COGSAccountID *uuid.UUID SalesAccountID *uuid.UUID PurchaseAccountID *uuid.UUID Tracking string IsActive bool CustomFields map[string]string UOMs []ItemUOM Revision int CreatedAt time.Time UpdatedAt time.Time}
type ItemUOMInput struct { UOMID uuid.UUID Factor money.Rate}
// ItemInput is what CreateItem and UpdateItem take. UOMs is the whole set of// alternate units: one left out is retired. CustomFields nil keeps what an// update finds (customfield.Resolve).type ItemInput struct { Code string Type string Tracking string // "" is none Names Names Descriptions Names BaseUOMID uuid.UUID CategoryID *uuid.UUID TaxCodeID *uuid.UUID InventoryAccountID *uuid.UUID COGSAccountID *uuid.UUID SalesAccountID *uuid.UUID PurchaseAccountID *uuid.UUID IsActive bool UOMs []ItemUOMInput CustomFields map[string]string}
// ItemFilter is ListItems' filter. Query matches a code prefix or any// locale's name; Fields matches user-defined values exactly (Ruling U-5).type ItemFilter struct { Query string Type string CategoryID *uuid.UUID ActiveOnly bool Fields map[string]string}
var itemCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9._/-]{0,39}$`)
const itemSelect = ` SELECT id, code, type, base_uom_id, category_id, tax_code_id, inventory_account_id, cogs_account_id, sales_account_id, purchase_account_id, tracking, is_active, custom_fields, revision, created_at, updated_at FROM item WHERE org_id = $1 `
func scanItem(row pgx.Row) (Item, error) { var it Item err := row.Scan(&it.ID, &it.Code, &it.Type, &it.BaseUOMID, &it.CategoryID, &it.TaxCodeID, &it.InventoryAccountID, &it.COGSAccountID, &it.SalesAccountID, &it.PurchaseAccountID, &it.Tracking, &it.IsActive, &it.CustomFields, &it.Revision, &it.CreatedAt, &it.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return Item{}, ErrNotFound } return it, err}
// attach reads the items' names, descriptions and alternate units, one query each.func attach(ctx context.Context, tx pgx.Tx, org uuid.UUID, items []Item) error { ids := make([]uuid.UUID, len(items)) for i := range items { ids[i] = items[i].ID } names, err := loadNames(ctx, tx, EntityItem, "name", ids) if err != nil { return err } descs, err := loadNames(ctx, tx, EntityItem, "description", ids) if err != nil { return err } rows, err := tx.Query(ctx, `SELECT item_id, uom_id, factor, is_active FROM item_uom WHERE org_id = $1 AND item_id = ANY($2) ORDER BY item_id, uom_id`, org, ids) if err != nil { return fmt.Errorf("inventory: item units: %w", err) } defer rows.Close() units := map[uuid.UUID][]ItemUOM{} for rows.Next() { var item uuid.UUID var u ItemUOM if err := rows.Scan(&item, &u.UOMID, &u.Factor, &u.IsActive); err != nil { return err } units[item] = append(units[item], u) } if err := rows.Err(); err != nil { return err } for i := range items { id := items[i].ID items[i].Names, items[i].Descriptions, items[i].UOMs = names[id], descs[id], units[id] if items[i].UOMs == nil { items[i].UOMs = []ItemUOM{} } } return nil}
func getItemWhere(ctx context.Context, tx pgx.Tx, where string, arg any) (Item, error) { org, err := orgOf(ctx) if err != nil { return Item{}, err } it, err := scanItem(tx.QueryRow(ctx, itemSelect+where, org, arg)) if err != nil { return Item{}, err } items := []Item{it} if err := attach(ctx, tx, org, items); err != nil { return Item{}, err } return items[0], nil}
func GetItem(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Item, error) { return getItemWhere(ctx, tx, `AND id = $2`, id)}
func GetItemByCode(ctx context.Context, tx pgx.Tx, code string) (Item, error) { return getItemWhere(ctx, tx, `AND code = $2`, strings.ToUpper(strings.TrimSpace(code)))}
// ListItems returns at most 1000 items, by code.func ListItems(ctx context.Context, tx pgx.Tx, f ItemFilter) ([]Item, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, itemSelect+` AND ($2 = '' OR code LIKE upper($2) || '%' OR EXISTS (SELECT 1 FROM translation t WHERE t.entity_kind = 'item' AND t.entity_id = item.id AND t.field = 'name' AND t.value ILIKE '%' || $2 || '%')) AND ($3 = '' OR type = $3) AND ($4::uuid IS NULL OR category_id = $4) AND (NOT $5 OR is_active) AND ($6::jsonb IS NULL OR custom_fields @> $6) ORDER BY code LIMIT 1000`, org, strings.TrimSpace(f.Query), f.Type, f.CategoryID, f.ActiveOnly, customfield.Filter(f.Fields)) if err != nil { return nil, fmt.Errorf("inventory: list items: %w", err) } defer rows.Close() out := []Item{} for rows.Next() { it, err := scanItem(rows) if err != nil { return nil, err } out = append(out, it) } if err := rows.Err(); err != nil { return nil, err } return out, attach(ctx, tx, org, out)}
// UOMFactor is how many base units one uomID of the item holds: 1 for its// base unit, the factor of an active alternate unit, else ErrUOMNotForItem.func UOMFactor(ctx context.Context, tx pgx.Tx, itemID, uomID uuid.UUID) (money.Rate, error) { org, err := orgOf(ctx) if err != nil { return money.Rate{}, err } var f money.Rate err = tx.QueryRow(ctx, ` SELECT 1::numeric FROM item WHERE org_id = $1 AND id = $2 AND base_uom_id = $3 UNION ALL SELECT factor FROM item_uom WHERE org_id = $1 AND item_id = $2 AND uom_id = $3 AND is_active LIMIT 1`, org, itemID, uomID).Scan(&f) if errors.Is(err, pgx.ErrNoRows) { return money.Rate{}, ErrUOMNotForItem } return f, err}
func activeUOM(ctx context.Context, tx pgx.Tx, field string, id uuid.UUID) error { u, err := GetUOM(ctx, tx, id) if errors.Is(err, ErrNotFound) || (err == nil && !u.IsActive) { return fieldErr(field, "uom_active", "choose an active unit") } return err}
// checkAccount: an active, postable account of type typ with no control kind.func checkAccount(ctx context.Context, tx pgx.Tx, field string, id *uuid.UUID, typ string) error { if id == nil { return nil } a, err := chart.GetAccount(ctx, tx, *id) switch { case errors.Is(err, chart.ErrNotFound): return fieldErr(field, "account_usable", "choose an active "+typ+" account") case err != nil: return err case !a.IsActive || !a.IsPostable || a.ControlKind != nil || a.Type != typ: return fieldErr(field, "account_usable", "choose an active, postable "+typ+" account that is not a control account") } return nil}
// checkItem normalizes in and applies Ruling O1-9 and the unit rules.func checkItem(ctx context.Context, tx pgx.Tx, in *ItemInput) error { in.Code = strings.ToUpper(strings.TrimSpace(in.Code)) in.Names, in.Descriptions = trimNames(in.Names), trimNames(in.Descriptions) if in.Tracking == "" { in.Tracking = TrackingNone } switch { case !itemCode.MatchString(in.Code): return fieldErr("code", "code_shape", "letters, digits and . _ / -, up to 40 characters") case in.Type != TypeStock && in.Type != TypeService && in.Type != TypeNonStock: return fieldErr("type", "type_known", "stock, service or non_stock") case in.Tracking != TrackingNone && in.Tracking != TrackingSerial && in.Tracking != TrackingBatch: return fieldErr("tracking", "tracking_known", "none, serial or batch") case in.Type != TypeStock && (in.InventoryAccountID != nil || in.COGSAccountID != nil): return fieldErr("inventory_account_id", "stock_only", "only a stock item has inventory and cost of sales accounts") case in.Tracking != TrackingNone && in.Type != TypeStock: return fieldErr("tracking", "tracking_stock_only", "only a stock item is tracked by serial number or batch") case in.Type == TypeStock && in.InventoryAccountID == nil: return fieldErr("inventory_account_id", "required", "a stock item names its inventory account") case in.Type == TypeStock && in.COGSAccountID == nil: return fieldErr("cogs_account_id", "required", "a stock item names its cost of sales account") case len(in.UOMs) > 20: return fieldErr("uoms", "uoms_count", "at most 20 other units") } if err := checkNames("names", in.Names, true, 200); err != nil { return err } if err := checkNames("descriptions", in.Descriptions, false, 1000); err != nil { return err } if err := activeUOM(ctx, tx, "base_uom_id", in.BaseUOMID); err != nil { return err } if in.CategoryID != nil { k, err := GetCategory(ctx, tx, *in.CategoryID) if errors.Is(err, ErrNotFound) || (err == nil && !k.IsActive) { return fieldErr("category_id", "category_active", "choose an active category") } if err != nil { return err } } if in.TaxCodeID != nil { tc, err := chart.GetTaxCode(ctx, tx, *in.TaxCodeID) if errors.Is(err, chart.ErrNotFound) || (err == nil && (!tc.IsActive || tc.Usage == chart.TaxUsageWithholding)) { return fieldErr("tax_code_id", "tax_code_valid", "choose an active sales or purchase tax code") } if err != nil { return err } } for _, a := range []struct { field, typ string id *uuid.UUID }{ {"inventory_account_id", chart.TypeAsset, in.InventoryAccountID}, {"cogs_account_id", chart.TypeExpense, in.COGSAccountID}, {"sales_account_id", chart.TypeIncome, in.SalesAccountID}, {"purchase_account_id", chart.TypeExpense, in.PurchaseAccountID}, } { if err := checkAccount(ctx, tx, a.field, a.id, a.typ); err != nil { return err } } seen := map[uuid.UUID]bool{in.BaseUOMID: true} for i, u := range in.UOMs { f := fmt.Sprintf("uoms[%d]", i) if seen[u.UOMID] { return fieldErr(f+".uom_id", "uom_once", "each unit once, and not the base unit") } seen[u.UOMID] = true // Rate prints ten places; a factor is stored at six. if !u.Factor.IsPositive() || !strings.HasSuffix(u.Factor.String(), "0000") { return fieldErr(f+".factor", "factor_positive", "a positive number of base units, up to six decimal places") } if err := activeUOM(ctx, tx, f+".uom_id", u.UOMID); err != nil { return err } } return nil}
// writeUOMs makes uoms the item's active alternate units; any other is retired.func writeUOMs(ctx context.Context, tx pgx.Tx, org, item uuid.UUID, uoms []ItemUOMInput) error { if _, err := tx.Exec(ctx, `UPDATE item_uom SET is_active = false WHERE org_id = $1 AND item_id = $2`, org, item); err != nil { return fmt.Errorf("inventory: item units: %w", err) } for _, u := range uoms { if _, err := tx.Exec(ctx, ` INSERT INTO item_uom (org_id, item_id, uom_id, factor) VALUES ($1, $2, $3, $4) ON CONFLICT (org_id, item_id, uom_id) DO UPDATE SET factor = EXCLUDED.factor, is_active = true`, org, item, u.UOMID, u.Factor); err != nil { return fmt.Errorf("inventory: item units: %w", err) } } return nil}
func writeItemText(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, in ItemInput) error { if err := writeUOMs(ctx, tx, org, id, in.UOMs); err != nil { return err } if err := writeNames(ctx, tx, org, EntityItem, id, "name", in.Names); err != nil { return err } return writeNames(ctx, tx, org, EntityItem, id, "description", in.Descriptions)}
func CreateItem(ctx context.Context, tx pgx.Tx, c clock.Clock, in ItemInput) (Item, error) { org, err := orgOf(ctx) if err != nil { return Item{}, err } if err := checkItem(ctx, tx, &in); err != nil { return Item{}, err } fields, err := customfield.Resolve(ctx, tx, customfield.EntityItem, in.CustomFields, nil, true) if err != nil { return Item{}, err } id, now := uuid.New(), c.Now() if _, err := tx.Exec(ctx, ` INSERT INTO item (id, org_id, code, type, base_uom_id, category_id, tax_code_id, inventory_account_id, cogs_account_id, sales_account_id, purchase_account_id, tracking, is_active, custom_fields, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)`, id, org, in.Code, in.Type, in.BaseUOMID, in.CategoryID, in.TaxCodeID, in.InventoryAccountID, in.COGSAccountID, in.SalesAccountID, in.PurchaseAccountID, in.Tracking, in.IsActive, fields, now); err != nil { return Item{}, mapWriteErr(err) } if err := writeItemText(ctx, tx, org, id, in); err != nil { return Item{}, err } it, err := GetItem(ctx, tx, id) if err != nil { return Item{}, err } return it, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityItem, EntityID: id, Action: "create", After: it})}
// UpdateItem replaces the item's editable shape. ORDER: item FOR UPDATE →// checks → writes → audit.func UpdateItem(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in ItemInput) (Item, error) { org, err := orgOf(ctx) if err != nil { return Item{}, err } if _, err := tx.Exec(ctx, `SELECT 1 FROM item WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id); err != nil { return Item{}, fmt.Errorf("inventory: lock item: %w", err) } before, err := GetItem(ctx, tx, id) if err != nil { return Item{}, err } if before.Revision != expectedRevision { return Item{}, &RevisionMismatchError{Current: before.Revision} } if err := checkItem(ctx, tx, &in); err != nil { return Item{}, err } fields, err := customfield.Resolve(ctx, tx, customfield.EntityItem, in.CustomFields, before.CustomFields, false) if err != nil { return Item{}, err } if _, err := tx.Exec(ctx, ` UPDATE item SET code = $3, type = $4, base_uom_id = $5, category_id = $6, tax_code_id = $7, inventory_account_id = $8, cogs_account_id = $9, sales_account_id = $10, purchase_account_id = $11, tracking = $12, is_active = $13, custom_fields = $14, revision = revision + 1, updated_at = $15 WHERE org_id = $1 AND id = $2`, org, id, in.Code, in.Type, in.BaseUOMID, in.CategoryID, in.TaxCodeID, in.InventoryAccountID, in.COGSAccountID, in.SalesAccountID, in.PurchaseAccountID, in.Tracking, in.IsActive, fields, c.Now()); err != nil { return Item{}, mapWriteErr(err) } if err := writeItemText(ctx, tx, org, id, in); err != nil { return Item{}, err } after, err := GetItem(ctx, tx, id) if err != nil { return Item{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityItem, EntityID: id, Action: "update", Before: before, After: after})}- Step 7: Write locations
Create internal/platform/inventory/location.go:
package inventory
import ( "context" "errors" "fmt" "regexp" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock")
// Location is a warehouse (spec O1.2: no bins).type Location struct { ID uuid.UUID Code string Names Names IsActive bool Revision int CreatedAt time.Time UpdatedAt time.Time}
type LocationInput struct { Code string Names Names IsActive bool}
var locationCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9._/-]{0,19}$`)
const locationSelect = `SELECT id, code, is_active, revision, created_at, updated_at FROM location WHERE org_id = $1 `
func scanLocation(row pgx.Row) (Location, error) { var l Location err := row.Scan(&l.ID, &l.Code, &l.IsActive, &l.Revision, &l.CreatedAt, &l.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return Location{}, ErrNotFound } return l, err}
func GetLocation(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Location, error) { org, err := orgOf(ctx) if err != nil { return Location{}, err } l, err := scanLocation(tx.QueryRow(ctx, locationSelect+`AND id = $2`, org, id)) if err != nil { return Location{}, err } names, err := loadNames(ctx, tx, EntityLocation, "name", []uuid.UUID{l.ID}) l.Names = names[l.ID] return l, err}
func ListLocations(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Location, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, locationSelect+`AND (NOT $2 OR is_active) ORDER BY code`, org, activeOnly) if err != nil { return nil, fmt.Errorf("inventory: list locations: %w", err) } defer rows.Close() out := []Location{} var ids []uuid.UUID for rows.Next() { l, err := scanLocation(rows) if err != nil { return nil, err } out = append(out, l) ids = append(ids, l.ID) } if err := rows.Err(); err != nil { return nil, err } names, err := loadNames(ctx, tx, EntityLocation, "name", ids) for i := range out { out[i].Names = names[out[i].ID] } return out, err}
func checkLocation(in *LocationInput) error { in.Code, in.Names = strings.ToUpper(strings.TrimSpace(in.Code)), trimNames(in.Names) if !locationCode.MatchString(in.Code) { return fieldErr("code", "code_shape", "letters, digits and . _ / -, up to 20 characters") } return checkNames("names", in.Names, true, 200)}
func CreateLocation(ctx context.Context, tx pgx.Tx, c clock.Clock, in LocationInput) (Location, error) { org, err := orgOf(ctx) if err != nil { return Location{}, err } if err := checkLocation(&in); err != nil { return Location{}, err } id, now := uuid.New(), c.Now() if _, err := tx.Exec(ctx, `INSERT INTO location (id, org_id, code, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)`, id, org, in.Code, in.IsActive, now); err != nil { return Location{}, mapWriteErr(err) } if err := writeNames(ctx, tx, org, EntityLocation, id, "name", in.Names); err != nil { return Location{}, err } l, err := GetLocation(ctx, tx, id) if err != nil { return Location{}, err } return l, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLocation, EntityID: id, Action: "create", After: l})}
func UpdateLocation(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in LocationInput) (Location, error) { org, err := orgOf(ctx) if err != nil { return Location{}, err } if _, err := tx.Exec(ctx, `SELECT 1 FROM location WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id); err != nil { return Location{}, fmt.Errorf("inventory: lock location: %w", err) } before, err := GetLocation(ctx, tx, id) if err != nil { return Location{}, err } if before.Revision != expectedRevision { return Location{}, &RevisionMismatchError{Current: before.Revision} } if err := checkLocation(&in); err != nil { return Location{}, err } if _, err := tx.Exec(ctx, `UPDATE location SET code = $3, is_active = $4, revision = revision + 1, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, id, in.Code, in.IsActive, c.Now()); err != nil { return Location{}, mapWriteErr(err) } if err := writeNames(ctx, tx, org, EntityLocation, id, "name", in.Names); err != nil { return Location{}, err } after, err := GetLocation(ctx, tx, id) if err != nil { return Location{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLocation, EntityID: id, Action: "update", Before: before, After: after})}- Step 8: Run the inventory tests
Run: go test ./internal/platform/inventory/ ./internal/platform/customfield/ -count=1
Expected: PASS. (In TestOnlyAStockItemNamesInventoryAccountsAndIsTracked the order of the switch in checkItem is what makes each refusal the one asserted; keep it.)
- Step 9: Commit
git add internal/platform/inventory internal/platform/customfield/customfield.go db/migrations/00712_item_custom_fields.sqlgit commit -m "Phase 4 O1: item master service (items, units, categories, locations)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: The chain service (platform/trade): documents, seeded workflows, numbering, transitions
Section titled “Task 5: The chain service (platform/trade): documents, seeded workflows, numbering, transitions”Files:
- Create:
internal/platform/trade/doc.go,cursor.go,graphs.go,document.go,transition.go - Create:
internal/platform/trade/tradetest/env.go - Modify:
internal/platform/workflow/soleowner.go(soleOwnerEdgecoverscommitted, Ruling O1-18) - Test:
internal/platform/trade/graphs_test.go,document_test.go,transition_test.go
Interfaces:
-
Consumes: Task 1 (
posting.Committed,workflow.CommitmentKinds), Task 2 (schema, constraint names), Task 4 (inventory.GetItem,inventory.UOMFactor,inventory.GetUOM,inventory.GetLocation,inventory.CreateItem,inventory.CreateLocation);workflow.Start,workflow.Transition,workflow.SetAmount,workflow.RegisterSeededGraph,numbering.Allocate/Format,company.Get,chart.GetTaxCode,analytic.Plan. -
Produces: in
trade— kind constants,Kinds,NextKind,PartySide,NeedsLocation,Line,Document,Document.Closed,LineInput,DocumentInput,Filter,Cursor,DecodeCursor,TransitionInput,Create,Update,Get,List,Transition, the four graph builders (opted into the sole-owner exception), every sentinel error,FieldError,RevisionMismatchError; intradetest—Envand every helper listed in “Interfaces O2 and O3 consume”. Task 6 addsDraft,OpenQuantity,Close. -
Step 1: Write the failing tests
Create internal/platform/trade/graphs_test.go:
package trade_test
import ( "testing"
"github.com/stretchr/testify/require"
_ "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
// Ruling O1-12, spec O1.1: each commitment kind has a seeded graph that the// validator provisioning runs would accept, ending in committed.func TestTheCommitmentKindsAreSeededAndValid(t *testing.T) { require.Len(t, workflow.CommitmentKinds, 4, "PRESENCE: the loop below judges four kinds") for _, k := range workflow.CommitmentKinds { workflowtest.RequireSeededGraphValid(t, k) }}Create internal/platform/trade/document_test.go:
package trade_test
import ( "context" "errors" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest")
func fieldOf(err error) string { var fe *trade.FieldError if errors.As(err, &fe) { return fe.Field + "/" + fe.Rule } return ""}
func create(e *tradetest.Env, in trade.DocumentInput) (trade.Document, error) { var d trade.Document err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.Create(ctx, tx, e.Clock, e.Staff, in) return err }) return d, err}
// Rulings O1-1 and O1-3: numbered at create by kind and year; priced in the// document's currency, and in base for the approval amount.func TestAnOrderIsNumberedAndPriced(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") d := e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "3", "10.00"), tradetest.TextLine(each, "1.5", "4.00")}}) require.Equal(t, "PO-2026-0001", d.Number) require.Equal(t, "draft", d.State) require.Equal(t, posting.Draft, d.Semantic) require.Equal(t, "36.0000", d.NetTotal.String()) require.Equal(t, "36.0000", d.NetBase.String()) require.Len(t, d.Lines, 2) require.Zero(t, money.MustParseRate("1.5").Cmp(d.Lines[1].QuantityBase)) require.Zero(t, d.Lines[1].QuantityBase.Cmp(d.Lines[1].OpenQuantity), "nothing draws from it yet")
usd := money.MustParseRate("4.4") f := e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "USD", FXRate: &usd, Lines: []trade.LineInput{tradetest.TextLine(each, "2", "10.00")}}) require.Equal(t, "PO-2026-0002", f.Number) require.Equal(t, "88.0000", f.NetBase.String()) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM workflow_instance WHERE org_id = $1 AND id = $2 AND amount = 88`, e.Org, f.InstanceID), "approval bands compare the base amount")}
func TestAKindTakesItsPartyAndLocation(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s, c := e.Supplier(t, "BEKAL"), e.Customer(t, "KEDAI") loc := e.Location(t, "KL").ID line := []trade.LineInput{tradetest.TextLine(each, "1", "1.00")}
_, err := create(e, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: line}) require.NoError(t, err, "PRESENCE: a requisition names no party and no location") _, err = create(e, trade.DocumentInput{Kind: trade.KindRequisition, PartyID: &s, Currency: "MYR", Lines: line}) require.Equal(t, "party_id/party_none", fieldOf(err)) _, err = create(e, trade.DocumentInput{Kind: trade.KindOrder, Currency: "MYR", Lines: line}) require.Equal(t, "party_id/party_required", fieldOf(err)) _, err = create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &c, Currency: "MYR", Lines: line}) require.Equal(t, "party_id/party_active", fieldOf(err), "a customer is not a supplier") _, err = create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, LocationID: &loc, Currency: "MYR", Lines: line}) require.Equal(t, "location_id/location_none", fieldOf(err)) _, err = create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1.1234567", "1.00")}}) require.Equal(t, "lines[0].quantity/quantity_places", fieldOf(err))}
// Ruling O1-8: an item line's unit is its base unit or one of its// alternates, and the factor is frozen on the line.func TestAnItemLineTakesItsUnitsFactor(t *testing.T) { e := tradetest.New(t) box := e.UOM(t, "BX") it := e.StockItem(t, "PAPER-A4", inventory.ItemUOMInput{UOMID: box, Factor: money.MustParseRate("12")}) s := e.Supplier(t, "BEKAL") d := e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{{ItemID: &it.ID, Quantity: money.MustParseRate("2"), UOMID: box, UnitPrice: money.MustParse("120.00")}}}) require.Zero(t, money.MustParseRate("24").Cmp(d.Lines[0].QuantityBase)) require.Equal(t, "PAPER-A4 stock item", d.Lines[0].Description, "a blank description takes the item's English name")
_, err := create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{{ItemID: &it.ID, Quantity: money.MustParseRate("1"), UOMID: e.UOM(t, "KGM"), UnitPrice: money.Zero()}}}) require.Equal(t, "lines[0].uom_id/uom_of_item", fieldOf(err))}
// Ruling O1-7: only the preparer edits a draft, at the revision they read;// a replaced line is withdrawn, never deleted.func TestOnlyThePreparerEditsADraftAtItsRevision(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") in := trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "10.00")}} d := e.Create(t, in) update := func(by uuid.UUID, rev int) (trade.Document, error) { var out trade.Document err := e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = trade.Update(ctx, tx, e.Clock, d.ID, by, rev, in) return err }) return out, err } in.Lines = append(in.Lines, tradetest.TextLine(each, "2", "5.00")) after, err := update(e.Staff, d.Revision) require.NoError(t, err, "PRESENCE: the preparer edits it") require.Equal(t, d.Revision+1, after.Revision) require.Equal(t, "20.0000", after.NetTotal.String()) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM trade_line WHERE org_id = $1 AND document_id = $2 AND withdrawn_at IS NOT NULL`, e.Org, d.ID))
_, err = update(e.Owner, after.Revision) require.ErrorIs(t, err, trade.ErrNotPreparer) _, err = update(e.Staff, d.Revision) var stale *trade.RevisionMismatchError require.ErrorAs(t, err, &stale)}
// Ruling O1-9 over the chain: once a line names an item, its base unit is fixed.func TestAnItemsBaseUnitIsFixedOnceALineNamesIt(t *testing.T) { e := tradetest.New(t) it := e.StockItem(t, "PAPER-A4") rebase := func(key string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { cur, err := inventory.GetItem(ctx, tx, it.ID) if err != nil { return err } _, err = inventory.UpdateItem(ctx, tx, e.Clock, it.ID, cur.Revision, inventory.ItemInput{Code: cur.Code, Type: cur.Type, Names: cur.Names, BaseUOMID: e.UOM(t, key), InventoryAccountID: cur.InventoryAccountID, COGSAccountID: cur.COGSAccountID, IsActive: true}) return err }) } require.NoError(t, rebase("KGM"), "PRESENCE: before any document names it, the base unit may change") s := e.Supplier(t, "BEKAL") e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{{ItemID: &it.ID, Quantity: money.MustParseRate("1"), UOMID: e.UOM(t, "KGM"), UnitPrice: money.Zero()}}}) require.ErrorIs(t, rebase("C62"), inventory.ErrItemInUse)}Create internal/platform/trade/transition_test.go:
package trade_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest" "github.com/mcom/erp/internal/platform/workflow")
// Spec O1.1: an approved order is committed and posts nothing. This world// registers no Poster, so a ledger-relevant event would have failed the// transition with posting.ErrNoPoster; the counts below say nothing was// left behind either.func TestAnApprovedOrderIsCommittedAndPostsNothing(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") d := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "3", "10.00")}})) require.Equal(t, "approved", d.State) require.Equal(t, posting.Committed, d.Semantic) require.Equal(t, 0, e.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1 AND instance_id = $2`, e.Org, d.InstanceID)) require.Equal(t, 0, e.Count(t, `SELECT count(*) FROM workflow_instance WHERE org_id = $1 AND id = $2 AND (accrual_open OR payout_open)`, e.Org, d.InstanceID))}
// A claimant never picks their own approver: the seeded grant names the// requester's superior.func TestARequisitionIsApprovedByTheRequestersSuperiorNeverTheRequester(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") req := func() trade.Document { return e.Move(t, e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "5", "2.00")}}), e.Staff, "draft", "pending_approval") } approved := e.Move(t, req(), e.Owner, "pending_approval", "approved") require.Equal(t, posting.Committed, approved.Semantic, "PRESENCE: the superior approves")
d := req() err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, _, err := trade.Transition(ctx, tx, e.Clock, trade.TransitionInput{ID: d.ID, From: "pending_approval", To: "approved", Actor: e.Staff}) return err }) var refused *workflow.RefusedError require.ErrorAs(t, err, &refused)}
// Ruling O1-18: a one-member organization's Owner approves their own order// with a reason, recorded as the exception; a blank reason is refused. With a// second active member the claimant rule stands (presence of the rule first).func TestASoleOwnerApprovesTheirOwnOrderWithAReason(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") own := func() trade.Document { var d trade.Document require.NoError(t, e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.Create(ctx, tx, e.Clock, e.Owner, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "10.00")}}) return err })) return d } move := func(d trade.Document, from, to, reason string) error { return e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := trade.Transition(ctx, tx, e.Clock, trade.TransitionInput{ID: d.ID, From: from, To: to, Actor: e.Owner, Reason: reason}) return err }) } commit := func(d trade.Document, reason string) error { if err := move(d, "draft", "pending_approval", reason); err != nil { return err } return move(d, "pending_approval", "approved", reason) }
// Two active members: the Owner may not approve their own order, at // submission (unroutable) or at approval (refused). require.Error(t, commit(own(), "Urgent restock"), "PRESENCE: the claimant rule refuses the Owner")
_, err := e.H.Admin.Exec(context.Background(), `UPDATE organization_member SET active = false WHERE org_id = $1 AND id = $2`, e.Org, e.Staff) require.NoError(t, err) d := own() require.NoError(t, move(d, "draft", "pending_approval", ""), "the submission routes under the exception") require.ErrorIs(t, move(d, "pending_approval", "approved", " "), workflow.ErrSoleOwnerReasonRequired) require.NoError(t, move(d, "pending_approval", "approved", "Urgent restock")) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM workflow_decision WHERE org_id = $1 AND instance_id = $2 AND authority_via = 'sole_owner_exception' AND reason = 'Urgent restock'`, e.Org, d.InstanceID)) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'trade_document' AND entity_id = $2 AND action = 'transition' AND after->>'sole_owner_exception' = 'true' AND after->>'reason' = 'Urgent restock'`, e.Org, d.ID))}
// Spec O3.2, Ruling O1-12: a quotation is issued by its preparer in one step.func TestAQuotationIsIssuedByItsPreparerInOneStep(t *testing.T) { e := tradetest.New(t) c := e.Customer(t, "KEDAI") q := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindQuotation, PartyID: &c, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(e.UOM(t, "HUR"), "8", "150.00")}})) require.Equal(t, "QT-2026-0001", q.Number) require.Equal(t, "issued", q.State) require.Equal(t, posting.Committed, q.Semantic)}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/trade/... -count=1
Expected: FAIL to compile — package trade and tradetest do not exist.
- Step 3: Write the package basics
Create internal/platform/trade/doc.go:
// Package trade is the document chain (Phase 4 O1.1,// docs/superpowers/specs/2026-09-30-operations-design.md): requisitions,// orders, goods receipts, purchase returns, quotations, sales orders,// delivery orders, sales returns and stock adjustments on one// trade_document / trade_line pair (OD-5), linked line to line.// Procurement and sales (O3) and stock (O2) build on it; sales adds// nothing to it.//// The database is the authority for the chain (00701's trade_line_guard);// this package maps its refusals to the sentinels below. It imports// platform/inventory and never platform/payables or platform/receivables,// which O3's three-way match will have import this package.package trade
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/posting")
// The chain's kinds: each is a workflow kind and trade_document.kind.const ( KindRequisition = "purchase_requisition" KindOrder = "purchase_order" KindReceipt = "goods_receipt" KindPurchaseReturn = "purchase_return" KindQuotation = "quotation" KindSalesOrder = "sales_order" KindDelivery = "delivery_order" KindSalesReturn = "sales_return" KindAdjustment = "stock_adjustment"
// EntityDocument is the audit rows' entity kind. EntityDocument = "trade_document")
// Kinds is every chain kind, in chain order.var Kinds = []string{KindRequisition, KindOrder, KindReceipt, KindPurchaseReturn, KindQuotation, KindSalesOrder, KindDelivery, KindSalesReturn, KindAdjustment}
// next is the allowed pairs (spec O1.1), source kind to the one kind it// feeds. 00701's trade_line_guard holds the same list.var next = map[string]string{ KindRequisition: KindOrder, KindOrder: KindReceipt, KindReceipt: KindPurchaseReturn, KindQuotation: KindSalesOrder, KindSalesOrder: KindDelivery, KindDelivery: KindSalesReturn,}
// NextKind is the kind a source kind feeds; false for a kind that feeds none.func NextKind(source string) (string, bool) { t, ok := next[source] return t, ok}
// PartySide is "supplier" or "customer" for a kind with a party, "" for a// requisition or adjustment (spec O1.1).func PartySide(kind string) string { switch kind { case KindOrder, KindReceipt, KindPurchaseReturn: return "supplier" case KindQuotation, KindSalesOrder, KindDelivery, KindSalesReturn: return "customer" } return ""}
// NeedsLocation: receipts, deliveries, returns and adjustments move stock at// a location (spec O1.1).func NeedsLocation(kind string) bool { switch kind { case KindReceipt, KindPurchaseReturn, KindDelivery, KindSalesReturn, KindAdjustment: return true } return false}
// prefixes are Ruling O1-1's.var prefixes = map[string]string{ KindRequisition: "PR", KindOrder: "PO", KindReceipt: "GRN", KindPurchaseReturn: "PRN", KindQuotation: "QT", KindSalesOrder: "SO", KindDelivery: "DO", KindSalesReturn: "SRN", KindAdjustment: "ADJ",}
// actionable is Ruling O1-6: a document at sem may feed its next kind and be// closed. The guard's list in 00701 is the same.func actionable(sem posting.Semantic) bool { switch sem { case posting.Draft, posting.Pending, posting.Rejected, posting.Cancelled, posting.Dispute: return false } return true}
var ( ErrNotFound = errors.New("trade: no such document") ErrNotDraft = errors.New("trade: the document is not a draft") ErrNotPreparer = errors.New("trade: only the member who prepared this draft may edit it") ErrPairNotAllowed = errors.New("trade: this kind of document cannot draw from that one") ErrNotApproved = errors.New("trade: the document is not approved, or its line was replaced") ErrSourceClosed = errors.New("trade: the source document is closed") ErrPartyMismatch = errors.New("trade: the source document is for another supplier or customer") ErrItemMismatch = errors.New("trade: a line carries its source line's item") ErrOverOpenQuantity = errors.New("trade: the quantity is more than the source line has open") ErrSourceNotFound = errors.New("trade: no such source line in this organization") ErrNothingOpen = errors.New("trade: every line of the source document is fulfilled") ErrHasChildren = errors.New("trade: later documents draw from this one; cancel them first, or close it") ErrNotClosable = errors.New("trade: nothing draws from this kind of document, so there is nothing to close") ErrAlreadyClosed = errors.New("trade: the document is already closed"))
// FieldError is one refusal about one input field, in the wire shape// `field=<name> rule=<code>: <sentence>`.type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail)}
func fieldErr(field, rule, detail string) error { return &FieldError{Field: field, Rule: rule, Detail: detail}}
// RevisionMismatchError: the document changed since the caller read it.type RevisionMismatchError struct{ Current int }
func (e *RevisionMismatchError) Error() string { return fmt.Sprintf("trade: the document is at revision %d", e.Current)}
func orgOf(ctx context.Context) (uuid.UUID, error) { id, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("trade: %w", tenancy.ErrNoOrg) } return id, nil}
// mapPg turns 00701's refusals into this package's sentinels.func mapPg(err error) error { var pg *pgconn.PgError if !errors.As(err, &pg) { return err } switch pg.ConstraintName { case "trade_line_allowed_pair": return ErrPairNotAllowed case "trade_line_source_actionable": return ErrNotApproved case "trade_line_source_open": return ErrSourceClosed case "trade_line_same_party": return ErrPartyMismatch case "trade_line_same_item": return ErrItemMismatch case "trade_line_open_quantity": return ErrOverOpenQuantity case "trade_line_source_fk": return ErrSourceNotFound case "trade_line_is_draft", "trade_document_is_draft": return ErrNotDraft case "trade_line_has_children": return ErrHasChildren } return err}Create internal/platform/trade/cursor.go:
package trade
import ( "encoding/base64" "errors" "strings" "time"
"github.com/google/uuid")
var ErrBadCursor = errors.New("trade: malformed cursor")
// Cursor is a keyset position on (created_at DESC, id DESC).type Cursor struct { CreatedAt time.Time ID uuid.UUID}
func (c Cursor) Encode() string { return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt.UTC().Format(time.RFC3339Nano) + "|" + c.ID.String()))}
func DecodeCursor(s string) (Cursor, error) { raw, err := base64.RawURLEncoding.DecodeString(s) if err != nil { return Cursor{}, ErrBadCursor } at, id, ok := strings.Cut(string(raw), "|") if !ok { return Cursor{}, ErrBadCursor } t, err1 := time.Parse(time.RFC3339Nano, at) u, err2 := uuid.Parse(id) if err1 != nil || err2 != nil { return Cursor{}, ErrBadCursor } return Cursor{CreatedAt: t, ID: u}, nil}- Step 4: Write the seeded graphs
Create internal/platform/trade/graphs.go:
package trade
import ( "github.com/google/uuid"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
// The commitment kinds' graphs (Ruling O1-12). Receipt, delivery, returns// and adjustment are registered by the lanes that post them (O2, O3).func init() { workflow.RegisterSeededGraph(KindRequisition, RequisitionGraph) workflow.RegisterSeededGraph(KindOrder, OrderGraph) workflow.RegisterSeededGraph(KindQuotation, QuotationGraph) workflow.RegisterSeededGraph(KindSalesOrder, SalesOrderGraph) // Ruling O1-18: a one-member organization approves and cancels its own // commitments, with a reason (Ruling O-3a). for _, k := range []string{KindRequisition, KindOrder, KindQuotation, KindSalesOrder} { workflow.AllowSoleOwnerException(k, true) }}
func ls(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
func st(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { return workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l}}
func ed(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
// why marks a rejection or cancellation: a reason is required, and nothing// posted, so nothing reverses.func why(e workflow.EdgeSpec) workflow.EdgeSpec { e.RequiresReason = true return e}
func seat(state string, ownerSeat uuid.UUID) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}
var ( draftLabel = ls("Draft", "草稿", "Draf") withdrawnLabel = ls("Withdrawn", "已撤回", "Ditarik balik") cancelledLabel = ls("Cancelled", "已取消", "Dibatalkan") withdrawLabel = ls("Withdraw", "撤回", "Tarik balik") cancelLabel = ls("Cancel", "取消", "Batalkan"))
// approvalGraph is the two-step shape://// draft ──submit──▶ pending_approval ──approve──▶ approved (committed) ──cancel──▶ cancelled// │ └──reject──▶ rejected ──revise──▶ draft// └──withdraw──▶ withdrawnfunc approvalGraph(kind string, approver workflow.GrantSpec, approved workflow.Labels, ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: kind, PostingOrder: workflow.AccrualFirst, // vacuous: nothing posts (Ruling O1-12) States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, draftLabel), st(2, "pending_approval", posting.Pending, false, ls("Pending approval", "待批准", "Menunggu kelulusan")), st(3, "approved", posting.Committed, false, approved), st(4, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝", "Ditolak")), st(5, "withdrawn", posting.Cancelled, true, withdrawnLabel), st(6, "cancelled", posting.Cancelled, true, cancelledLabel), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_approval", workflow.GateReporter, "submit", ls("Submit", "提交", "Hantar")), ed(2, "pending_approval", "approved", workflow.GateTarget, "approve", ls("Approve", "批准", "Luluskan")), why(ed(3, "pending_approval", "rejected", workflow.GateAdvance, "reject", ls("Reject", "拒绝", "Tolak"))), ed(4, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改", "Semak semula")), why(ed(5, "draft", "withdrawn", workflow.GateReporter, "withdraw", withdrawLabel)), why(ed(6, "approved", "cancelled", workflow.GateTarget, "cancel", cancelLabel)), }, Grants: []workflow.GrantSpec{approver, seat("cancelled", ownerSeat)}, }.Normalized()}
// RequisitionGraph: approved by the requester's superior (spec O3.1: "resolved// from grants, never chosen by the requester").func RequisitionGraph(ownerSeat uuid.UUID) workflow.Graph { return approvalGraph(KindRequisition, workflow.GrantSpec{StateKey: "approved", GranteeKind: workflow.GranteeSuperior}, ls("Approved", "已批准", "Diluluskan"), ownerSeat)}
// OrderGraph: approved by the OWNER seat.func OrderGraph(ownerSeat uuid.UUID) workflow.Graph { return approvalGraph(KindOrder, seat("approved", ownerSeat), ls("Approved", "已批准", "Diluluskan"), ownerSeat)}
// SalesOrderGraph: confirmed by the OWNER seat.func SalesOrderGraph(ownerSeat uuid.UUID) workflow.Graph { return approvalGraph(KindSalesOrder, seat("approved", ownerSeat), ls("Confirmed", "已确认", "Disahkan"), ownerSeat)}
// QuotationGraph is spec O3.2's one step, by its preparer://// draft ──issue──▶ issued (committed) ──cancel──▶ cancelled// └──withdraw──▶ withdrawnfunc QuotationGraph(uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindQuotation, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, draftLabel), st(2, "issued", posting.Committed, false, ls("Issued", "已发出", "Telah dikeluarkan")), st(3, "withdrawn", posting.Cancelled, true, withdrawnLabel), st(4, "cancelled", posting.Cancelled, true, cancelledLabel), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "issued", workflow.GateReporter, "issue", ls("Issue", "发出", "Keluarkan")), why(ed(2, "draft", "withdrawn", workflow.GateReporter, "withdraw", withdrawLabel)), why(ed(3, "issued", "cancelled", workflow.GateReporter, "cancel", cancelLabel)), }, }.Normalized()}- Step 5: Write documents
Create internal/platform/trade/document.go:
package trade
import ( "context" "encoding/json" "errors" "fmt" "regexp" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/numbering" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
// Line is one line as read back. Quantity is in the line's unit;// QuantityBase and OpenQuantity are in base units (a text line's unit is its// own base: factor 1).type Line struct { ID uuid.UUID LineNo int ItemID *uuid.UUID Description string Quantity money.Rate UOMID uuid.UUID UOMFactor money.Rate QuantityBase money.Rate UnitPrice money.Amount NetAmount money.Amount TaxCodeID *uuid.UUID Shares []analytic.Share SourceLineID *uuid.UUID // OpenQuantity is QuantityBase less every live child's (spec O1.1, // Ruling O1-4): what a next document may still draw. OpenQuantity money.Rate}
// Document is one chain document.type Document struct { ID uuid.UUID Kind string Number string PartyID *uuid.UUID // the supplier or customer; nil for a requisition or adjustment Currency string FXRate money.Rate DocumentDate time.Time DueDate *time.Time LocationID *uuid.UUID NetTotal money.Amount NetBase money.Amount // ClosedAt, CloseReason and ClosedBy are the manual close (Ruling O1-5). ClosedAt *time.Time CloseReason *string ClosedBy *uuid.UUID // Fulfilled: it has lines and none has open quantity (the derived // auto-close). Fulfilled bool InstanceID uuid.UUID PreparedBy uuid.UUID Revision int CreatedAt time.Time UpdatedAt time.Time State string Semantic posting.Semantic Lines []Line}
// Closed: closed by hand, or every line fulfilled (spec O1.1 "Close").func (d Document) Closed() bool { return d.ClosedAt != nil || d.Fulfilled }
// LineInput is one line as a caller supplies it. A blank Description on an// item line takes the item's English name.type LineInput struct { ItemID *uuid.UUID Description string Quantity money.Rate UOMID uuid.UUID UnitPrice money.Amount TaxCodeID *uuid.UUID Shares []analytic.Share SourceLineID *uuid.UUID}
// DocumentInput is what Create and Update take (Kind: create only).type DocumentInput struct { Kind string PartyID *uuid.UUID Currency string FXRate *money.Rate DueDate *time.Time LocationID *uuid.UUID Lines []LineInput}
// Filter is List's filter and keyset page. Open keeps approved documents// not closed by hand with some line still open.type Filter struct { Kind string PartyID *uuid.UUID State string Open bool Limit int After *Cursor}
var currencyCode = regexp.MustCompile(`^[A-Z]{3}$`)
type shareJSON struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
func sharesJSON(shares []analytic.Share) []byte { stored := make([]shareJSON, 0, len(shares)) for _, s := range shares { stored = append(stored, shareJSON{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } raw, _ := json.Marshal(stored) return raw}
const documentSelect = ` SELECT h.id, h.kind, h.number, coalesce(h.supplier_party_id, h.customer_party_id), h.currency, h.fx_rate, h.document_date, h.due_date, h.location_id, h.net_total, h.net_base, h.closed_at, h.close_reason, h.closed_by, h.instance_id, h.prepared_by, h.revision, h.created_at, h.updated_at, i.state_key, s.semantic, EXISTS (SELECT 1 FROM trade_line l WHERE l.org_id = h.org_id AND l.document_id = h.id AND l.withdrawn_at IS NULL) AND NOT EXISTS (SELECT 1 FROM trade_line l WHERE l.org_id = h.org_id AND l.document_id = h.id AND l.withdrawn_at IS NULL AND trade_open_quantity(l.org_id, l.id) > 0) FROM trade_document h JOIN workflow_instance i ON i.org_id = h.org_id AND i.id = h.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE h.org_id = $1 AND i.org_id = $1 AND s.org_id = $1 `
func scanDocument(row pgx.Row) (Document, error) { var d Document err := row.Scan(&d.ID, &d.Kind, &d.Number, &d.PartyID, &d.Currency, &d.FXRate, &d.DocumentDate, &d.DueDate, &d.LocationID, &d.NetTotal, &d.NetBase, &d.ClosedAt, &d.CloseReason, &d.ClosedBy, &d.InstanceID, &d.PreparedBy, &d.Revision, &d.CreatedAt, &d.UpdatedAt, &d.State, &d.Semantic, &d.Fulfilled) if errors.Is(err, pgx.ErrNoRows) { return Document{}, ErrNotFound } return d, err}
func loadLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) ([]Line, error) { rows, err := tx.Query(ctx, ` SELECT id, line_no, item_id, description, quantity, uom_id, uom_factor, quantity_base, unit_price, net_amount, tax_code_id, shares, source_line_id, trade_open_quantity(org_id, id) FROM trade_line WHERE org_id = $1 AND document_id = $2 AND withdrawn_at IS NULL ORDER BY line_no`, org, id) if err != nil { return nil, fmt.Errorf("trade: lines: %w", err) } defer rows.Close() var out []Line for rows.Next() { var l Line var raw []byte if err := rows.Scan(&l.ID, &l.LineNo, &l.ItemID, &l.Description, &l.Quantity, &l.UOMID, &l.UOMFactor, &l.QuantityBase, &l.UnitPrice, &l.NetAmount, &l.TaxCodeID, &raw, &l.SourceLineID, &l.OpenQuantity); err != nil { return nil, err } var stored []shareJSON if err := json.Unmarshal(raw, &stored); err != nil { return nil, fmt.Errorf("trade: line shares: %w", err) } for _, s := range stored { l.Shares = append(l.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } out = append(out, l) } return out, rows.Err()}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Document, error) { org, err := orgOf(ctx) if err != nil { return Document{}, err } d, err := scanDocument(tx.QueryRow(ctx, documentSelect+`AND h.id = $2`, org, id)) if err != nil { return Document{}, err } d.Lines, err = loadLines(ctx, tx, org, id) return d, err}
// lock locks the header alone, then reads it in a statement of its own (a// joined FOR UPDATE that waited would read the workflow state as of before// the wait; receivables.lockDocument's reasoning).func lock(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Document, error) { org, err := orgOf(ctx) if err != nil { return Document{}, err } if _, err := tx.Exec(ctx, `SELECT 1 FROM trade_document WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id); err != nil { return Document{}, fmt.Errorf("trade: lock: %w", err) } return Get(ctx, tx, id)}
// List pages documents newest first. Lines are left nil.func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Document, *Cursor, error) { org, err := orgOf(ctx) if err != nil { return nil, nil, err } if f.Limit <= 0 || f.Limit > 200 { f.Limit = 50 } var afterAt *time.Time var afterID *uuid.UUID if f.After != nil { afterAt, afterID = &f.After.CreatedAt, &f.After.ID } rows, err := tx.Query(ctx, documentSelect+` AND ($2 = '' OR h.kind = $2) AND ($3::uuid IS NULL OR coalesce(h.supplier_party_id, h.customer_party_id) = $3) AND ($4 = '' OR i.state_key = $4) AND ($5::timestamptz IS NULL OR (h.created_at, h.id) < ($5, $6::uuid)) AND (NOT $8 OR (s.semantic NOT IN ('draft', 'pending', 'rejected', 'cancelled', 'dispute') AND h.closed_at IS NULL AND EXISTS (SELECT 1 FROM trade_line l WHERE l.org_id = h.org_id AND l.document_id = h.id AND l.withdrawn_at IS NULL AND trade_open_quantity(l.org_id, l.id) > 0))) ORDER BY h.created_at DESC, h.id DESC LIMIT $7`, org, f.Kind, f.PartyID, f.State, afterAt, afterID, f.Limit+1, f.Open) if err != nil { return nil, nil, fmt.Errorf("trade: list: %w", err) } defer rows.Close() var out []Document for rows.Next() { d, err := scanDocument(rows) if err != nil { return nil, nil, err } out = append(out, d) } if err := rows.Err(); err != nil { return nil, nil, err } var more *Cursor if len(out) > f.Limit { out = out[:f.Limit] last := out[len(out)-1] more = &Cursor{CreatedAt: last.CreatedAt, ID: last.ID} } return out, more, nil}
// priced is one checked line with its frozen factor and amounts.type priced struct { LineInput factor money.Rate net, netBase money.Amount shares []byte}
// accountType is what a line's shares are planned against (Ruling O1-16).func accountType(kind string) string { if PartySide(kind) == "customer" { return chart.TypeIncome } return chart.TypeExpense}
func checkParty(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, party *uuid.UUID) error { side := PartySide(kind) if side == "" { if party != nil { return fieldErr("party_id", "party_none", "this kind of document names no supplier or customer") } return nil } if party == nil { return fieldErr("party_id", "party_required", "choose the "+side) } table := map[string]string{"supplier": "party_supplier", "customer": "party_customer"}[side] var active bool err := tx.QueryRow(ctx, `SELECT is_active FROM `+table+` WHERE org_id = $1 AND party_id = $2`, org, *party).Scan(&active) if errors.Is(err, pgx.ErrNoRows) || (err == nil && !active) { return fieldErr("party_id", "party_active", "choose an active "+side) } return err}
func checkLocation(ctx context.Context, tx pgx.Tx, kind string, loc *uuid.UUID) error { if !NeedsLocation(kind) { if loc != nil { return fieldErr("location_id", "location_none", "this kind of document moves no stock, so it names no location") } return nil } if loc == nil { return fieldErr("location_id", "location_required", "choose a location") } l, err := inventory.GetLocation(ctx, tx, *loc) if errors.Is(err, inventory.ErrNotFound) || (err == nil && !l.IsActive) { return fieldErr("location_id", "location_active", "choose an active location") } return err}
// checkLine resolves the line's unit factor and prices it.func checkLine(ctx context.Context, tx pgx.Tx, kind string, fx money.Rate, i int, l LineInput) (priced, error) { f := func(field string) string { return fmt.Sprintf("lines[%d].%s", i, field) } factor := money.OneRate() if l.ItemID != nil { it, err := inventory.GetItem(ctx, tx, *l.ItemID) if errors.Is(err, inventory.ErrNotFound) || (err == nil && !it.IsActive) { return priced{}, fieldErr(f("item_id"), "item_active", "choose an active item") } if err != nil { return priced{}, err } factor, err = inventory.UOMFactor(ctx, tx, it.ID, l.UOMID) if errors.Is(err, inventory.ErrUOMNotForItem) { return priced{}, fieldErr(f("uom_id"), "uom_of_item", "choose the item's base unit or one of its other units") } if err != nil { return priced{}, err } if strings.TrimSpace(l.Description) == "" { l.Description = it.Names["en"] } } else { u, err := inventory.GetUOM(ctx, tx, l.UOMID) if errors.Is(err, inventory.ErrNotFound) || (err == nil && !u.IsActive) { return priced{}, fieldErr(f("uom_id"), "uom_active", "choose an active unit") } if err != nil { return priced{}, err } } l.Description = strings.TrimSpace(l.Description) switch { case l.Description == "" || len(l.Description) > 500 || recordtext.Check("description", l.Description) != nil: return priced{}, fieldErr(f("description"), "required", "a description in English or Bahasa Malaysia is required") case kind == KindAdjustment && l.Quantity.IsZero(): return priced{}, fieldErr(f("quantity"), "quantity_nonzero", "the quantity cannot be zero") case kind != KindAdjustment && !l.Quantity.IsPositive(): return priced{}, fieldErr(f("quantity"), "quantity_positive", "the quantity must be positive") case !strings.HasSuffix(l.Quantity.String(), "0000"): // Rate prints ten places; quantities keep six return priced{}, fieldErr(f("quantity"), "quantity_places", "at most six decimal places") case l.UnitPrice.IsNegative(): return priced{}, fieldErr(f("unit_price"), "nonnegative", "the price cannot be negative") } if l.TaxCodeID != nil { tc, err := chart.GetTaxCode(ctx, tx, *l.TaxCodeID) if errors.Is(err, chart.ErrNotFound) || (err == nil && (!tc.IsActive || tc.Usage == chart.TaxUsageWithholding)) { return priced{}, fieldErr(f("tax_code_id"), "tax_code_valid", "choose an active sales or purchase tax code") } if err != nil { return priced{}, err } } net := l.UnitPrice.MulRateCents(l.Quantity) netBase := net.MulRate(fx) if len(l.Shares) > 0 { if _, err := analytic.Plan(ctx, tx, accountType(kind), netBase, l.Shares, false); err != nil { return priced{}, fieldErr(f("shares"), "shares_valid", err.Error()) } } return priced{LineInput: l, factor: factor, net: net, netBase: netBase, shares: sharesJSON(l.Shares)}, nil}
// check normalizes in and prices its lines. The chain rules on// SourceLineID are the database's (mapped by mapPg when the lines are written).func check(ctx context.Context, tx pgx.Tx, org uuid.UUID, in *DocumentInput, base string, today time.Time) ([]priced, error) { in.Currency = strings.ToUpper(strings.TrimSpace(in.Currency)) switch { case !currencyCode.MatchString(in.Currency): return nil, fieldErr("currency", "currency_code", "a three-letter currency code, such as MYR") case in.Currency == base && in.FXRate != nil && in.FXRate.Cmp(money.OneRate()) != 0: return nil, fieldErr("fx_rate", "fx_rate_identity", "a document in the base currency has a rate of 1") case in.Currency != base && in.FXRate == nil: return nil, fieldErr("fx_rate", "fx_rate_required", "a foreign-currency document needs its rate to "+base) case in.FXRate != nil && !in.FXRate.IsPositive(): return nil, fieldErr("fx_rate", "fx_rate_positive", "the rate must be positive") case in.DueDate != nil && in.DueDate.Before(today): return nil, fieldErr("due_date", "due_after_date", "the date cannot be before the document's own date") case len(in.Lines) == 0 || len(in.Lines) > 200: return nil, fieldErr("lines", "lines_count", "a document has 1 to 200 lines") } if in.Currency == base { one := money.OneRate() in.FXRate = &one } if err := checkParty(ctx, tx, org, in.Kind, in.PartyID); err != nil { return nil, err } if err := checkLocation(ctx, tx, in.Kind, in.LocationID); err != nil { return nil, err } out := make([]priced, 0, len(in.Lines)) for i, l := range in.Lines { p, err := checkLine(ctx, tx, in.Kind, *in.FXRate, i, l) if err != nil { return nil, err } out = append(out, p) } return out, nil}
// writeLines withdraws the current lines and inserts lines (Ruling O1-7),// returning the net total and its base.func writeLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, kind string, now time.Time, lines []priced) (money.Amount, money.Amount, error) { if _, err := tx.Exec(ctx, `UPDATE trade_line SET withdrawn_at = $3 WHERE org_id = $1 AND document_id = $2 AND withdrawn_at IS NULL`, org, id, now); err != nil { return money.Zero(), money.Zero(), mapPg(err) } total, base := money.Zero(), money.Zero() for i, l := range lines { if _, err := tx.Exec(ctx, ` INSERT INTO trade_line (org_id, document_id, document_kind, line_no, item_id, description, quantity, uom_id, uom_factor, unit_price, net_amount, tax_code_id, shares, source_line_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, org, id, kind, i+1, l.ItemID, l.Description, l.Quantity, l.UOMID, l.factor, l.UnitPrice, l.net, l.TaxCodeID, l.shares, l.SourceLineID, now); err != nil { return money.Zero(), money.Zero(), mapPg(err) } total, base = total.Add(l.net), base.Add(l.netBase) } return total, base, nil}
// reprice writes the lines and totals and tells the engine the base amount.func reprice(ctx context.Context, tx pgx.Tx, c clock.Clock, org, id uuid.UUID, kind string, lines []priced, now time.Time) (Document, error) { total, base, err := writeLines(ctx, tx, org, id, kind, now, lines) if err != nil { return Document{}, err } if _, err := tx.Exec(ctx, `UPDATE trade_document SET net_total = $3, net_base = $4, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, id, total, base, now); err != nil { return Document{}, mapPg(err) } d, err := Get(ctx, tx, id) if err != nil { return Document{}, err } amount := base if amount.IsNegative() { // an adjustment's issue lines; an approval band starts at zero amount = money.Zero() } return d, workflow.SetAmount(ctx, tx, c, d.InstanceID, amount)}
func partyColumns(kind string, party *uuid.UUID) (supplier, customer *uuid.UUID) { switch PartySide(kind) { case "supplier": return party, nil case "customer": return nil, party } return nil, nil}
// Create files a draft of in.Kind, numbered (Ruling O1-1) and dated today// (Ruling O1-2). ORDER: checks → number → workflow.Start → header → lines// (each child line locks its source line: 00701) → audit.func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, actor uuid.UUID, in DocumentInput) (Document, error) { org, err := orgOf(ctx) if err != nil { return Document{}, err } prefix, ok := prefixes[in.Kind] if !ok { return Document{}, fieldErr("kind", "kind_known", "one of the chain's document kinds") } profile, err := company.Get(ctx, tx) if err != nil { return Document{}, err } today := clock.Today(ctx, c) lines, err := check(ctx, tx, org, &in, profile.BaseCurrency, today) if err != nil { return Document{}, err } n, err := numbering.Allocate(ctx, tx, in.Kind, today.Year()) if err != nil { return Document{}, err } id := uuid.New() inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: in.Kind, SourceID: id, Reporter: actor, Amount: money.Zero()}) if err != nil { return Document{}, err } supplier, customer := partyColumns(in.Kind, in.PartyID) now := c.Now() if _, err := tx.Exec(ctx, ` INSERT INTO trade_document (id, org_id, kind, number, supplier_party_id, customer_party_id, currency, fx_rate, document_date, due_date, location_id, instance_id, prepared_by, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14)`, id, org, in.Kind, numbering.Format(prefix, today.Year(), n), supplier, customer, in.Currency, *in.FXRate, today, in.DueDate, in.LocationID, inst.ID, actor, now); err != nil { return Document{}, fmt.Errorf("trade: create: %w", mapPg(err)) } d, err := reprice(ctx, tx, c, org, id, in.Kind, lines, now) if err != nil { return Document{}, err } return d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: id, Action: "create", After: d})}
// Update replaces a draft's terms and lines; only its preparer may, at the// revision they read. ORDER: header FOR UPDATE → checks → header → lines → audit.func Update(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, in DocumentInput) (Document, error) { org, err := orgOf(ctx) if err != nil { return Document{}, err } before, err := lock(ctx, tx, id) if err != nil { return Document{}, err } switch { case before.PreparedBy != actor: return Document{}, ErrNotPreparer case before.Revision != expectedRevision: return Document{}, &RevisionMismatchError{Current: before.Revision} case before.Semantic != posting.Draft: return Document{}, ErrNotDraft } in.Kind = before.Kind profile, err := company.Get(ctx, tx) if err != nil { return Document{}, err } lines, err := check(ctx, tx, org, &in, profile.BaseCurrency, before.DocumentDate) if err != nil { return Document{}, err } supplier, customer := partyColumns(in.Kind, in.PartyID) now := c.Now() if _, err := tx.Exec(ctx, ` UPDATE trade_document SET supplier_party_id = $3, customer_party_id = $4, currency = $5, fx_rate = $6, due_date = $7, location_id = $8, revision = revision + 1, updated_at = $9 WHERE org_id = $1 AND id = $2`, org, id, supplier, customer, in.Currency, *in.FXRate, in.DueDate, in.LocationID, now); err != nil { return Document{}, fmt.Errorf("trade: update: %w", mapPg(err)) } after, err := reprice(ctx, tx, c, org, id, in.Kind, lines, now) if err != nil { return Document{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: id, Action: "update", Before: before, After: after})}- Step 6: Write the transition wrapper
Create internal/platform/trade/transition.go:
package trade
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
type TransitionInput struct { ID uuid.UUID From, To string Actor uuid.UUID Reason string}
// target reads the state an edge enters, without locking the instance.func target(ctx context.Context, tx pgx.Tx, instanceID uuid.UUID, to string) (workflow.StateSpec, error) { inst, err := workflow.Get(ctx, tx, instanceID) if err != nil { return workflow.StateSpec{}, err } def, err := workflow.Load(ctx, tx, inst.DefinitionID) if err != nil { return workflow.StateSpec{}, err } s, ok := def.State(to) if !ok { return workflow.StateSpec{}, workflow.ErrNoSuchEdge } return s, nil}
// lockLines locks a document's live lines in id order: a child line's// insert locks its source line (00701), so a cancel or close holding them// makes a concurrent child wait and then see the new state.func lockLines(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) error { if _, err := tx.Exec(ctx, `SELECT 1 FROM trade_line WHERE org_id = $1 AND document_id = $2 AND withdrawn_at IS NULL ORDER BY id FOR UPDATE`, org, id); err != nil { return fmt.Errorf("trade: lock lines: %w", err) } return nil}
// liveChildren counts the live lines drawn from a document (Ruling O1-4).func liveChildren(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (int, error) { var n int err := tx.QueryRow(ctx, ` SELECT count(*) FROM trade_line c JOIN trade_line p ON p.org_id = c.org_id AND p.id = c.source_line_id JOIN trade_document d ON d.org_id = c.org_id AND d.id = c.document_id WHERE c.org_id = $1 AND p.document_id = $2 AND c.withdrawn_at IS NULL AND trade_semantic(d.org_id, d.instance_id) IS DISTINCT FROM 'cancelled'`, org, id).Scan(&n) return n, err}
// Transition moves a chain document along its workflow. A document others// draw from cannot be cancelled (Ruling O1-7): cancel them, or close it.// O2 and O3 wrap this for their kinds' own checks. ORDER: in a one-member// organization, organization FOR UPDATE (workflow.LockSoleOwner, Ruling// O1-18) → header FOR UPDATE → on a cancel, its live lines FOR UPDATE in id// order → workflow.Transition (Poster, when a kind posts) → audit, flagged// when the decision rests on the sole-owner exception.func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Document, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Document{}, workflow.Decision{}, err } if err := workflow.LockSoleOwner(ctx, tx); err != nil { return Document{}, workflow.Decision{}, err } d, err := lock(ctx, tx, in.ID) if err != nil { return Document{}, workflow.Decision{}, err } to, err := target(ctx, tx, d.InstanceID, in.To) if err != nil { return Document{}, workflow.Decision{}, err } if to.Semantic == posting.Cancelled { if err := lockLines(ctx, tx, org, d.ID); err != nil { return Document{}, workflow.Decision{}, err } n, err := liveChildren(ctx, tx, org, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } if n > 0 { return Document{}, workflow.Decision{}, ErrHasChildren } } dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: d.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Document{}, workflow.Decision{}, err } after, err := Get(ctx, tx, d.ID) if err != nil { return Document{}, workflow.Decision{}, err } return after, dec, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: d.ID, Action: "transition", Before: map[string]string{"state": d.State}, After: workflow.SoleOwnerAudit(after.State, dec)})}- Step 6b: Let the sole-owner exception cover
committed
In internal/platform/workflow/soleowner.go, in soleOwnerEdge, replace
void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual return (m.void && void) || (m.issue && to.Semantic == posting.Accrual) || (m.payout && to.Semantic == posting.Payout)with
void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual // Phase 4 O1 (Ruling O1-18): a commitment's approval and its cancel are // a second person's decisions too; the cancel reverses nothing. void = void || (from.Semantic == posting.Committed && to.Semantic == posting.Cancelled) issue := to.Semantic == posting.Accrual || to.Semantic == posting.Committed return (m.void && void) || (m.issue && issue) || (m.payout && to.Semantic == posting.Payout)(O2’s Task 1 edits these same lines for effect; the merged form keeps both conditions.) Update the doc comment of AllowSoleOwnerException to say the commitment kinds opt in too.
- Step 7: Write the fixture
Create internal/platform/trade/tradetest/env.go:
// Package tradetest is the chain's test world: expensestest's provisioned// Malaysian organization (Owner holds the OWNER seat, Staff reports to it)// with suppliers, customers, items, locations and committed documents one// call away. O2 and O3 build their fixtures on it.package tradetest
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/workflow")
type Env struct{ *expensestest.Env }
func New(t *testing.T) *Env { t.Helper(); return &Env{Env: expensestest.New(t)} }
// Another is a second organization on the same database.func (e *Env) Another(t *testing.T) *Env { t.Helper(); return &Env{Env: e.Env.Another(t)} }
// As is e.Ctx with member as the audited actor.func (e *Env) As(member uuid.UUID) context.Context { return audit.WithActor(e.Ctx, e.Org, member) }
// UOM is the seeded global unit of that UN/ECE key (C62, KGM, BX, HUR, ...).func (e *Env) UOM(t *testing.T, key string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM uom WHERE org_id IS NULL AND key = $1`, key).Scan(&id)) return id}
func (e *Env) Account(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.GetAccountByCode(ctx, tx, code) id = a.ID return err }) return id}
// Supplier is an active MYR supplier created by Staff; its party id.func (e *Env) Supplier(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { s, err := payables.CreateSupplier(ctx, tx, e.Clock, payables.SupplierInput{Code: code, PartyKind: "organization", LegalName: code + " Sdn Bhd", CountryCode: "MY", PaymentTermsDays: 30, Currency: "MYR", IsActive: true}) id = s.PartyID return err })) return id}
// Customer is an active MYR customer created by Staff; its party id.func (e *Env) Customer(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { c, err := receivables.CreateCustomer(ctx, tx, e.Clock, receivables.CustomerInput{Code: code, PartyKind: "organization", LegalName: code + " Sdn Bhd", CountryCode: "MY", Address: receivables.Address{Line1: "Lot 5, Jalan Niaga", City: "Shah Alam", Postcode: "40000", State: "Selangor"}, PaymentTermsDays: 30, Currency: "MYR", IsActive: true}) id = c.PartyID return err })) return id}
// StockItem is a stock item in C62 named "<code> stock item", on inventory// 1560 and cost of sales 5100 (O2 adds a real inventory account), with the// given alternate units.func (e *Env) StockItem(t *testing.T, code string, alternates ...inventory.ItemUOMInput) inventory.Item { t.Helper() inv, cogs := e.Account(t, "1560"), e.Account(t, "5100") var it inventory.Item e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error it, err = inventory.CreateItem(ctx, tx, e.Clock, inventory.ItemInput{Code: code, Type: inventory.TypeStock, Names: inventory.Names{"en": code + " stock item"}, BaseUOMID: e.UOM(t, "C62"), InventoryAccountID: &inv, COGSAccountID: &cogs, IsActive: true, UOMs: alternates}) return err }) return it}
func (e *Env) Location(t *testing.T, code string) inventory.Location { t.Helper() var l inventory.Location e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error l, err = inventory.CreateLocation(ctx, tx, e.Clock, inventory.LocationInput{Code: code, Names: inventory.Names{"en": code + " warehouse"}, IsActive: true}) return err }) return l}
// PublishStub publishes draft → done (custom, terminal, by the preparer) for// a kind O1 does not seed, so a test can create a receipt, delivery, return// or adjustment before O2/O3 register the real graph.func (e *Env) PublishStub(t *testing.T, kind string) { t.Helper() g := workflow.Graph{Kind: kind, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ {Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: workflow.Labels{"en": "Draft"}}, {Key: "done", Semantic: posting.Custom, DisplayOrder: 2, IsTerminal: true, Labels: workflow.Labels{"en": "Done"}}, }, Edges: []workflow.EdgeSpec{{From: "draft", To: "done", Gate: workflow.GateReporter, ActionKey: "post", DisplayOrder: 1, Labels: workflow.Labels{"en": "Post"}}}, } e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.PublishGraph(ctx, tx, e.Clock, g, nil) return err })}
// TextLine is a line with no item: qty of uom at price.func TextLine(uom uuid.UUID, qty, price string) trade.LineInput { return trade.LineInput{Description: "Office supplies", Quantity: money.MustParseRate(qty), UOMID: uom, UnitPrice: money.MustParse(price)}}
// Create files a draft as Staff.func (e *Env) Create(t *testing.T, in trade.DocumentInput) trade.Document { t.Helper() var d trade.Document require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.Create(ctx, tx, e.Clock, e.Staff, in) return err })) return d}
// Move takes one edge as by, with a reason.func (e *Env) Move(t *testing.T, d trade.Document, by uuid.UUID, from, to string) trade.Document { t.Helper() require.NoError(t, e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error d, _, err = trade.Transition(ctx, tx, e.Clock, trade.TransitionInput{ID: d.ID, From: from, To: to, Actor: by, Reason: "test"}) return err })) return d}
// Commit takes a draft to its committed state: a quotation issued by Staff,// any other commitment kind submitted by Staff and approved by Owner (the// OWNER seat, and Staff's superior).func (e *Env) Commit(t *testing.T, d trade.Document) trade.Document { t.Helper() if d.Kind == trade.KindQuotation { return e.Move(t, d, e.Staff, "draft", "issued") } return e.Move(t, e.Move(t, d, e.Staff, "draft", "pending_approval"), e.Owner, "pending_approval", "approved")}- Step 8: Run the trade tests
Run: go test ./internal/platform/trade/... -count=1
Expected: PASS.
- Step 9: Commit
git add internal/platform/tradegit commit -m "Phase 4 O1: trade documents, commitment workflows and numbering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: Copy-forward, open quantity, close, and the chain’s drift tests
Section titled “Task 6: Copy-forward, open quantity, close, and the chain’s drift tests”Files:
- Create:
internal/platform/trade/chain.go - Test:
internal/platform/trade/chain_test.go(the spec’s O1 drift tests, at the database),draft_test.go,close_test.go
Interfaces:
-
Consumes: Task 5 (
Get,lock,lockLines,liveChildren,actionable,next, sentinels,tradetest). -
Produces:
trade.Draft(ctx, tx, sourceDocID uuid.UUID, targetKind string) (DocumentInput, error),trade.OpenQuantity(ctx, tx, lineID uuid.UUID) (money.Rate, error),trade.Close(ctx, tx, c, id, actor uuid.UUID, reason string) (Document, error). -
Step 1: Write the drift tests (they exercise Task 2’s guard)
Create internal/platform/trade/chain_test.go:
package trade_test
// The spec's O1 drift tests, at the database: a chain link outside the// allowed pairs, across organizations or parties, or past the source's open// quantity is refused. Each inserts a child line straight into trade_line,// so the assertion is about the guard, not about trade.Create; each shows// the same insert accepted first.
import ( "context" "errors" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest")
const insertChild = ` INSERT INTO trade_line (org_id, document_id, document_kind, line_no, description, quantity, uom_id, source_line_id, created_at) VALUES ($1, $2, $3, $4, 'Drawn line', $5, $6, $7, now())`
func constraint(err error) string { var pg *pgconn.PgError if errors.As(err, &pg) { return pg.ConstraintName } return ""}
// child inserts one child line as the tenant.func child(e *tradetest.Env, doc trade.Document, lineNo int, src, uom uuid.UUID, qty string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, insertChild, e.Org, doc.ID, doc.Kind, lineNo, money.MustParseRate(qty), uom, src) return err })}
// committedRequisition is a requisition of one text line of qty, approved.func committedRequisition(t *testing.T, e *tradetest.Env, qty string) trade.Document { t.Helper() return e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(e.UOM(t, "C62"), qty, "5.00")}}))}
func draftOrder(t *testing.T, e *tradetest.Env, supplier uuid.UUID) trade.Document { t.Helper() return e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &supplier, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(e.UOM(t, "C62"), "1", "5.00")}})}
func TestTheChainRefusesALinkOutsideTheAllowedPairs(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") src := committedRequisition(t, e, "10").Lines[0].ID order := draftOrder(t, e, e.Supplier(t, "BEKAL")) cust := e.Customer(t, "KEDAI") so := e.Create(t, trade.DocumentInput{Kind: trade.KindSalesOrder, PartyID: &cust, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "5.00")}})
require.NoError(t, child(e, order, 50, src, each, "1"), "PRESENCE: requisition → order is an allowed pair") require.Equal(t, "trade_line_allowed_pair", constraint(child(e, so, 50, src, each, "1")))}
func TestTheChainRefusesALinkAcrossOrganizations(t *testing.T) { e := tradetest.New(t) f := e.Another(t) each := e.UOM(t, "C62") src := committedRequisition(t, e, "10").Lines[0].ID own := draftOrder(t, e, e.Supplier(t, "BEKAL")) theirs := draftOrder(t, f, f.Supplier(t, "BEKAL")) ctx := context.Background()
// As the migration role, past RLS: only the key can refuse it. _, err := e.H.Admin.Exec(ctx, insertChild, e.Org, own.ID, own.Kind, 50, money.MustParseRate("1"), each, src) require.NoError(t, err, "PRESENCE: the same link within the organization is accepted") _, err = e.H.Admin.Exec(ctx, insertChild, f.Org, theirs.ID, theirs.Kind, 50, money.MustParseRate("1"), each, src) require.Equal(t, "trade_line_source_fk", constraint(err))}
func TestTheChainRefusesALinkAcrossParties(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") kedai, other := e.Customer(t, "KEDAI"), e.Customer(t, "LAIN") q := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindQuotation, PartyID: &kedai, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "10", "5.00")}})) so := func(party uuid.UUID) trade.Document { return e.Create(t, trade.DocumentInput{Kind: trade.KindSalesOrder, PartyID: &party, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "5.00")}}) } require.NoError(t, child(e, so(kedai), 50, q.Lines[0].ID, each, "1"), "PRESENCE: the same customer's order may draw") require.Equal(t, "trade_line_same_party", constraint(child(e, so(other), 50, q.Lines[0].ID, each, "1")))}
func TestTheChainRefusesALinkPastTheOpenQuantity(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") req := committedRequisition(t, e, "10") src := req.Lines[0].ID take := func(qty string) (trade.Document, error) { line := tradetest.TextLine(each, qty, "5.00") line.SourceLineID = &src return create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{line}}) } first, err := take("6") require.NoError(t, err, "PRESENCE: within the open quantity") _, err = take("5") require.ErrorIs(t, err, trade.ErrOverOpenQuantity) require.Equal(t, "trade_line_open_quantity", constraint(child(e, draftOrder(t, e, s), 50, src, each, "5")), "the database refuses it, not only the Go path") _, err = take("4") require.NoError(t, err)
open := func() money.Rate { var q money.Rate e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error q, err = trade.OpenQuantity(ctx, tx, src) return err }) return q } require.True(t, open().IsZero())
// Spec O1.1 "Reversal": a withdrawn child gives its quantity back. e.Move(t, first, e.Staff, "draft", "withdrawn") require.Zero(t, money.MustParseRate("6").Cmp(open()))}
// Ruling O1-6: a draft or pending source feeds nothing.func TestAnUnapprovedSourceFeedsNothing(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") approved := committedRequisition(t, e, "10") pending := e.Move(t, e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "10", "5.00")}}), e.Staff, "draft", "pending_approval") order := draftOrder(t, e, e.Supplier(t, "BEKAL")) require.NoError(t, child(e, order, 50, approved.Lines[0].ID, each, "1"), "PRESENCE: an approved source feeds") require.Equal(t, "trade_line_source_actionable", constraint(child(e, order, 51, pending.Lines[0].ID, each, "1")))}
// Ruling O1-7: a document others draw from is not cancelled; its children// are, first.func TestADocumentWithLiveChildrenCannotBeCancelled(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") cancel := func(d trade.Document) error { return e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := trade.Transition(ctx, tx, e.Clock, trade.TransitionInput{ID: d.ID, From: "approved", To: "cancelled", Actor: e.Owner, Reason: "No longer needed"}) return err }) } alone := committedRequisition(t, e, "10") require.NoError(t, cancel(alone), "PRESENCE: with nothing drawn from it, the Owner cancels it")
req := committedRequisition(t, e, "10") src := req.Lines[0].ID line := tradetest.TextLine(each, "2", "5.00") line.SourceLineID = &src order := e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{line}}) require.ErrorIs(t, cancel(req), trade.ErrHasChildren)
e.Move(t, order, e.Staff, "draft", "withdrawn") require.NoError(t, cancel(req), "the child withdrawn, the source cancels")}Create internal/platform/trade/draft_test.go:
package trade_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest")
func draft(t *testing.T, e *tradetest.Env, src uuid.UUID, kind string) (trade.DocumentInput, error) { t.Helper() var in trade.DocumentInput err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error in, err = trade.Draft(ctx, tx, src, kind) return err }) return in, err}
// Spec O1.1 "Copy-forward", Ruling O1-8: a draft of the next kind at each// line's open quantity, in the source's unit when that is exact, else in// the item's base unit.func TestDraftCopiesForwardEachLinesOpenQuantity(t *testing.T) { e := tradetest.New(t) each, box := e.UOM(t, "C62"), e.UOM(t, "BX") it := e.StockItem(t, "PAPER-A4", inventory.ItemUOMInput{UOMID: box, Factor: money.MustParseRate("12")}) cust := e.Customer(t, "KEDAI") q := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindQuotation, PartyID: &cust, Currency: "MYR", Lines: []trade.LineInput{ {ItemID: &it.ID, Quantity: money.MustParseRate("2"), UOMID: box, UnitPrice: money.MustParse("120.00")}, tradetest.TextLine(each, "3", "50.00"), }}))
in, err := draft(t, e, q.ID, trade.KindSalesOrder) require.NoError(t, err) require.Equal(t, trade.KindSalesOrder, in.Kind) require.Equal(t, cust, *in.PartyID, "the party carries forward") require.Len(t, in.Lines, 2) require.Equal(t, box, in.Lines[0].UOMID) require.Zero(t, money.MustParseRate("2").Cmp(in.Lines[0].Quantity)) require.Equal(t, q.Lines[0].ID, *in.Lines[0].SourceLineID) require.Equal(t, "120.0000", in.Lines[0].UnitPrice.String())
// A first order takes one box: 12 base units remain, exactly one box. in.Lines[0].Quantity = money.MustParseRate("1") in.Lines = in.Lines[:1] e.Create(t, in) in, err = draft(t, e, q.ID, trade.KindSalesOrder) require.NoError(t, err) require.Equal(t, box, in.Lines[0].UOMID) require.Zero(t, money.MustParseRate("1").Cmp(in.Lines[0].Quantity)) require.Zero(t, money.MustParseRate("3").Cmp(in.Lines[1].Quantity), "the untouched line is whole")
// A second takes five units: seven remain, not a whole box, so the draft // falls back to the base unit. in.Lines = []trade.LineInput{{ItemID: &it.ID, Quantity: money.MustParseRate("5"), UOMID: each, UnitPrice: money.MustParse("10.00"), SourceLineID: in.Lines[0].SourceLineID}} e.Create(t, in) in, err = draft(t, e, q.ID, trade.KindSalesOrder) require.NoError(t, err) require.Equal(t, each, in.Lines[0].UOMID) require.Zero(t, money.MustParseRate("7").Cmp(in.Lines[0].Quantity))}
// Ruling O1-8: a line that falls back to the base unit is priced per base// unit — 2 BX @ 24.00 (a box is 12 C62) with 13 C62 drawn leaves 11 C62 @// 2.00, not @ 24.00 (24.00 ÷ 12, money.Amount.MulDiv, 4 places).func TestDraftPricesABaseUnitFallbackPerBaseUnit(t *testing.T) { e := tradetest.New(t) each, box := e.UOM(t, "C62"), e.UOM(t, "BX") it := e.StockItem(t, "PAPER-A4", inventory.ItemUOMInput{UOMID: box, Factor: money.MustParseRate("12")}) cust := e.Customer(t, "KEDAI") q := e.Commit(t, e.Create(t, trade.DocumentInput{Kind: trade.KindQuotation, PartyID: &cust, Currency: "MYR", Lines: []trade.LineInput{{ItemID: &it.ID, Quantity: money.MustParseRate("2"), UOMID: box, UnitPrice: money.MustParse("24.00")}}})) in, err := draft(t, e, q.ID, trade.KindSalesOrder) require.NoError(t, err) require.Equal(t, "24.0000", in.Lines[0].UnitPrice.String(), "PRESENCE: in the source's unit, its price")
in.Lines[0].Quantity, in.Lines[0].UOMID, in.Lines[0].UnitPrice = money.MustParseRate("13"), each, money.MustParse("2.00") e.Create(t, in) in, err = draft(t, e, q.ID, trade.KindSalesOrder) require.NoError(t, err) require.Equal(t, each, in.Lines[0].UOMID) require.Zero(t, money.MustParseRate("11").Cmp(in.Lines[0].Quantity)) require.Equal(t, "2.0000", in.Lines[0].UnitPrice.String())}
func TestDraftRefusesWhatCannotFollow(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") req := committedRequisition(t, e, "10") _, err := draft(t, e, req.ID, trade.KindOrder) require.NoError(t, err, "PRESENCE: requisition → order")
_, err = draft(t, e, req.ID, trade.KindSalesOrder) require.ErrorIs(t, err, trade.ErrPairNotAllowed)
pending := e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "1.00")}}) _, err = draft(t, e, pending.ID, trade.KindOrder) require.ErrorIs(t, err, trade.ErrNotApproved)
s := e.Supplier(t, "BEKAL") all := tradetest.TextLine(each, "10", "5.00") all.SourceLineID = &req.Lines[0].ID e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{all}}) _, err = draft(t, e, req.ID, trade.KindOrder) require.ErrorIs(t, err, trade.ErrNothingOpen)}Create internal/platform/trade/close_test.go:
package trade_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest")
func closeDoc(e *tradetest.Env, id uuid.UUID, reason string) (trade.Document, error) { var d trade.Document err := e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { var err error d, err = trade.Close(ctx, tx, e.Clock, id, e.Owner, reason) return err }) return d, err}
// Spec O1.1 "Close": a member closes a document by hand with a reason,// audited; a closed document takes no new children.func TestAManualCloseIsAuditedAndStopsNewChildren(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") req := committedRequisition(t, e, "10") src := req.Lines[0].ID take := func() error { line := tradetest.TextLine(each, "2", "5.00") line.SourceLineID = &src _, err := create(e, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{line}}) return err } require.NoError(t, take(), "PRESENCE: before the close, an order draws from it")
_, err := closeDoc(e, req.ID, " ") require.Equal(t, "reason/required", fieldOf(err)) closed, err := closeDoc(e, req.ID, "Supplier discontinued the item") require.NoError(t, err) require.NotNil(t, closed.ClosedAt) require.True(t, closed.Closed()) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'trade_document' AND entity_id = $2 AND action = 'close'`, e.Org, req.ID))
require.ErrorIs(t, take(), trade.ErrSourceClosed) _, err = draft(t, e, req.ID, trade.KindOrder) require.ErrorIs(t, err, trade.ErrSourceClosed) _, err = closeDoc(e, req.ID, "Again") require.ErrorIs(t, err, trade.ErrAlreadyClosed)}
func TestOnlyAnApprovedDocumentThatFeedsAnotherIsClosed(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") d := e.Create(t, trade.DocumentInput{Kind: trade.KindRequisition, Currency: "MYR", Lines: []trade.LineInput{tradetest.TextLine(each, "1", "1.00")}}) _, err := closeDoc(e, d.ID, "Not needed") require.ErrorIs(t, err, trade.ErrNotApproved)
e.PublishStub(t, trade.KindAdjustment) loc := e.Location(t, "KL").ID adj := e.Create(t, trade.DocumentInput{Kind: trade.KindAdjustment, Currency: "MYR", LocationID: &loc, Lines: []trade.LineInput{tradetest.TextLine(each, "-1", "0")}}) _, err = closeDoc(e, adj.ID, "Not needed") require.ErrorIs(t, err, trade.ErrNotClosable)}
// Ruling O1-5: a document every line of which is drawn in full reads closed.func TestAFulfilledDocumentReadsClosed(t *testing.T) { e := tradetest.New(t) each := e.UOM(t, "C62") s := e.Supplier(t, "BEKAL") req := committedRequisition(t, e, "10") require.False(t, req.Closed(), "PRESENCE: open while its line is open") all := tradetest.TextLine(each, "10", "5.00") all.SourceLineID = &req.Lines[0].ID e.Create(t, trade.DocumentInput{Kind: trade.KindOrder, PartyID: &s, Currency: "MYR", Lines: []trade.LineInput{all}}) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { got, err := trade.Get(ctx, tx, req.ID) require.True(t, got.Fulfilled) require.True(t, got.Closed()) require.Nil(t, got.ClosedAt, "fulfilment is derived, not stored") return err })}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/trade/ -count=1
Expected: FAIL to compile — undefined: trade.Draft, trade.OpenQuantity, trade.Close.
- Step 3: Write copy-forward, open quantity and close
Create internal/platform/trade/chain.go:
package trade
import ( "context" "encoding/json" "errors" "fmt" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting/analytic")
// Draft builds targetKind's draft from a source document: one line per// source line with open quantity, at that quantity (spec O1.1// "Copy-forward"), carrying item, description, price, tax code, shares and// the link. The unit stays the source line's when the open base quantity is// a whole multiple of its factor at six places, else it is the item's base// unit (Ruling O1-8). The party, currency and rate carry forward; the// caller sets the location and due date and passes it to Create. Draft// writes nothing.func Draft(ctx context.Context, tx pgx.Tx, sourceDocID uuid.UUID, targetKind string) (DocumentInput, error) { org, err := orgOf(ctx) if err != nil { return DocumentInput{}, err } src, err := Get(ctx, tx, sourceDocID) if err != nil { return DocumentInput{}, err } switch { case next[src.Kind] != targetKind: return DocumentInput{}, ErrPairNotAllowed case !actionable(src.Semantic): return DocumentInput{}, ErrNotApproved case src.ClosedAt != nil: return DocumentInput{}, ErrSourceClosed } rows, err := tx.Query(ctx, ` SELECT l.id, l.item_id, l.description, l.unit_price, l.tax_code_id, l.shares, whole, l.uom_factor, CASE WHEN whole THEN l.uom_id ELSE coalesce(i.base_uom_id, l.uom_id) END, CASE WHEN whole THEN round(o.open / l.uom_factor, 6) ELSE o.open END FROM trade_line l CROSS JOIN LATERAL (SELECT trade_open_quantity(l.org_id, l.id) AS open) o CROSS JOIN LATERAL (SELECT round(round(o.open / l.uom_factor, 6) * l.uom_factor, 6) = o.open AS whole) w LEFT JOIN item i ON i.org_id = l.org_id AND i.id = l.item_id WHERE l.org_id = $1 AND l.document_id = $2 AND l.withdrawn_at IS NULL AND o.open > 0 ORDER BY l.line_no`, org, src.ID) if err != nil { return DocumentInput{}, fmt.Errorf("trade: draft: %w", err) } defer rows.Close() var lines []LineInput for rows.Next() { var l LineInput var id uuid.UUID var raw []byte var whole bool var factor money.Rate if err := rows.Scan(&id, &l.ItemID, &l.Description, &l.UnitPrice, &l.TaxCodeID, &raw, &whole, &factor, &l.UOMID, &l.Quantity); err != nil { return DocumentInput{}, err } if !whole { // Fallen back to the base unit: the price is per base unit, the // source's unit price ÷ its factor, exact decimal arithmetic rounded // half away from zero to 4 places (money.Scale, NUMERIC(19,4)). if l.UnitPrice, err = l.UnitPrice.MulDiv(money.OneRate(), factor); err != nil { return DocumentInput{}, err } } var stored []shareJSON if err := json.Unmarshal(raw, &stored); err != nil { return DocumentInput{}, fmt.Errorf("trade: draft shares: %w", err) } for _, s := range stored { l.Shares = append(l.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } l.SourceLineID = &id lines = append(lines, l) } if err := rows.Err(); err != nil { return DocumentInput{}, err } if len(lines) == 0 { return DocumentInput{}, ErrNothingOpen } fx := src.FXRate return DocumentInput{Kind: targetKind, PartyID: src.PartyID, Currency: src.Currency, FXRate: &fx, Lines: lines}, nil}
// OpenQuantity is a line's base quantity less its live children's (spec// O1.1; 00701's trade_open_quantity).func OpenQuantity(ctx context.Context, tx pgx.Tx, lineID uuid.UUID) (money.Rate, error) { org, err := orgOf(ctx) if err != nil { return money.Rate{}, err } var q money.Rate err = tx.QueryRow(ctx, `SELECT trade_open_quantity($1, id) FROM trade_line WHERE org_id = $1 AND id = $2`, org, lineID).Scan(&q) if errors.Is(err, pgx.ErrNoRows) { return money.Rate{}, ErrNotFound } return q, err}
// Close closes an approved document by hand, with a reason, audited (spec// O1.1 "Close"); it then takes no new children. Only a kind that feeds// another has anything to close. ORDER: header FOR UPDATE → its live lines// FOR UPDATE in id order → update → audit.func Close(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, reason string) (Document, error) { org, err := orgOf(ctx) if err != nil { return Document{}, err } before, err := lock(ctx, tx, id) if err != nil { return Document{}, err } reason = strings.TrimSpace(reason) switch { case next[before.Kind] == "": return Document{}, ErrNotClosable case !actionable(before.Semantic): return Document{}, ErrNotApproved case before.ClosedAt != nil: return Document{}, ErrAlreadyClosed case reason == "" || len(reason) > 500 || recordtext.Check("reason", reason) != nil: return Document{}, fieldErr("reason", "required", "a reason, in English or Bahasa Malaysia, of up to 500 characters") } if err := lockLines(ctx, tx, org, id); err != nil { return Document{}, err } now := c.Now() if _, err := tx.Exec(ctx, `UPDATE trade_document SET closed_at = $3, close_reason = $4, closed_by = $5, revision = revision + 1, updated_at = $3 WHERE org_id = $1 AND id = $2`, org, id, now, reason, actor); err != nil { return Document{}, fmt.Errorf("trade: close: %w", err) } after, err := Get(ctx, tx, id) if err != nil { return Document{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityDocument, EntityID: id, Action: "close", Before: map[string]any{"closed_at": nil}, After: map[string]any{"closed_at": now, "reason": reason}})}- Step 4: Run the trade tests
Run: go test ./internal/platform/trade/... -count=1
Expected: PASS.
- Step 5: Watch each guard fail once
For each of trade_line_allowed_pair, trade_line_same_party, trade_line_open_quantity and trade_line_source_actionable: in a scratch copy of 00701’s trade_line_guard, comment out that one RAISE block, run go test ./internal/platform/trade/ -run 'TestTheChainRefuses|TestAnUnapprovedSourceFeedsNothing' -count=1, confirm the matching test goes red with the constraint name in its message, and restore the migration (git diff db/migrations/00701_trade_document.sql must be empty afterwards). For the cross-organization test, drop org_id from trade_line_source_fk the same way. Record in the task’s report which test went red for each.
- Step 6: Commit
git add internal/platform/tradegit commit -m "Phase 4 O1: copy-forward, open quantity, manual close; chain drift tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 7: The invoice link (00702)
Section titled “Task 7: The invoice link (00702)”Files:
- Create:
db/migrations/00702_invoice_trade_link.sql - Test:
internal/platform/trade/invoicelink_test.go
Interfaces:
-
Consumes: Task 2’s
trade_line/trade_document;purchase_invoice(_line),sales_invoice(_line);tradetest.PublishStub;payablestest.Env.Invoice,payablestest.Line,receivablestest.Env.Invoice,receivablestest.Env.StdLine. -
Produces: nullable
purchase_invoice_line.trade_line_idandsales_invoice_line.trade_line_id(foreign keyspurchase_invoice_line_trade_line_fk,sales_invoice_line_trade_line_fk); refusalspurchase_invoice_line_trade_kind,purchase_invoice_line_trade_party,sales_invoice_line_trade_kind,sales_invoice_line_trade_party(Ruling O1-15). The Go inputs are untouched: O3 addsTradeLineIDtopayables.LineInputandreceivables.LineInputwith its matching rules. -
Step 1: Write the failing test
Create internal/platform/trade/invoicelink_test.go:
package trade_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/receivables" "github.com/mcom/erp/internal/platform/receivables/receivablestest" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/trade/tradetest")
// Spec O1.1 "Invoice link", Ruling O1-15: an AP line may point at a goods// receipt line of its supplier, an AR line at a delivery line of its// customer, and nothing else.func TestAnInvoiceLineLinksOnlyToItsKindsLineOfTheSameParty(t *testing.T) { e := tradetest.New(t) e.PublishStub(t, trade.KindReceipt) e.PublishStub(t, trade.KindDelivery) each := e.UOM(t, "C62") loc := e.Location(t, "KL").ID line := func(kind string, party uuid.UUID) uuid.UUID { return e.Create(t, trade.DocumentInput{Kind: kind, PartyID: &party, Currency: "MYR", LocationID: &loc, Lines: []trade.LineInput{tradetest.TextLine(each, "1", "100.00")}}).Lines[0].ID } bekal, lain := e.Supplier(t, "BEKAL"), e.Supplier(t, "LAIN") kedai, other := e.Customer(t, "KEDAI"), e.Customer(t, "LAINLAIN") bekalReceipt, lainReceipt := line(trade.KindReceipt, bekal), line(trade.KindReceipt, lain) kedaiDelivery, otherDelivery := line(trade.KindDelivery, kedai), line(trade.KindDelivery, other)
var sup payables.Supplier var cust receivables.Customer e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error if sup, err = payables.GetSupplier(ctx, tx, bekal); err != nil { return err } cust, err = receivables.GetCustomer(ctx, tx, kedai) return err }) ap := &payablestest.Env{Env: e.Env} ar := &receivablestest.Env{Env: e.Env} purchase := ap.Invoice(t, payables.KindInvoice, sup, "INV-1", payablestest.Line(e.Account(t, "6100"), "100.00")) sale := ar.Invoice(t, cust, ar.StdLine(t, "1", "100.00"))
link := func(table string, invoice, tradeLine uuid.UUID) string { return constraint(e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE `+table+` SET trade_line_id = $3 WHERE org_id = $1 AND invoice_id = $2 AND withdrawn_at IS NULL`, e.Org, invoice, tradeLine) return err })) } require.Equal(t, "", link("purchase_invoice_line", purchase.ID, bekalReceipt), "PRESENCE: its supplier's receipt line") require.Equal(t, "purchase_invoice_line_trade_party", link("purchase_invoice_line", purchase.ID, lainReceipt)) require.Equal(t, "purchase_invoice_line_trade_kind", link("purchase_invoice_line", purchase.ID, kedaiDelivery))
require.Equal(t, "", link("sales_invoice_line", sale.ID, kedaiDelivery), "PRESENCE: its customer's delivery line") require.Equal(t, "sales_invoice_line_trade_party", link("sales_invoice_line", sale.ID, otherDelivery)) require.Equal(t, "sales_invoice_line_trade_kind", link("sales_invoice_line", sale.ID, bekalReceipt))}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/trade/ -run TestAnInvoiceLineLinksOnlyToItsKindsLineOfTheSameParty -count=1
Expected: FAIL — column "trade_line_id" of relation "purchase_invoice_line" does not exist (the presence assertion reports an empty constraint name only because the error is not a constraint; read the message).
- Step 3: Write the migration
Create db/migrations/00702_invoice_trade_link.sql:
-- +goose Up-- Phase 4 O1.1 "Invoice link" (Ruling O1-15): an AP line may point at a goods-- receipt line (a supplier credit note's, at a purchase return line), an AR-- line at a delivery line (a customer credit note's, at a sales return line),-- of the same party. Matching quantities and prices is O3's.ALTER TABLE purchase_invoice_line ADD COLUMN trade_line_id UUID, ADD CONSTRAINT purchase_invoice_line_trade_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id);ALTER TABLE sales_invoice_line ADD COLUMN trade_line_id UUID, ADD CONSTRAINT sales_invoice_line_trade_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id);CREATE INDEX purchase_invoice_line_by_trade_line ON purchase_invoice_line (org_id, trade_line_id) WHERE trade_line_id IS NOT NULL;CREATE INDEX sales_invoice_line_by_trade_line ON sales_invoice_line (org_id, trade_line_id) WHERE trade_line_id IS NOT NULL;
-- +goose StatementBeginCREATE FUNCTION purchase_invoice_line_trade_link() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE inv_kind TEXT; inv_party UUID; line_kind TEXT; line_party UUID;BEGIN IF NEW.trade_line_id IS NULL THEN RETURN NEW; END IF; SELECT kind, supplier_party_id INTO inv_kind, inv_party FROM public.purchase_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id; SELECT l.document_kind, d.supplier_party_id INTO line_kind, line_party FROM public.trade_line l JOIN public.trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE l.org_id = NEW.org_id AND l.id = NEW.trade_line_id; IF NOT FOUND THEN RETURN NEW; -- purchase_invoice_line_trade_line_fk refuses it END IF; IF (inv_kind, line_kind) NOT IN (('purchase_invoice', 'goods_receipt'), ('supplier_credit_note', 'purchase_return')) THEN RAISE EXCEPTION 'a % line cannot point at a % line', inv_kind, line_kind USING ERRCODE = '23514', CONSTRAINT = 'purchase_invoice_line_trade_kind'; END IF; IF line_party IS DISTINCT FROM inv_party THEN RAISE EXCEPTION 'the % line is another supplier''s', line_kind USING ERRCODE = '23514', CONSTRAINT = 'purchase_invoice_line_trade_party'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER purchase_invoice_line_trade_link BEFORE INSERT OR UPDATE OF trade_line_id ON purchase_invoice_line FOR EACH ROW EXECUTE FUNCTION purchase_invoice_line_trade_link();
-- +goose StatementBeginCREATE FUNCTION sales_invoice_line_trade_link() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE inv_kind TEXT; inv_party UUID; line_kind TEXT; line_party UUID;BEGIN IF NEW.trade_line_id IS NULL THEN RETURN NEW; END IF; SELECT kind, customer_party_id INTO inv_kind, inv_party FROM public.sales_invoice WHERE org_id = NEW.org_id AND id = NEW.invoice_id; SELECT l.document_kind, d.customer_party_id INTO line_kind, line_party FROM public.trade_line l JOIN public.trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE l.org_id = NEW.org_id AND l.id = NEW.trade_line_id; IF NOT FOUND THEN RETURN NEW; -- sales_invoice_line_trade_line_fk refuses it END IF; IF (inv_kind, line_kind) NOT IN (('sales_invoice', 'delivery_order'), ('customer_credit_note', 'sales_return')) THEN RAISE EXCEPTION 'a % line cannot point at a % line', inv_kind, line_kind USING ERRCODE = '23514', CONSTRAINT = 'sales_invoice_line_trade_kind'; END IF; IF line_party IS DISTINCT FROM inv_party THEN RAISE EXCEPTION 'the % line is another customer''s', line_kind USING ERRCODE = '23514', CONSTRAINT = 'sales_invoice_line_trade_party'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER sales_invoice_line_trade_link BEFORE INSERT OR UPDATE OF trade_line_id ON sales_invoice_line FOR EACH ROW EXECUTE FUNCTION sales_invoice_line_trade_link();
-- +goose DownDROP TRIGGER sales_invoice_line_trade_link ON sales_invoice_line;DROP TRIGGER purchase_invoice_line_trade_link ON purchase_invoice_line;DROP FUNCTION sales_invoice_line_trade_link();DROP FUNCTION purchase_invoice_line_trade_link();ALTER TABLE sales_invoice_line DROP COLUMN trade_line_id;ALTER TABLE purchase_invoice_line DROP COLUMN trade_line_id;- Step 4: Run it
Run: go test ./internal/platform/trade/ ./internal/platform/payables/ ./internal/platform/receivables/ -count=1
Expected: PASS (AP and AR are run because their line tables gained a column; nothing they select names it).
- Step 5: Commit
git add db/migrations/00702_invoice_trade_link.sql internal/platform/trade/invoicelink_test.gogit commit -m "Phase 4 O1: invoice lines may point at a receipt or delivery line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 8: The item master’s HTTP surface, permissions, grants and module install
Section titled “Task 8: The item master’s HTTP surface, permissions, grants and module install”Files:
- Modify:
constants.yaml(permissions), regenerate vocab - Create:
internal/modules/inventory/permissions.go,routes.go,http.go,errors.go,item_handlers.go,lookup_handlers.go,location_handlers.go,boot.go - Test:
internal/modules/inventory/env_test.go,handlers_test.go,grants_test.go - Create:
db/migrations/00713_inventory_grants.sql,internal/platform/inventory/migration00713_test.go - Modify:
internal/platform/moduleinstall/moduleinstall.go,internal/platform/moduleinstall/moduleinstall_test.go - Modify:
internal/app/app.go,internal/app/routes_test.go,internal/app/seededgraphs_test.go,cmd/api/main.go - Modify:
cmd/orgadmin/main.go,cmd/orgadmin/wiring_test.go,cmd/migrate/main.go,cmd/migrate/wiring_test.go - Modify:
api/openapi.yaml(paths, responses, parameters, headers, schemas;OrgModule.key; the three custom-fieldentityenums), regenerate - Modify:
web/src/features/customfields/api.ts; locale filesmodules.json,team.json,customfields.json,common.json(en, ms, zh)
Interfaces:
-
Consumes: Task 4 (every
inventoryfunction), Task 5 (platform/trade’sinit()registering the graphs). -
Produces:
inventory.PermissionItemRead = "inventory.item.read",inventory.PermissionItemManage = "inventory.item.manage",inventory.Deps{DB *db.DB; Clock clock.Clock},inventory.Register(g *gin.RouterGroup, deps Deps); routesGET|POST /v1/inventory/items,GET|PUT /v1/inventory/items/{id},GET|POST /v1/inventory/uoms,GET|POST /v1/inventory/categories,PUT /v1/inventory/categories/{id},GET|POST /v1/inventory/locations,PUT /v1/inventory/locations/{id}; refusal codesduplicate_inventory_code,item_in_use; module keysinventory,procurement(requires inventory),sales(requires inventory). -
Step 1: Write the failing module tests
Create internal/modules/inventory/env_test.go:
package inventory_test
import ( "context" "encoding/json" "log/slog" "net/http/httptest" "strings" "testing"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/inventory" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/identity/token")
type alwaysLive struct{}
func (alwaysLive) Check(context.Context, authn.Claims) error { return nil }
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// world is expensestest's provisioned organization with THIS module mounted// alone on a test router.type world struct { e *expensestest.Env r *gin.Engine signer *token.Signer}
func newWorld(t *testing.T) *world { t.Helper() gin.SetMode(gin.TestMode) e := expensestest.New(t) signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")}, "k1", clock.Fixed(e.Clock.Now())) require.NoError(t, err) r, api := httpx.NewRouter(httpx.Deps{DB: e.DB, Logger: slog.New(slog.NewTextHandler(discard{}, nil)), Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}}) inventory.Register(api, inventory.Deps{DB: e.DB, Clock: e.Clock}) return &world{e: e, r: r, signer: signer}}
// member mints a token for a fresh member holding exactly keys.func (w *world) member(t *testing.T, keys ...string) string { t.Helper() ctx, admin := context.Background(), w.e.H.Admin memberID, _ := w.e.Member(t, "M-"+uuid.NewString(), &w.e.OwnerSeat) var roleID uuid.UUID require.NoError(t, admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, w.e.Org, "R-"+uuid.NewString()).Scan(&roleID)) for _, k := range keys { _, err := admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,$3)`, w.e.Org, roleID, k) require.NoError(t, err) } _, err := admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1,$2,$3)`, w.e.Org, memberID, roleID) require.NoError(t, err) tok, err := w.signer.Mint(authn.Claims{UserID: uuid.New(), OrgID: w.e.Org, MemberID: memberID, SessionID: uuid.New()}) require.NoError(t, err) return tok}
func (w *world) do(t *testing.T, method, path, body, tok, ifMatch string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } if ifMatch != "" { req.Header.Set("If-Match", ifMatch) } rec := httptest.NewRecorder() w.r.ServeHTTP(rec, req) return rec}
func jsonField(t *testing.T, rec *httptest.ResponseRecorder, field string) string { t.Helper() var body map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) v, ok := body[field].(string) require.True(t, ok, "field %q not found or not a string in %s", field, rec.Body.String()) return v}
// unit is a seeded global unit's id.func (w *world) unit(t *testing.T, key string) string { t.Helper() var id uuid.UUID require.NoError(t, w.e.H.Admin.QueryRow(context.Background(), `SELECT id FROM uom WHERE org_id IS NULL AND key = $1`, key).Scan(&id)) return id.String()}Create internal/modules/inventory/handlers_test.go:
package inventory_test
import ( "net/http" "strings" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/inventory")
func TestTheItemRoutes(t *testing.T) { w := newWorld(t) manage := w.member(t, inventory.PermissionItemRead, inventory.PermissionItemManage) reader := w.member(t, inventory.PermissionItemRead) body := `{"code":"INSTALL","type":"service","names":{"en":"Installation","ms":"Pemasangan","zh":"安装"}, "base_uom_id":"` + w.unit(t, "HUR") + `","uoms":[{"uom_id":"` + w.unit(t, "DAY") + `","factor":"8"}]}`
// PRESENCE: the manager creates; ABSENCE: the reader cannot. res := w.do(t, "POST", "/v1/inventory/items", body, manage, "") require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) require.Equal(t, http.StatusForbidden, w.do(t, "POST", "/v1/inventory/items", body, reader, "").Code) id, etag := jsonField(t, res, "id"), res.Header().Get("ETag")
got := w.do(t, "GET", "/v1/inventory/items/"+id, "", reader, "") require.Equal(t, http.StatusOK, got.Code) require.Contains(t, got.Body.String(), `"factor":"8.0000000000"`, "a factor is a string on the wire") require.Contains(t, w.do(t, "GET", "/v1/inventory/items?q=pemasangan", "", reader, "").Body.String(), `"INSTALL"`, "a name in any locale finds it")
require.Equal(t, http.StatusConflict, w.do(t, "POST", "/v1/inventory/items", body, manage, "").Code, "duplicate code") bad := w.do(t, "POST", "/v1/inventory/items", strings.Replace(body, `"service"`, `"stock"`, 1), manage, "") require.Equal(t, http.StatusUnprocessableEntity, bad.Code) require.Contains(t, bad.Body.String(), "field=inventory_account_id rule=required")
stale := w.do(t, "PUT", "/v1/inventory/items/"+id, body, manage, `"0"`) require.Equal(t, http.StatusConflict, stale.Code) require.Contains(t, stale.Body.String(), "revision_mismatch") require.Equal(t, http.StatusOK, w.do(t, "PUT", "/v1/inventory/items/"+id, body, manage, etag).Code)}
func TestTheLookupAndLocationRoutes(t *testing.T) { w := newWorld(t) manage := w.member(t, inventory.PermissionItemRead, inventory.PermissionItemManage) reader := w.member(t, inventory.PermissionItemRead)
require.Contains(t, w.do(t, "GET", "/v1/inventory/uoms", "", reader, "").Body.String(), `"C62"`) require.Equal(t, http.StatusCreated, w.do(t, "POST", "/v1/inventory/uoms", `{"key":"CTN","names":{"en":"Carton"}}`, manage, "").Code) cat := w.do(t, "POST", "/v1/inventory/categories", `{"key":"stationery","names":{"en":"Stationery"}}`, manage, "") require.Equal(t, http.StatusCreated, cat.Code, cat.Body.String()) require.Equal(t, http.StatusOK, w.do(t, "PUT", "/v1/inventory/categories/"+jsonField(t, cat, "id"), `{"key":"stationery","names":{"en":"Office stationery"},"is_active":false}`, manage, "").Code)
loc := w.do(t, "POST", "/v1/inventory/locations", `{"code":"KL","names":{"en":"Kuala Lumpur"}}`, manage, "") require.Equal(t, http.StatusCreated, loc.Code, loc.Body.String()) require.Equal(t, http.StatusForbidden, w.do(t, "POST", "/v1/inventory/locations", `{"code":"PG","names":{"en":"Penang"}}`, reader, "").Code) require.Equal(t, http.StatusOK, w.do(t, "PUT", "/v1/inventory/locations/"+jsonField(t, loc, "id"), `{"code":"KL","names":{"en":"Kuala Lumpur"},"is_active":false}`, manage, loc.Header().Get("ETag")).Code) require.Equal(t, `{"locations":[]}`, w.do(t, "GET", "/v1/inventory/locations?active=true", "", reader, "").Body.String())}Create internal/modules/inventory/grants_test.go:
package inventory_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/inventory" "github.com/mcom/erp/internal/platform/expenses/expensestest")
// Ruling O1-10: a new organization's Finance role holds both item keys (the// Owner half is proven against 00713 in platform/inventory, because// expensestest overrides the Owner's set).func TestANewOrganizationsFinanceHoldsTheItemKeys(t *testing.T) { e := expensestest.New(t) rows, err := e.H.Admin.Query(context.Background(), ` SELECT rp.permission_key FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.system AND r.name = 'Finance'`, e.Org) require.NoError(t, err) defer rows.Close() keys := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) keys[k] = true } require.NotEmpty(t, keys, "PRESENCE: the Finance role exists and holds keys") require.True(t, keys[inventory.PermissionItemRead]) require.True(t, keys[inventory.PermissionItemManage])}Create internal/platform/inventory/migration00713_test.go:
package inventory_test
import ( "context" "database/sql" "testing"
"github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
func TestMigration00713GrantsOwnerAndFinanceTheItemKeys(t *testing.T) { h := testdb.StartAt(t, 712) ctx := context.Background() org := uuid.New() _, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) role := func(name string) uuid.UUID { var id uuid.UUID require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, org, name).Scan(&id)) return id } owner, finance, employee := role("Owner"), role("Finance"), role("Employee") keys := func(id uuid.UUID) map[string]bool { rows, err := h.Admin.Query(ctx, `SELECT permission_key FROM role_permission WHERE role_id = $1`, id) require.NoError(t, err) defer rows.Close() out := map[string]bool{} for rows.Next() { var k string require.NoError(t, rows.Scan(&k)) out[k] = true } return out } require.Empty(t, keys(owner), "ABSENCE before: nothing granted yet")
conn, err := sql.Open("pgx", h.AdminDSN) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 713, goose.WithAllowMissing()))
for _, r := range []uuid.UUID{owner, finance} { k := keys(r) require.Len(t, k, 2) require.True(t, k["inventory.item.read"]) require.True(t, k["inventory.item.manage"]) } require.Empty(t, keys(employee), "no other role is touched")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) require.Empty(t, keys(owner), "the Down removes them") require.Empty(t, keys(finance))}- Step 2: Run them to verify they fail
Run: go test ./internal/modules/inventory/ ./internal/platform/inventory/ -run 'TestTheItemRoutes|TestTheLookupAndLocationRoutes|TestANewOrganizationsFinanceHoldsTheItemKeys|TestMigration00713' -count=1
Expected: FAIL to compile — package internal/modules/inventory does not exist.
- Step 3: Declare the contract
In constants.yaml, append to permissions after - einvoice.settings.manage:
- inventory.item.read - inventory.item.manageIn api/openapi.yaml:
(a) Change the three custom-field entity enums (the listCustomFields query parameter, CustomField.entity, CustomFieldInput.entity) to enum: [customer, supplier, sales_document, purchase_document, item].
(b) Change OrgModule.key to { type: string, enum: [expenses, advances, pettycash, payables, receivables, banking, einvoice, taxreturns, inventory, procurement, sales] }.
(c) Insert immediately before the line components::
# --- Phase 4 O1.2: the item master. Every operation needs inventory.item.read or .manage (Ruling O1-10). --- /inventory/items: get: operationId: listItems tags: [Inventory] summary: List items description: > At most 1000, by code. q matches a code prefix or a name in any locale; cf.<key>=<value> filters on a user-defined field. Requires inventory.item.read. parameters: - { name: q, in: query, required: false, schema: { type: string, maxLength: 100 } } - { name: type, in: query, required: false, schema: { $ref: '#/components/schemas/ItemType' } } - { name: category_id, in: query, required: false, schema: { type: string, format: uuid } } - { name: active, in: query, required: false, schema: { type: boolean } } responses: '200': { description: The items, content: { application/json: { schema: { $ref: '#/components/schemas/ItemList' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } post: operationId: createItem tags: [Inventory] summary: Create an item description: > A stock item names its inventory and cost of sales accounts; a service or non-stock item names neither (Ruling O1-9). 409 duplicate_inventory_code. Requires inventory.item.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ItemInput' } } } } responses: '201': { description: Created, headers: { ETag: { $ref: '#/components/headers/InventoryRevision' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Item' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/items/{id}: get: operationId: getItem tags: [Inventory] summary: One item description: Requires inventory.item.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The item, headers: { ETag: { $ref: '#/components/headers/InventoryRevision' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Item' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '404': { $ref: '#/components/responses/InventoryNotFound' } '422': { $ref: '#/components/responses/InventoryInvalid' } put: operationId: updateItem tags: [Inventory] summary: Replace an item's editable shape description: > The whole shape; an alternate unit left out is retired. The type, base unit and tracking are fixed once a document names the item (409 item_in_use). If-Match is required. Requires inventory.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { $ref: '#/components/parameters/InventoryIfMatch' } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ItemInput' } } } } responses: '200': { description: The item, headers: { ETag: { $ref: '#/components/headers/InventoryRevision' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Item' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '404': { $ref: '#/components/responses/InventoryNotFound' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/uoms: get: operationId: listUoms tags: [Inventory] summary: Units of measure description: The seeded units and the organization's own, which shadow a seeded one of the same key. Requires inventory.item.read. responses: '200': { description: The units, content: { application/json: { schema: { $ref: '#/components/schemas/UomList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } post: operationId: createUom tags: [Inventory] summary: Add the organization's own unit description: 409 duplicate_inventory_code. Requires inventory.item.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/UomInput' } } } } responses: '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Uom' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/categories: get: operationId: listItemCategories tags: [Inventory] summary: Item categories description: Requires inventory.item.read. responses: '200': { description: The categories, content: { application/json: { schema: { $ref: '#/components/schemas/ItemCategoryList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } post: operationId: createItemCategory tags: [Inventory] summary: Add a category description: 409 duplicate_inventory_code. Requires inventory.item.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ItemCategoryInput' } } } } responses: '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/ItemCategory' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/categories/{id}: put: operationId: updateItemCategory tags: [Inventory] summary: Rename or retire a category description: The key is fixed; the one sent is ignored. Requires inventory.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ItemCategoryInput' } } } } responses: '200': { description: The category, content: { application/json: { schema: { $ref: '#/components/schemas/ItemCategory' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '404': { $ref: '#/components/responses/InventoryNotFound' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/locations: get: operationId: listLocations tags: [Inventory] summary: Locations description: By code; active=true keeps active ones. Requires inventory.item.read. parameters: - { name: active, in: query, required: false, schema: { type: boolean } } responses: '200': { description: The locations, content: { application/json: { schema: { $ref: '#/components/schemas/LocationList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } post: operationId: createLocation tags: [Inventory] summary: Add a location description: 409 duplicate_inventory_code. Requires inventory.item.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LocationInput' } } } } responses: '201': { description: Created, headers: { ETag: { $ref: '#/components/headers/InventoryRevision' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Location' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' } /inventory/locations/{id}: put: operationId: updateLocation tags: [Inventory] summary: Replace a location's editable shape description: If-Match is required. Requires inventory.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { $ref: '#/components/parameters/InventoryIfMatch' } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LocationInput' } } } } responses: '200': { description: The location, headers: { ETag: { $ref: '#/components/headers/InventoryRevision' } }, content: { application/json: { schema: { $ref: '#/components/schemas/Location' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/InventoryForbidden' } '404': { $ref: '#/components/responses/InventoryNotFound' } '409': { $ref: '#/components/responses/InventoryConflict' } '422': { $ref: '#/components/responses/InventoryInvalid' }(d) Under components: → responses:, after the TaxReturnsInvalid response, add:
InventoryForbidden: description: The caller does not hold the permission this operation requires. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } InventoryNotFound: description: No such record in this organization. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } InventoryConflict: description: 'The same request would succeed against the records in another state: revision_mismatch, duplicate_inventory_code, item_in_use.' content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } InventoryInvalid: description: The request is well-formed but refused by an item-master rule (field=<name> rule=<code>). content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' }Under parameters:, after ReceivablesIfMatch, add:
InventoryIfMatch: name: If-Match in: header required: true schema: { type: string } description: The ETag of the record being changed.Under headers:, after ReceivablesRevision, add:
InventoryRevision: description: The record's revision, quoted. schema: { type: string }Under schemas:, after CustomerList, add:
ItemType: { type: string, enum: [stock, service, non_stock] } ItemTracking: { type: string, enum: [none, serial, batch] } InventoryNames: type: object description: A name by locale; en is required on write, ms and zh are optional. additionalProperties: { type: string } Uom: type: object required: [id, key, names, is_active, global] properties: id: { type: string, format: uuid } key: { type: string, description: 'A UN/ECE Recommendation 20 code: C62 (unit), KGM, BX, HUR.' } names: { $ref: '#/components/schemas/InventoryNames' } is_active: { type: boolean } global: { type: boolean, description: Seeded for every organization; not editable. } UomInput: type: object required: [key, names] properties: key: { type: string, pattern: '^[A-Za-z0-9]{2,3}$' } names: { $ref: '#/components/schemas/InventoryNames' } UomList: type: object required: [uoms] properties: uoms: { type: array, items: { $ref: '#/components/schemas/Uom' } } ItemCategory: type: object required: [id, key, names, is_active] properties: id: { type: string, format: uuid } key: { type: string } names: { $ref: '#/components/schemas/InventoryNames' } is_active: { type: boolean } ItemCategoryInput: type: object required: [key, names] properties: key: { type: string, pattern: '^[a-z][a-z0-9_]{0,39}$', description: Create only. } names: { $ref: '#/components/schemas/InventoryNames' } is_active: { type: boolean, description: Update only; omitted, true. } ItemCategoryList: type: object required: [categories] properties: categories: { type: array, items: { $ref: '#/components/schemas/ItemCategory' } } ItemUom: type: object required: [uom_id, factor, is_active] properties: uom_id: { type: string, format: uuid } factor: { $ref: '#/components/schemas/Rate' } is_active: { type: boolean } ItemUomInput: type: object required: [uom_id, factor] properties: uom_id: { type: string, format: uuid } factor: { $ref: '#/components/schemas/Rate' } Item: type: object required: [id, code, names, descriptions, type, base_uom_id, category_id, tax_code_id, inventory_account_id, cogs_account_id, sales_account_id, purchase_account_id, tracking, is_active, uoms, custom_fields, revision] properties: id: { type: string, format: uuid } code: { type: string } names: { $ref: '#/components/schemas/InventoryNames' } descriptions: { $ref: '#/components/schemas/InventoryNames' } type: { $ref: '#/components/schemas/ItemType' } base_uom_id: { type: string, format: uuid } category_id: { type: string, format: uuid, nullable: true } tax_code_id: { type: string, format: uuid, nullable: true } inventory_account_id: { type: string, format: uuid, nullable: true } cogs_account_id: { type: string, format: uuid, nullable: true } sales_account_id: { type: string, format: uuid, nullable: true } purchase_account_id: { type: string, format: uuid, nullable: true } tracking: { $ref: '#/components/schemas/ItemTracking' } is_active: { type: boolean } uoms: { type: array, items: { $ref: '#/components/schemas/ItemUom' } } custom_fields: { type: object, additionalProperties: { type: string } } revision: { type: integer } ItemInput: type: object required: [code, names, type, base_uom_id] properties: code: { type: string, maxLength: 40 } names: { $ref: '#/components/schemas/InventoryNames' } descriptions: { $ref: '#/components/schemas/InventoryNames' } type: { $ref: '#/components/schemas/ItemType' } base_uom_id: { type: string, format: uuid } category_id: { type: string, format: uuid, nullable: true } tax_code_id: { type: string, format: uuid, nullable: true } inventory_account_id: { type: string, format: uuid, nullable: true } cogs_account_id: { type: string, format: uuid, nullable: true } sales_account_id: { type: string, format: uuid, nullable: true } purchase_account_id: { type: string, format: uuid, nullable: true } tracking: { $ref: '#/components/schemas/ItemTracking' } is_active: { type: boolean, description: Omitted, true. } uoms: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/ItemUomInput' } } custom_fields: { type: object, additionalProperties: { type: string }, description: Omitted on an update, kept. } ItemList: type: object required: [items] properties: items: { type: array, items: { $ref: '#/components/schemas/Item' } } Location: type: object required: [id, code, names, is_active, revision] properties: id: { type: string, format: uuid } code: { type: string } names: { $ref: '#/components/schemas/InventoryNames' } is_active: { type: boolean } revision: { type: integer } LocationInput: type: object required: [code, names] properties: code: { type: string, maxLength: 20 } names: { $ref: '#/components/schemas/InventoryNames' } is_active: { type: boolean, description: Omitted, true. } LocationList: type: object required: [locations] properties: locations: { type: array, items: { $ref: '#/components/schemas/Location' } }Run: make generate
Expected: internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts, internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts change; nothing else.
- Step 4: Write the module
Create internal/modules/inventory/http.go from AR’s request plumbing (a module does not import another module):
{ printf '%s\n' \ '// Package inventory is the HTTP surface of the item master (Phase 4 O1.2):' \ '// internal/platform/inventory owns every rule and writes the audit row for' \ '// every mutation; this package parses requests, calls the platform inside' \ '// one transaction, and maps its refusals onto HTTP statuses.' \ '//' \ '// This file is a COPY of internal/modules/receivables/http.go, below its' \ '// package clause: a module does not import another module.' \ 'package inventory' ''; \ sed -n '/^import (/,$p' internal/modules/receivables/http.go; } > internal/modules/inventory/http.goCreate internal/modules/inventory/permissions.go:
package inventory
import ( "github.com/mcom/erp/internal/platform/identity/member" "github.com/mcom/erp/internal/platform/provisioning")
// The two keys this module's routes are gated on (Ruling O1-10): items,// units, categories and locations are all master data.const ( PermissionItemRead = "inventory.item.read" PermissionItemManage = "inventory.item.manage")
func init() { member.RegisterPermission(PermissionItemRead, "Read items, units, categories and locations") member.RegisterPermission(PermissionItemManage, "Create and edit items, units, categories and locations") // Finance keeps the item master: its accounts are the ledger's. The Owner // receives every registered key already. provisioning.RegisterRolePermissions("Finance", PermissionItemRead, PermissionItemManage)}Create internal/modules/inventory/boot.go:
package inventory
// platform/trade registers the commitment kinds' seeded workflow graphs from// its init() (Task 5). No module imports it until O3's procurement and// sales, so this one links it: every binary that links the modules seeds// the graphs at provisioning.import _ "github.com/mcom/erp/internal/platform/trade"Create internal/modules/inventory/routes.go:
package inventory
import ( "context" "fmt"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/platform/identity/member")
type Deps struct { DB *db.DB Clock clock.Clock}
// Register hangs the item master's routes off the authenticated,// tenant-scoped group (app.Router mounts it behind the inventory install// switch). Every route goes through httpx.Protected.func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} httpx.Protected(g, pl, "GET", "/inventory/items", PermissionItemRead, h.listItems) httpx.Protected(g, pl, "POST", "/inventory/items", PermissionItemManage, h.createItem) httpx.Protected(g, pl, "GET", "/inventory/items/:id", PermissionItemRead, h.getItem) httpx.Protected(g, pl, "PUT", "/inventory/items/:id", PermissionItemManage, h.updateItem) httpx.Protected(g, pl, "GET", "/inventory/uoms", PermissionItemRead, h.listUOMs) httpx.Protected(g, pl, "POST", "/inventory/uoms", PermissionItemManage, h.createUOM) httpx.Protected(g, pl, "GET", "/inventory/categories", PermissionItemRead, h.listCategories) httpx.Protected(g, pl, "POST", "/inventory/categories", PermissionItemManage, h.createCategory) httpx.Protected(g, pl, "PUT", "/inventory/categories/:id", PermissionItemManage, h.updateCategory) httpx.Protected(g, pl, "GET", "/inventory/locations", PermissionItemRead, h.listLocations) httpx.Protected(g, pl, "POST", "/inventory/locations", PermissionItemManage, h.createLocation) httpx.Protected(g, pl, "PUT", "/inventory/locations/:id", PermissionItemManage, h.updateLocation)}
type handlers struct{ deps Deps }
// permissionLoader adapts member.Permissions to httpx.PermissionLoader (a// copy of AR's; a module does not import another module).type permissionLoader struct{ db *db.DB }
var _ httpx.PermissionLoader = permissionLoader{}
func (p permissionLoader) Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error) { if p.db == nil { return nil, apperr.New(apperr.KindInternal, "authorization is not configured") } var out map[string]struct{} err := p.db.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = member.Permissions(ctx, tx, memberID) return err }) if err != nil { return nil, fmt.Errorf("inventory: load permissions: %w", err) } return out, nil}Create internal/modules/inventory/errors.go:
package inventory
import ( "context" "errors" "fmt" "strings"
"github.com/gin-gonic/gin" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/customfield" p "github.com/mcom/erp/internal/platform/inventory")
// mapErr turns platform errors into RFC 7807 problems: 409 when the same// request would succeed against the records in another state, 422 when it// is wrong whatever they hold.func mapErr(err error) error { var cf *customfield.InvalidError if errors.As(err, &cf) { return cf.Problem() } var ( field *p.FieldError revision *p.RevisionMismatchError ) msg := func() string { return strings.TrimPrefix(err.Error(), "inventory: ") } switch { case errors.Is(err, p.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such record in this organization") case errors.As(err, &field): return apperr.New(apperr.KindValidation, field.Error()) case errors.As(err, &revision): return apperr.New(apperr.KindConflict, fmt.Sprintf("the record is at revision %d; reload and retry", revision.Current)). With("code", "revision_mismatch") case errors.Is(err, p.ErrDuplicateCode): return apperr.New(apperr.KindConflict, msg()).With("code", "duplicate_inventory_code") case errors.Is(err, p.ErrItemInUse): return apperr.New(apperr.KindConflict, msg()).With("code", "item_in_use") } return err}
// orEmpty answers a map as {} rather than null.func orEmpty(m map[string]string) map[string]string { if m == nil { return map[string]string{} } return m}
// respond runs fn in a transaction and answers what it returns.func respond[T any](h handlers, c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx) (T, error)) { var out T if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = fn(ctx, tx) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(status, out)}Create internal/modules/inventory/item_handlers.go:
package inventory
import ( "context" "net/http"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/customfield" p "github.com/mcom/erp/internal/platform/inventory")
type itemUOMDTO struct { UOMID uuid.UUID `json:"uom_id"` Factor money.Rate `json:"factor"` IsActive bool `json:"is_active"`}
type itemDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names map[string]string `json:"names"` Descriptions map[string]string `json:"descriptions"` Type string `json:"type"` BaseUOMID uuid.UUID `json:"base_uom_id"` CategoryID *uuid.UUID `json:"category_id"` TaxCodeID *uuid.UUID `json:"tax_code_id"` InventoryAccountID *uuid.UUID `json:"inventory_account_id"` COGSAccountID *uuid.UUID `json:"cogs_account_id"` SalesAccountID *uuid.UUID `json:"sales_account_id"` PurchaseAccountID *uuid.UUID `json:"purchase_account_id"` Tracking string `json:"tracking"` IsActive bool `json:"is_active"` UOMs []itemUOMDTO `json:"uoms"` CustomFields map[string]string `json:"custom_fields"` Revision int `json:"revision"`}
func toItemDTO(it p.Item) itemDTO { uoms := make([]itemUOMDTO, 0, len(it.UOMs)) for _, u := range it.UOMs { uoms = append(uoms, itemUOMDTO{UOMID: u.UOMID, Factor: u.Factor, IsActive: u.IsActive}) } return itemDTO{ID: it.ID, Code: it.Code, Names: orEmpty(it.Names), Descriptions: orEmpty(it.Descriptions), Type: it.Type, BaseUOMID: it.BaseUOMID, CategoryID: it.CategoryID, TaxCodeID: it.TaxCodeID, InventoryAccountID: it.InventoryAccountID, COGSAccountID: it.COGSAccountID, SalesAccountID: it.SalesAccountID, PurchaseAccountID: it.PurchaseAccountID, Tracking: it.Tracking, IsActive: it.IsActive, UOMs: uoms, CustomFields: orEmpty(it.CustomFields), Revision: it.Revision}}
type itemUOMBody struct { UOMID uuid.UUID `json:"uom_id"` Factor money.Rate `json:"factor"`}
type itemBody struct { Code string `json:"code"` Type string `json:"type"` Tracking string `json:"tracking"` Names map[string]string `json:"names"` Descriptions map[string]string `json:"descriptions"` BaseUOMID uuid.UUID `json:"base_uom_id"` CategoryID *uuid.UUID `json:"category_id"` TaxCodeID *uuid.UUID `json:"tax_code_id"` InventoryAccountID *uuid.UUID `json:"inventory_account_id"` COGSAccountID *uuid.UUID `json:"cogs_account_id"` SalesAccountID *uuid.UUID `json:"sales_account_id"` PurchaseAccountID *uuid.UUID `json:"purchase_account_id"` IsActive *bool `json:"is_active"` UOMs []itemUOMBody `json:"uoms"` CustomFields map[string]string `json:"custom_fields"`}
func (b itemBody) input() p.ItemInput { uoms := make([]p.ItemUOMInput, 0, len(b.UOMs)) for _, u := range b.UOMs { uoms = append(uoms, p.ItemUOMInput{UOMID: u.UOMID, Factor: u.Factor}) } return p.ItemInput{Code: b.Code, Type: b.Type, Tracking: b.Tracking, Names: b.Names, Descriptions: b.Descriptions, BaseUOMID: b.BaseUOMID, CategoryID: b.CategoryID, TaxCodeID: b.TaxCodeID, InventoryAccountID: b.InventoryAccountID, COGSAccountID: b.COGSAccountID, SalesAccountID: b.SalesAccountID, PurchaseAccountID: b.PurchaseAccountID, IsActive: b.IsActive == nil || *b.IsActive, UOMs: uoms, CustomFields: b.CustomFields}}
// itemTx runs fn in a transaction and answers the item with its ETag.func (h handlers) itemTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx) (p.Item, error)) { var out itemDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { it, err := fn(ctx, tx) if err != nil { return err } out = toItemDTO(it) return nil }); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Revision) c.JSON(status, out)}
func (h handlers) listItems(c *gin.Context) { f := p.ItemFilter{Query: c.Query("q"), Type: c.Query("type"), ActiveOnly: c.Query("active") == "true", Fields: customfield.QueryFilter(c.Request.URL.Query())} if v := c.Query("category_id"); v != "" { id, err := uuid.Parse(v) if err != nil { _ = c.Error(apperr.New(apperr.KindBadRequest, "category_id must be a UUID")) return } f.CategoryID = &id } respond(h, c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (gin.H, error) { items, err := p.ListItems(ctx, tx, f) out := make([]itemDTO, 0, len(items)) for _, it := range items { out = append(out, toItemDTO(it)) } return gin.H{"items": out}, err })}
func (h handlers) createItem(c *gin.Context) { var body itemBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.itemTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (p.Item, error) { return p.CreateItem(ctx, tx, h.deps.Clock, body.input()) })}
func (h handlers) getItem(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.itemTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (p.Item, error) { return p.GetItem(ctx, tx, id) })}
func (h handlers) updateItem(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } rev, err := ifMatch(c) if err != nil { _ = c.Error(err) return } var body itemBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.itemTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (p.Item, error) { return p.UpdateItem(ctx, tx, h.deps.Clock, id, rev, body.input()) })}Create internal/modules/inventory/lookup_handlers.go:
package inventory
import ( "context" "net/http"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
p "github.com/mcom/erp/internal/platform/inventory")
type uomDTO struct { ID uuid.UUID `json:"id"` Key string `json:"key"` Names map[string]string `json:"names"` IsActive bool `json:"is_active"` Global bool `json:"global"`}
func toUOMDTO(u p.UOM) uomDTO { return uomDTO{ID: u.ID, Key: u.Key, Names: orEmpty(u.Names), IsActive: u.IsActive, Global: u.Global}}
type uomBody struct { Key string `json:"key"` Names map[string]string `json:"names"`}
type categoryDTO struct { ID uuid.UUID `json:"id"` Key string `json:"key"` Names map[string]string `json:"names"` IsActive bool `json:"is_active"`}
func toCategoryDTO(k p.Category) categoryDTO { return categoryDTO{ID: k.ID, Key: k.Key, Names: orEmpty(k.Names), IsActive: k.IsActive}}
type categoryBody struct { Key string `json:"key"` Names map[string]string `json:"names"` IsActive *bool `json:"is_active"`}
func (h handlers) listUOMs(c *gin.Context) { respond(h, c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (gin.H, error) { us, err := p.ListUOMs(ctx, tx) out := make([]uomDTO, 0, len(us)) for _, u := range us { out = append(out, toUOMDTO(u)) } return gin.H{"uoms": out}, err })}
func (h handlers) createUOM(c *gin.Context) { var body uomBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } respond(h, c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (uomDTO, error) { u, err := p.CreateUOM(ctx, tx, h.deps.Clock, body.Key, body.Names) return toUOMDTO(u), err })}
func (h handlers) listCategories(c *gin.Context) { respond(h, c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (gin.H, error) { ks, err := p.ListCategories(ctx, tx) out := make([]categoryDTO, 0, len(ks)) for _, k := range ks { out = append(out, toCategoryDTO(k)) } return gin.H{"categories": out}, err })}
func (h handlers) createCategory(c *gin.Context) { var body categoryBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } respond(h, c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (categoryDTO, error) { k, err := p.CreateCategory(ctx, tx, h.deps.Clock, body.Key, body.Names) return toCategoryDTO(k), err })}
func (h handlers) updateCategory(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var body categoryBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } respond(h, c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (categoryDTO, error) { k, err := p.UpdateCategory(ctx, tx, h.deps.Clock, id, body.Names, body.IsActive == nil || *body.IsActive) return toCategoryDTO(k), err })}Create internal/modules/inventory/location_handlers.go:
package inventory
import ( "context" "net/http"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
p "github.com/mcom/erp/internal/platform/inventory")
type locationDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names map[string]string `json:"names"` IsActive bool `json:"is_active"` Revision int `json:"revision"`}
func toLocationDTO(l p.Location) locationDTO { return locationDTO{ID: l.ID, Code: l.Code, Names: orEmpty(l.Names), IsActive: l.IsActive, Revision: l.Revision}}
type locationBody struct { Code string `json:"code"` Names map[string]string `json:"names"` IsActive *bool `json:"is_active"`}
func (b locationBody) input() p.LocationInput { return p.LocationInput{Code: b.Code, Names: b.Names, IsActive: b.IsActive == nil || *b.IsActive}}
func (h handlers) locationTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx) (p.Location, error)) { var out locationDTO if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { l, err := fn(ctx, tx) out = toLocationDTO(l) return err }); err != nil { _ = c.Error(mapErr(err)) return } setETag(c, out.Revision) c.JSON(status, out)}
func (h handlers) listLocations(c *gin.Context) { respond(h, c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (gin.H, error) { ls, err := p.ListLocations(ctx, tx, c.Query("active") == "true") out := make([]locationDTO, 0, len(ls)) for _, l := range ls { out = append(out, toLocationDTO(l)) } return gin.H{"locations": out}, err })}
func (h handlers) createLocation(c *gin.Context) { var body locationBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.locationTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (p.Location, error) { return p.CreateLocation(ctx, tx, h.deps.Clock, body.input()) })}
func (h handlers) updateLocation(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } rev, err := ifMatch(c) if err != nil { _ = c.Error(err) return } var body locationBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } h.locationTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (p.Location, error) { return p.UpdateLocation(ctx, tx, h.deps.Clock, id, rev, body.input()) })}- Step 5: Write the grants migration
Create db/migrations/00713_inventory_grants.sql:
-- +goose Up-- Phase 4 O1.2, Ruling O1-10: a new organization's Owner and Finance roles-- hold both item keys (provisioning); an existing organization's get them-- here (00313's pattern). Runs as the migration role, which bypasses RLS.INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['inventory.item.read', 'inventory.item.manage']) AS k WHERE r.system AND r.name IN ('Owner', 'Finance')ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner', 'Finance'));
-- +goose DownDELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name IN ('Owner', 'Finance') AND rp.permission_key LIKE 'inventory.%';UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner', 'Finance'));- Step 6: Run the module and migration tests
Run: go test ./internal/modules/inventory/ ./internal/platform/inventory/ -count=1
Expected: PASS.
- Step 7: Write the failing module-install test
Append to internal/platform/moduleinstall/moduleinstall_test.go:
// Phase 4 O1.2: procurement and sales need inventory, both ways.func TestProcurementAndSalesNeedInventory(t *testing.T) { e := expensestest.New(t) set := func(key string, on bool) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := moduleinstall.Set(ctx, tx, e.Clock, key, on, e.Owner) return err }) } require.NoError(t, set("procurement", true), "PRESENCE: with inventory on, procurement is on") var by *moduleinstall.RequiredByError require.ErrorAs(t, set("inventory", false), &by) require.NoError(t, set("procurement", false)) require.NoError(t, set("sales", false)) require.NoError(t, set("inventory", false)) var missing *moduleinstall.DependencyMissingError require.ErrorAs(t, set("sales", true), &missing) require.Equal(t, "inventory", missing.Missing)}Run: go test ./internal/platform/moduleinstall/ -run TestProcurementAndSalesNeedInventory -count=1
Expected: FAIL — moduleinstall: no such optional module.
- Step 8: Add the modules to the catalog
In internal/platform/moduleinstall/moduleinstall.go, append to Catalog after the taxreturns entry:
// Phase 4 O1.2 (Ruling O1-13): procurement and sales have no routes until O3. {Key: "inventory"}, {Key: "procurement", Requires: []string{"inventory"}}, {Key: "sales", Requires: []string{"inventory"}},Run: go test ./internal/platform/moduleinstall/ -count=1
Expected: PASS.
- Step 9: Wire the module into the application and the binaries
In internal/app/app.go: add the import "github.com/mcom/erp/internal/modules/inventory", the field Inventory inventory.Deps after Imports imports.Deps in Deps, and after imports.Register(api, deps.Imports):
inventory.Register(optional("inventory"), deps.Inventory)In cmd/api/main.go, in the app.Deps{...} literal after the Imports: entry:
Inventory: inventory.Deps{ DB: database, Clock: clock.System(), },and add "github.com/mcom/erp/internal/modules/inventory" to its imports.
In internal/app/routes_test.go, import "github.com/mcom/erp/internal/modules/inventory" and add to composedModules:
"inventory": {inventory.PermissionItemRead, inventory.PermissionItemManage},In cmd/orgadmin/main.go and cmd/migrate/main.go, add to the blank-import block, in order:
_ "github.com/mcom/erp/internal/modules/inventory"In cmd/orgadmin/wiring_test.go and cmd/migrate/wiring_test.go, append "github.com/mcom/erp/internal/modules/inventory", to wantModules, and append to wantPermissions:
"inventory.item.read", // modules/inventory "inventory.item.manage", // modules/inventoryAppend to cmd/orgadmin/wiring_test.go (it imports only platform packages, as its header requires; add "github.com/google/uuid" and "github.com/mcom/erp/internal/platform/workflow" to its imports):
// Phase 4 O1: platform/trade registers the commitment kinds' graphs from its// init(), which this binary reaches only through modules/inventory's blank// import of it. Without it, create-org would provision an organization whose// first purchase order fails at workflow.Start.func TestProvisioningSeedsTheCommitmentGraphs(t *testing.T) { seeded := map[string]bool{} for _, g := range workflow.SeededGraphs(uuid.Nil) { seeded[g.Kind] = true } require.True(t, seeded[workflow.KindExpenseClaim], "PRESENCE: the registry is populated") for _, k := range workflow.CommitmentKinds { require.True(t, seeded[k], "%s is not seeded in cmd/orgadmin", k) }}Append to internal/app/seededgraphs_test.go:
// Phase 4 O1: every commitment kind the validator names is seeded by a// module, so none is refused at provisioning for want of a graph.func TestTheCommitmentKindsAreSeeded(t *testing.T) { seeded := map[string]bool{} for _, g := range workflow.SeededGraphs(uuid.New()) { seeded[g.Kind] = true } require.True(t, seeded[workflow.KindExpenseClaim], "PRESENCE: the registry is populated") for _, k := range workflow.CommitmentKinds { require.True(t, seeded[k], "%s is a commitment kind no module seeds", k) }}- Step 10: Run the composition-root tests
Run: go test ./internal/app/ ./cmd/... -count=1
Expected: PASS — including TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales, TestTheCommitmentKindsAreSeeded, the wiring tests.
- Step 11: Name the modules, keys, entity and refusals in the web’s locales
In web/src/features/customfields/api.ts, add 'item', after 'purchase_document', in ENTITIES.
In each of web/src/lib/i18n/locales/{en,ms,zh}/customfields.json, add to "entities" after "purchase_document" (mind the comma on the line before): en "item": "Items", ms "item": "Item", zh "item": "物品".
In each modules.json, add to "names" after "taxreturns": en "inventory": "Inventory", "procurement": "Procurement", "sales": "Sales"; ms "inventory": "Inventori", "procurement": "Perolehan", "sales": "Jualan"; zh "inventory": "库存", "procurement": "采购", "sales": "销售". Add to "descriptions" after "taxreturns": en "inventory": "Items, units of measure and warehouses.", "procurement": "Requisitions, purchase orders and goods receipts.", "sales": "Quotations, sales orders and deliveries."; ms "inventory": "Item, unit ukuran dan gudang.", "procurement": "Permintaan, pesanan belian dan penerimaan barang.", "sales": "Sebut harga, pesanan jualan dan penghantaran."; zh "inventory": "物品、计量单位和仓库。", "procurement": "请购单、采购订单和收货单。", "sales": "报价单、销售订单和送货单。".
In each team.json, add to "modules" after "search": en "inventory": "Inventory", ms "inventory": "Inventori", zh "inventory": "库存"; and to "perm" (after its last entry): en "inventory_item_read": "Read items, units and locations", "inventory_item_manage": "Manage items, units and locations"; ms "inventory_item_read": "Baca item, unit dan lokasi", "inventory_item_manage": "Urus item, unit dan lokasi"; zh "inventory_item_read": "查看物品、单位和地点", "inventory_item_manage": "管理物品、单位和地点".
In each common.json, replace the line "refusals": { with the line followed by two entries:
en:
"refusals": { "duplicate_inventory_code": "Another record already has this code.", "item_in_use": "A document already names this item, so its type, base unit and tracking cannot change.",ms:
"refusals": { "duplicate_inventory_code": "Rekod lain sudah menggunakan kod ini.", "item_in_use": "Dokumen sudah menamakan item ini, jadi jenis, unit asas dan penjejakannya tidak boleh diubah.",zh:
"refusals": { "duplicate_inventory_code": "已有其他记录使用此代码。", "item_in_use": "已有单据引用此物品,其类型、基本单位和追踪方式不能更改。",Run: pnpm -C web typecheck && pnpm -C web test -- drift i18n customfields modules
Expected: PASS (refusalcodes.test.ts fails first if a code above is missing from a locale).
- Step 12: Commit
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts api/openapi.yaml internal/gen/api \ internal/modules/inventory internal/platform/inventory/migration00713_test.go db/migrations/00713_inventory_grants.sql \ internal/platform/moduleinstall internal/app cmd web/srcgit commit -m "Phase 4 O1: item master HTTP surface, permissions, grants and module install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 9: The items import kind (00714)
Section titled “Task 9: The items import kind (00714)”Files:
- Create:
internal/platform/imports/items.go,internal/platform/imports/items_test.go - Create:
db/migrations/00714_import_items.sql - Modify:
internal/platform/imports/imports.go(kinds) - Modify:
api/openapi.yaml(ImportKindenum and the counts description), regenerate;web/src/lib/i18n/locales/{en,ms,zh}/imports.json
Interfaces:
-
Consumes:
inventory.CreateItem,inventory.ListUOMs,inventory.ListCategories,inventory.GetItemByCode,inventory.FieldError,inventory.ErrDuplicateCode; the package’s ownloadLookups,accountRef,lineBad,inSavepoint,customFieldsOf,customFieldProblems. -
Produces:
imports.KindItems = "items"with fieldscode, name_en, name_ms, name_zh, description_en, type, base_unit, category, tax_code, inventory_account, cogs_account, sales_account, purchase_account, tracking, active(+cf.<key>); required columnscode, name_en, type, base_unit; counts{"created": n}. -
Step 1: Write the failing tests
Create internal/platform/imports/items_test.go:
package imports_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/imports" "github.com/mcom/erp/internal/platform/inventory")
const itemsCSV = "code,name_en,name_zh,type,base_unit,inventory_account,cogs_account,sales_account\n" + "paper-a4,Copier paper A4,A4 复印纸,stock,C62,1560,5100,4100\n" + "INSTALL,Installation service,,service,HUR,,,4100\n"
// Spec O1.2, Ruling O1-11: every row is saved through inventory.CreateItem.func TestItemsArriveThroughCreateItem(t *testing.T) { w := newPartyWorld(t) res, err := w.run(t, imports.KindItems, itemsCSV) require.NoError(t, err) require.Equal(t, map[string]int{"created": 2}, res.Counts) w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { it, err := inventory.GetItemByCode(ctx, tx, "PAPER-A4") require.NoError(t, err) require.Equal(t, inventory.TypeStock, it.Type) require.Equal(t, "A4 复印纸", it.Names["zh"]) return nil })}
// A row the screen would refuse is reported on its own line and column;// nothing is imported.func TestAnItemRowTheScreenWouldRefuseIsReportedOnItsLine(t *testing.T) { w := newPartyWorld(t) _, err := w.run(t, imports.KindItems, "code,name_en,type,base_unit,inventory_account,cogs_account\n"+ "PAPER-A4,Copier paper A4,stock,C62,1560,5100\n"+ "PAPER-A3,Copier paper A3,stock,XX,1560,5100\n"+ "PEN,Pen,stock,C62,,\n"+ "TAPE,Tape,stock,,1560,5100\n") var pe *imports.ProblemsError require.ErrorAs(t, err, &pe) require.Equal(t, []imports.Problem{ {Line: 3, Column: "base_unit", Reason: "no unit XX"}, {Line: 4, Column: "inventory_account", Reason: "a stock item names its inventory account"}, {Line: 5, Column: "base_unit", Reason: "a base unit is required"}, }, pe.Problems, "PRESENCE: line 2 is valid and is not reported")}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/imports/ -run 'TestItemsArriveThroughCreateItem|TestAnItemRowTheScreenWouldRefuse' -count=1
Expected: FAIL to compile — undefined: imports.KindItems.
- Step 3: Write the kind
Create internal/platform/imports/items.go:
package imports
import ( "context" "errors" "fmt" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/inventory")
// Items (Phase 4 O1.2, Ruling O1-11). Every row is saved through// inventory.CreateItem, so the screen's rules refuse the same row here,// reported on its line. Create only; alternate units are added on the// item's screen.const KindItems = "items"
var itemsKind = kind{ fields: []string{"code", "name_en", "name_ms", "name_zh", "description_en", "type", "base_unit", "category", "tax_code", "inventory_account", "cogs_account", "sales_account", "purchase_account", "tracking", "active"}, required: []string{"code", "name_en", "type", "base_unit"}, extra: customFieldPrefix, save: saveItems,}
// itemColumns maps an inventory.FieldError's field to the import's column.var itemColumns = map[string]string{ "names": "name_en", "descriptions": "description_en", "base_uom_id": "base_unit", "category_id": "category", "tax_code_id": "tax_code", "inventory_account_id": "inventory_account", "cogs_account_id": "cogs_account", "sales_account_id": "sales_account", "purchase_account_id": "purchase_account",}
func saveItems(ctx context.Context, tx pgx.Tx, c clock.Clock, _ jobRef, rows []row) (outcome, error) { l, err := loadLookups(ctx, tx, false) if err != nil { return outcome{}, err } uoms, err := inventory.ListUOMs(ctx, tx) if err != nil { return outcome{}, err } units := map[string]uuid.UUID{} for _, u := range uoms { units[u.Key] = u.ID } cats, err := inventory.ListCategories(ctx, tx) if err != nil { return outcome{}, err } categories := map[string]uuid.UUID{} for _, k := range cats { categories[k.Key] = k.ID }
var out outcome created := 0 for _, r := range rows { bad := lineBad(&out.problems, r.line) cell := func(f string) string { return r.cells[f] } ok := true ref := func(col, what, v string, m map[string]uuid.UUID) *uuid.UUID { if v == "" { return nil } id, found := m[v] if !found { bad(col, "no %s %s", what, v) ok = false return nil } return &id } if cell("base_unit") == "" { bad("base_unit", "a base unit is required") ok = false } base := ref("base_unit", "unit", strings.ToUpper(cell("base_unit")), units) category := ref("category", "category", strings.ToLower(cell("category")), categories) tax := ref("tax_code", "tax code", strings.ToUpper(cell("tax_code")), l.taxCodes) inv, okA := accountRef(l, r, "inventory_account", bad) cogs, okB := accountRef(l, r, "cogs_account", bad) sales, okC := accountRef(l, r, "sales_account", bad) purchase, okD := accountRef(l, r, "purchase_account", bad) active := true switch strings.ToLower(cell("active")) { case "", "yes", "y", "true", "1": case "no", "n", "false", "0": active = false default: bad("active", "active must be yes or no") ok = false } if !ok || !okA || !okB || !okC || !okD { continue } in := inventory.ItemInput{Code: cell("code"), Type: strings.ToLower(cell("type")), Tracking: strings.ToLower(cell("tracking")), Names: inventory.Names{"en": cell("name_en"), "ms": cell("name_ms"), "zh": cell("name_zh")}, Descriptions: inventory.Names{"en": cell("description_en")}, BaseUOMID: *base, CategoryID: category, TaxCodeID: tax, InventoryAccountID: inv, COGSAccountID: cogs, SalesAccountID: sales, PurchaseAccountID: purchase, IsActive: active, CustomFields: customFieldsOf(r)} err := inSavepoint(ctx, tx, func(sp pgx.Tx) error { _, err := inventory.CreateItem(ctx, sp, c, in) return err }) var fe *inventory.FieldError switch { case err == nil: created++ case errors.As(err, &fe): col := fe.Field if mapped, found := itemColumns[col]; found { col = mapped } bad(col, "%s", fe.Detail) case customFieldProblems(err, bad): case errors.Is(err, inventory.ErrDuplicateCode): bad("code", "another item has code %s", strings.ToUpper(cell("code"))) default: return outcome{}, fmt.Errorf("imports: items: line %d: %w", r.line, err) } } out.counts = map[string]int{"created": created} return out, nil}In internal/platform/imports/imports.go, add to kinds after KindArchivePurchaseDocuments: archivePurchaseKind,:
KindItems: itemsKind,Create db/migrations/00714_import_items.sql:
-- +goose Up-- Phase 4 O1.2 (Ruling O1-11): items join the import kinds.ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements', 'opening_sales_documents', 'opening_purchase_documents', 'archive_sales_documents', 'archive_purchase_documents', 'items'));
-- +goose Down-- Fails while an items import is recorded (import_job is append-only).ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements', 'opening_sales_documents', 'opening_purchase_documents', 'archive_sales_documents', 'archive_purchase_documents'));- Step 4: Run the import tests
Run: go test ./internal/platform/imports/ -count=1
Expected: PASS.
- Step 5: Name the kind in the contract and the web
In api/openapi.yaml, in the import request’s kind enum (the line listing chart_of_accounts, customers, suppliers, ...), append , items inside the brackets; in the counts description that begins 'Rows by outcome., append items: created. before the closing quote.
Run: make generate
Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain items.
In each web/src/lib/i18n/locales/{en,ms,zh}/imports.json, add to "kinds" after "archive_purchase_documents": en "items": "Items", ms "items": "Item", zh "items": "物品".
Run: pnpm -C web typecheck && pnpm -C web test -- imports i18n schema
Expected: PASS.
- Step 6: Commit
git add internal/platform/imports db/migrations/00714_import_items.sql api/openapi.yaml internal/gen/api web/srcgit commit -m "Phase 4 O1: items import kind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 10: The item master’s screens
Section titled “Task 10: The item master’s screens”Files:
- Create:
web/src/features/inventory/api.ts,ItemsPage.tsx,ItemDialog.tsx,ItemDrawer.tsx,LookupsDialog.tsx,LocationsPage.tsx,routes.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/inventory.json - Modify:
web/src/app/routes.ts,web/src/app/shell/nav.ts - Test:
web/src/features/inventory/__tests__/inventory.test.tsx
Interfaces:
-
Consumes: Task 8’s routes and generated
Schemas['Item' | 'ItemInput' | 'Uom' | 'ItemCategory' | 'ItemCategoryInput' | 'Location' | 'LocationInput'];useAccounts/accountOptions(@/features/accounting/queries),CustomFieldsSection/CustomFieldsView/customFieldErrors(@/features/customfields),pickLabel/useLocale(@/lib/i18n),problemMessage/unwrap. -
Produces: routes
/inventory/items(new,$id,$id/edit) and/inventory/locations; hooksuseItems,useItem,useSaveItem,useUoms,useCreateUom,useCategories,useSaveCategory,useLocations,useSaveLocation,useTaxCodes(O3’s screens reuse them to pick items, units and locations). -
Step 1: Write the failing screen tests
Create web/src/features/inventory/__tests__/inventory.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const item = { id: 'i1', code: 'PAPER-A4', names: { en: 'Copier paper A4', zh: 'A4 复印纸' }, descriptions: {}, type: 'stock', base_uom_id: 'u1', category_id: null, tax_code_id: null, inventory_account_id: 'a1', cogs_account_id: 'a2', sales_account_id: null, purchase_account_id: null, tracking: 'none', is_active: true, uoms: [], custom_fields: {}, revision: 1,};
function mockApi() { server.use( http.get('*/v1/inventory/items', () => HttpResponse.json({ items: [item] })), http.get('*/v1/inventory/uoms', () => HttpResponse.json({ uoms: [{ id: 'u1', key: 'C62', names: { en: 'Unit' }, is_active: true, global: true }], }), ), http.get('*/v1/inventory/categories', () => HttpResponse.json({ categories: [] })), http.get('*/v1/inventory/locations', () => HttpResponse.json({ locations: [ { id: 'l1', code: 'KL', names: { en: 'Kuala Lumpur warehouse' }, is_active: true, revision: 1 }, ], }), ), );}
describe('ItemsPage', () => { it('lists items with their base unit and offers a new one to a manager (presence first)', async () => { mockApi(); await renderWithApp({ path: '/inventory/items', permissions: ['inventory.item.read', 'inventory.item.manage'], }); const row = (await screen.findByText('Copier paper A4')).closest('tr')!; expect(within(row).getByText('C62')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'New item' })).toBeInTheDocument(); });
it('is read-only without inventory.item.manage', async () => { mockApi(); await renderWithApp({ path: '/inventory/items', permissions: ['inventory.item.read'] }); expect(await screen.findByText('Copier paper A4')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New item' })).not.toBeInTheDocument(); });});
describe('LocationsPage', () => { it('lists locations by name', async () => { mockApi(); await renderWithApp({ path: '/inventory/locations', permissions: ['inventory.item.read'] }); expect(await screen.findByText('Kuala Lumpur warehouse')).toBeInTheDocument(); });});- Step 2: Run them to verify they fail
Run: pnpm -C web test -- features/inventory
Expected: FAIL — /inventory/items renders the not-found page.
- Step 3: Write the API hooks
Create web/src/features/inventory/api.ts:
// The item master (Phase 4 O1.2): items, units, categories and locations.import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';import { api } from '@/lib/api/client';import { unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';
export type Item = Schemas['Item'];export type ItemInput = Schemas['ItemInput'];export type Uom = Schemas['Uom'];export type ItemCategory = Schemas['ItemCategory'];export type Location = Schemas['Location'];export type LocationInput = Schemas['LocationInput'];
const etag = (rev: number) => `"${rev}"`;
export const inventoryKeys = { items: ['inventory', 'items'] as const, item: (id: string) => ['inventory', 'item', id] as const, uoms: ['inventory', 'uoms'] as const, categories: ['inventory', 'categories'] as const, locations: ['inventory', 'locations'] as const,};
export function useItems() { return useQuery({ queryKey: inventoryKeys.items, queryFn: async ({ signal }) => unwrap(await api.GET('/inventory/items', { signal })).items, });}
export function useItem(id: string) { return useQuery({ queryKey: inventoryKeys.item(id), queryFn: async ({ signal }) => unwrap(await api.GET('/inventory/items/{id}', { params: { path: { id } }, signal })), });}
export function useSaveItem() { const qc = useQueryClient(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: ItemInput }) => v.id ? unwrap( await api.PUT('/inventory/items/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await api.POST('/inventory/items', { body: v.body })), onSuccess: (it: Item) => { qc.setQueryData(inventoryKeys.item(it.id), it); void qc.invalidateQueries({ queryKey: inventoryKeys.items }); }, });}
export function useUoms() { return useQuery({ queryKey: inventoryKeys.uoms, staleTime: 60_000, queryFn: async ({ signal }) => unwrap(await api.GET('/inventory/uoms', { signal })).uoms, });}
export function useCreateUom() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['UomInput']) => unwrap(await api.POST('/inventory/uoms', { body })), onSuccess: () => void qc.invalidateQueries({ queryKey: inventoryKeys.uoms }), });}
export function useCategories() { return useQuery({ queryKey: inventoryKeys.categories, staleTime: 60_000, queryFn: async ({ signal }) => unwrap(await api.GET('/inventory/categories', { signal })).categories, });}
export function useSaveCategory() { const qc = useQueryClient(); return useMutation({ mutationFn: async (v: { id?: string; body: Schemas['ItemCategoryInput'] }) => v.id ? unwrap( await api.PUT('/inventory/categories/{id}', { params: { path: { id: v.id } }, body: v.body, }), ) : unwrap(await api.POST('/inventory/categories', { body: v.body })), onSuccess: () => void qc.invalidateQueries({ queryKey: inventoryKeys.categories }), });}
export function useLocations() { return useQuery({ queryKey: inventoryKeys.locations, queryFn: async ({ signal }) => unwrap(await api.GET('/inventory/locations', { signal })).locations, });}
export function useSaveLocation() { const qc = useQueryClient(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; body: LocationInput }) => v.id ? unwrap( await api.PUT('/inventory/locations/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.body, }), ) : unwrap(await api.POST('/inventory/locations', { body: v.body })), onSuccess: () => void qc.invalidateQueries({ queryKey: inventoryKeys.locations }), });}
/** Active sales and purchase tax codes (an item's default; never a withholding code). */export function useTaxCodes() { return useQuery({ queryKey: ['accounting', 'tax-codes', 'trade'], queryFn: async ({ signal }) => unwrap(await api.GET('/accounting/tax-codes', { signal })).tax_codes.filter( (c) => c.active && c.usage !== 'withholding', ), });}- Step 4: Write the items screens
Create web/src/features/inventory/ItemsPage.tsx:
import { useMemo, useState, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Plus, Tags } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { useCan } from '@/features/auth/session';import { pickLabel, useLocale } from '@/lib/i18n';import { LookupsDialog } from './LookupsDialog';import { useCategories, useItems, useUoms, type Item } from './api';
export function ItemsPage(p: { onOpen(id: string): void; onNew(): void; children?: ReactNode }) { const { t } = useTranslation('inventory'); const [locale] = useLocale(); const canManage = useCan('inventory.item.manage'); const items = useItems(); const uoms = useUoms(); const categories = useCategories(); const [lookups, setLookups] = useState(false); const unitKey = useMemo(() => new Map((uoms.data ?? []).map((u) => [u.id, u.key])), [uoms.data]); const categoryName = useMemo( () => new Map((categories.data ?? []).map((c) => [c.id, pickLabel(c.names, locale)])), [categories.data, locale], ); const columns = useMemo<ColumnDef<Item>[]>( () => [ { id: 'code', meta: { required: true }, header: t('items.columns.code'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.code}</span>, }, { id: 'name', meta: { required: true }, header: t('items.columns.name'), cell: ({ row }) => ( <span className="font-medium">{pickLabel(row.original.names, locale)}</span> ), }, { id: 'type', header: t('items.columns.type'), cell: ({ row }) => t(`items.types.${row.original.type}`), }, { id: 'unit', header: t('items.columns.unit'), cell: ({ row }) => unitKey.get(row.original.base_uom_id) ?? t('items.none'), }, { id: 'category', header: t('items.columns.category'), cell: ({ row }) => (row.original.category_id && categoryName.get(row.original.category_id)) || t('items.none'), }, { id: 'status', meta: { defaultHidden: true }, header: t('items.columns.status'), cell: ({ row }) => (row.original.is_active ? t('active') : t('inactive')), }, ], [t, locale, unitKey, categoryName], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('items.title')} subtitle={t('items.subtitle')} actions={ canManage && ( <> <Button variant="outline" onClick={() => setLookups(true)}> <Tags className="size-4" aria-hidden /> {t('items.lookups')} </Button> <Button onClick={p.onNew}> <Plus className="size-4" aria-hidden /> {t('items.new')} </Button> </> ) } /> <DataTable tableId="inventory.items" columns={columns} data={items.data ?? []} loading={items.isLoading} getRowId={(it: Item) => it.id} onRowClick={(it: Item) => p.onOpen(it.id)} empty={ <EmptyState title={t('items.empty.title')} description={t('items.empty.description')} /> } /> {lookups && <LookupsDialog onClose={() => setLookups(false)} />} {p.children} </div> );}Create web/src/features/inventory/ItemDialog.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Checkbox } from '@/components/ui/Checkbox';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select, type Option } from '@/components/ui/Select';import { accountOptions, useAccounts } from '@/features/accounting/queries';import { CustomFieldsSection } from '@/features/customfields/CustomFieldsSection';import { customFieldErrors } from '@/features/customfields/api';import { problemMessage } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';import { pickLabel, useLocale } from '@/lib/i18n';import { useCategories, useSaveItem, useTaxCodes, useUoms, type Item, type ItemInput,} from './api';
const NONE = '__none';
function OptionalSelect(p: { id: string; label: string; value: string | null | undefined; options: Option[]; onChange(v: string | null): void;}) { return ( <Field label={p.label} id={p.id}> <Select id={p.id} aria-label={p.label} value={p.value ?? NONE} onValueChange={(x: string) => p.onChange(x === NONE ? null : x)} options={[{ value: NONE, label: '–' }, ...p.options]} /> </Field> );}
export function ItemDialog({ item, onClose, onDone,}: { item?: Item; onClose(): void; onDone(id: string): void;}) { const { t } = useTranslation('inventory'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const save = useSaveItem(); const uoms = useUoms(); const categories = useCategories(); const accounts = useAccounts(); const taxCodes = useTaxCodes(); const [v, setV] = useState<ItemInput>({ code: item?.code ?? '', names: item?.names ?? {}, descriptions: item?.descriptions ?? {}, type: item?.type ?? 'stock', base_uom_id: item?.base_uom_id ?? '', category_id: item?.category_id ?? null, tax_code_id: item?.tax_code_id ?? null, inventory_account_id: item?.inventory_account_id ?? null, cogs_account_id: item?.cogs_account_id ?? null, sales_account_id: item?.sales_account_id ?? null, purchase_account_id: item?.purchase_account_id ?? null, tracking: item?.tracking ?? 'none', is_active: item?.is_active ?? true, uoms: (item?.uoms ?? []) .filter((u) => u.is_active) .map((u) => ({ uom_id: u.uom_id, factor: u.factor })), }); const [cf, setCf] = useState<Record<string, string>>(item?.custom_fields ?? {}); const [cfErrors, setCfErrors] = useState<Record<string, string>>({}); const [error, setError] = useState(''); const set = <K extends keyof ItemInput>(k: K, x: ItemInput[K]) => setV((s) => ({ ...s, [k]: x })); const text = (field: 'names' | 'descriptions', loc: 'en' | 'ms' | 'zh') => ({ value: v[field]?.[loc] ?? '', onChange: (e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, [field]: { ...s[field], [loc]: e.target.value } })), }); const units = v.uoms ?? []; const setUnit = (i: number, patch: Partial<Schemas['ItemUomInput']>) => set( 'uoms', units.map((u, j) => (j === i ? { ...u, ...patch } : u)), ); const unitOptions = (uoms.data ?? []) .filter((u) => u.is_active) .map((u) => ({ value: u.id, label: `${u.key} · ${pickLabel(u.names, locale)}` })); const categoryOptions = (categories.data ?? []) .filter((c) => c.is_active) .map((c) => ({ value: c.id, label: pickLabel(c.names, locale) })); const usable = (type: string) => accountOptions(accounts.data, (a) => a.postable && a.active && !a.control_kind && a.type === type); const stock = v.type === 'stock';
async function submit() { setError(''); setCfErrors({}); // A service or non-stock item names no inventory or cost account and is not tracked (Ruling O1-9). const body: ItemInput = stock ? { ...v, custom_fields: cf } : { ...v, inventory_account_id: null, cogs_account_id: null, tracking: 'none', custom_fields: cf }; try { const it = await save.mutateAsync({ id: item?.id, revision: item?.revision, body }); onDone(it.id); } catch (e) { setError(problemMessage(e, tc)); setCfErrors(customFieldErrors(e)); } }
return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={item ? t('items.edit') : t('items.new')} footer={ <Button onClick={() => void submit()} loading={save.isPending}> {t('save')} </Button> } > <div className="grid gap-4 md:grid-cols-2"> <Field label={t('items.fields.code')} id="it-code"> <Input id="it-code" value={v.code} onChange={(e: ChangeEvent<HTMLInputElement>) => set('code', e.target.value)} /> </Field> <Field label={t('items.fields.type')} id="it-type"> <Select id="it-type" aria-label={t('items.fields.type')} value={v.type} onValueChange={(x: string) => set('type', x as ItemInput['type'])} options={(['stock', 'service', 'non_stock'] as const).map((k) => ({ value: k, label: t(`items.types.${k}`), }))} /> </Field> <Field label={t('items.fields.nameEn')} id="it-name-en"> <Input id="it-name-en" {...text('names', 'en')} /> </Field> <Field label={t('items.fields.nameMs')} id="it-name-ms" optional> <Input id="it-name-ms" {...text('names', 'ms')} /> </Field> <Field label={t('items.fields.nameZh')} id="it-name-zh" optional> <Input id="it-name-zh" {...text('names', 'zh')} /> </Field> <Field label={t('items.fields.description')} id="it-desc" optional> <Input id="it-desc" {...text('descriptions', 'en')} /> </Field> <Field label={t('items.fields.baseUnit')} hint={t('items.fields.baseUnitHint')} id="it-base"> <Select id="it-base" aria-label={t('items.fields.baseUnit')} value={v.base_uom_id || undefined} onValueChange={(x: string) => set('base_uom_id', x)} options={unitOptions} /> </Field> <OptionalSelect id="it-cat" label={t('items.fields.category')} value={v.category_id} options={categoryOptions} onChange={(x) => set('category_id', x)} /> <OptionalSelect id="it-tax" label={t('items.fields.taxCode')} value={v.tax_code_id} options={(taxCodes.data ?? []).map((c) => ({ value: c.id, label: `${c.code} · ${c.label}` }))} onChange={(x) => set('tax_code_id', x)} /> {stock && ( <> <OptionalSelect id="it-inv" label={t('items.fields.inventoryAccount')} value={v.inventory_account_id} options={usable('asset')} onChange={(x) => set('inventory_account_id', x)} /> <OptionalSelect id="it-cogs" label={t('items.fields.cogsAccount')} value={v.cogs_account_id} options={usable('expense')} onChange={(x) => set('cogs_account_id', x)} /> <Field label={t('items.fields.tracking')} id="it-track"> <Select id="it-track" aria-label={t('items.fields.tracking')} value={v.tracking ?? 'none'} onValueChange={(x: string) => set('tracking', x as ItemInput['tracking'])} options={(['none', 'serial', 'batch'] as const).map((k) => ({ value: k, label: t(`items.trackings.${k}`), }))} /> </Field> </> )} <OptionalSelect id="it-sales" label={t('items.fields.salesAccount')} value={v.sales_account_id} options={usable('income')} onChange={(x) => set('sales_account_id', x)} /> <OptionalSelect id="it-purchase" label={t('items.fields.purchaseAccount')} value={v.purchase_account_id} options={usable('expense')} onChange={(x) => set('purchase_account_id', x)} /> </div>
<section aria-labelledby="it-units" className="mt-6"> <h3 id="it-units" className="font-bold text-fg"> {t('items.units.title')} </h3> <p className="text-caption text-fg-muted">{t('items.units.hint')}</p> {units.map((u, i) => ( <div key={i} className="mt-2 flex items-end gap-2"> <Field label={t('items.units.unit')} id={`it-u-${i}`}> <Select id={`it-u-${i}`} aria-label={t('items.units.unit')} value={u.uom_id || undefined} onValueChange={(x: string) => setUnit(i, { uom_id: x })} options={unitOptions} /> </Field> <Field label={t('items.units.factor')} id={`it-f-${i}`}> <Input id={`it-f-${i}`} inputMode="decimal" value={u.factor} onChange={(e: ChangeEvent<HTMLInputElement>) => setUnit(i, { factor: e.target.value })} /> </Field> <Button variant="outline" onClick={() => set('uoms', units.filter((_, j) => j !== i))}> {t('items.units.remove')} </Button> </div> ))} <Button variant="outline" className="mt-2" onClick={() => set('uoms', [...units, { uom_id: '', factor: '' }])} > {t('items.units.add')} </Button> </section>
{item && ( <div className="mt-4"> <Checkbox checked={v.is_active ?? true} onCheckedChange={(x: boolean) => set('is_active', x)} label={t('items.fields.active')} /> </div> )} <CustomFieldsSection entity="item" values={cf} onChange={setCf} errors={cfErrors} /> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}Create web/src/features/inventory/ItemDrawer.tsx:
import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Skeleton } from '@/components/ui/Skeleton';import { useAccounts } from '@/features/accounting/queries';import { useCan } from '@/features/auth/session';import { CustomFieldsView } from '@/features/customfields/CustomFieldsSection';import { pickLabel, useLocale } from '@/lib/i18n';import { useItem, useUoms } from './api';
/** A factor comes as a ten-place decimal string; show it without trailing zeros. */const plain = (s: string) => (s.includes('.') ? s.replace(/\.?0+$/, '') : s);
export function ItemDrawer({ id, onClose, onEdit }: { id: string; onClose(): void; onEdit(): void }) { const { t } = useTranslation('inventory'); const [locale] = useLocale(); const q = useItem(id); const uoms = useUoms(); const accounts = useAccounts(); const canManage = useCan('inventory.item.manage'); const it = q.data; const unit = (uid: string) => uoms.data?.find((u) => u.id === uid)?.key ?? t('items.none'); const account = (aid: string | null | undefined) => { const a = aid ? accounts.data?.find((x) => x.id === aid) : undefined; return a ? `${a.code} · ${a.label}` : t('items.none'); }; return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={it ? pickLabel(it.names, locale) : t('items.detailTitle')} footer={ canManage ? ( <Button variant="outline" className="w-full" onClick={onEdit}> {t('items.edit')} </Button> ) : undefined } > {!it ? ( <Skeleton className="h-64 w-full" /> ) : ( <div className="flex flex-col gap-4"> <p className="text-caption text-fg-muted"> {it.code} · {t(`items.types.${it.type}`)} · {unit(it.base_uom_id)} {!it.is_active && ` · ${t('inactive')}`} </p> {pickLabel(it.descriptions, locale) && <p>{pickLabel(it.descriptions, locale)}</p>} <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-body"> {it.type === 'stock' && ( <> <dt className="text-fg-muted">{t('items.fields.inventoryAccount')}</dt> <dd>{account(it.inventory_account_id)}</dd> <dt className="text-fg-muted">{t('items.fields.cogsAccount')}</dt> <dd>{account(it.cogs_account_id)}</dd> <dt className="text-fg-muted">{t('items.fields.tracking')}</dt> <dd>{t(`items.trackings.${it.tracking}`)}</dd> </> )} <dt className="text-fg-muted">{t('items.fields.salesAccount')}</dt> <dd>{account(it.sales_account_id)}</dd> <dt className="text-fg-muted">{t('items.fields.purchaseAccount')}</dt> <dd>{account(it.purchase_account_id)}</dd> </dl> {it.uoms.some((u) => u.is_active) && ( <section aria-labelledby="it-d-units"> <h3 id="it-d-units" className="mb-1 font-bold text-fg"> {t('items.units.title')} </h3> <ul className="text-body"> {it.uoms .filter((u) => u.is_active) .map((u) => ( <li key={u.uom_id}> 1 {unit(u.uom_id)} = {plain(u.factor)} {unit(it.base_uom_id)} </li> ))} </ul> </section> )} <CustomFieldsView entity="item" values={it.custom_fields} /> </div> )} </Drawer> );}Create web/src/features/inventory/LookupsDialog.tsx:
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { toast } from '@/components/ui/Toaster';import { problemMessage } from '@/lib/api/problem';import { pickLabel, useLocale } from '@/lib/i18n';import { useCategories, useCreateUom, useSaveCategory, useUoms } from './api';
/** Units and categories: the item master's two registries (spec O1.2). */export function LookupsDialog({ onClose }: { onClose(): void }) { const { t } = useTranslation('inventory'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const uoms = useUoms(); const createUom = useCreateUom(); const categories = useCategories(); const saveCategory = useSaveCategory(); const [unit, setUnit] = useState({ key: '', name: '' }); const [cat, setCat] = useState({ key: '', name: '' }); const run = async (fn: () => Promise<unknown>, done: () => void) => { try { await fn(); done(); toast.success(t('lookups.added')); } catch (e) { toast.error(problemMessage(e, tc)); } }; return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('items.lookups')}> <section aria-labelledby="lk-units"> <h3 id="lk-units" className="font-bold text-fg"> {t('lookups.units')} </h3> <ul className="my-2 text-body"> {(uoms.data ?? []).map((u) => ( <li key={u.id}> <span className="font-mono">{u.key}</span> · {pickLabel(u.names, locale)} {u.global && <span className="text-caption text-fg-muted"> ({t('lookups.global')})</span>} </li> ))} </ul> <div className="flex items-end gap-2"> <Field label={t('lookups.key')} hint={t('lookups.unitKeyHint')} id="lk-uk"> <Input id="lk-uk" maxLength={3} value={unit.key} onChange={(e: ChangeEvent<HTMLInputElement>) => setUnit((s) => ({ ...s, key: e.target.value }))} /> </Field> <Field label={t('lookups.name')} id="lk-un"> <Input id="lk-un" value={unit.name} onChange={(e: ChangeEvent<HTMLInputElement>) => setUnit((s) => ({ ...s, name: e.target.value }))} /> </Field> <Button onClick={() => void run( () => createUom.mutateAsync({ key: unit.key, names: { en: unit.name } }), () => setUnit({ key: '', name: '' }), ) } > {t('lookups.add')} </Button> </div> </section> <section aria-labelledby="lk-cats" className="mt-6"> <h3 id="lk-cats" className="font-bold text-fg"> {t('lookups.categories')} </h3> <ul className="my-2 text-body"> {(categories.data ?? []).map((c) => ( <li key={c.id} className="flex items-center justify-between gap-2"> <span> {pickLabel(c.names, locale)} {!c.is_active && <span className="text-caption text-fg-muted"> ({t('inactive')})</span>} </span> <Button variant="outline" onClick={() => void run( () => saveCategory.mutateAsync({ id: c.id, body: { key: c.key, names: c.names, is_active: !c.is_active }, }), () => undefined, ) } > {c.is_active ? t('lookups.retire') : t('lookups.restore')} </Button> </li> ))} </ul> <div className="flex items-end gap-2"> <Field label={t('lookups.key')} hint={t('lookups.categoryKeyHint')} id="lk-ck"> <Input id="lk-ck" value={cat.key} onChange={(e: ChangeEvent<HTMLInputElement>) => setCat((s) => ({ ...s, key: e.target.value }))} /> </Field> <Field label={t('lookups.name')} id="lk-cn"> <Input id="lk-cn" value={cat.name} onChange={(e: ChangeEvent<HTMLInputElement>) => setCat((s) => ({ ...s, name: e.target.value }))} /> </Field> <Button onClick={() => void run( () => saveCategory.mutateAsync({ body: { key: cat.key, names: { en: cat.name } } }), () => setCat({ key: '', name: '' }), ) } > {t('lookups.add')} </Button> </div> </section> </Dialog> );}- Step 5: Write the locations screen
Create web/src/features/inventory/LocationsPage.tsx:
import { useMemo, useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { Checkbox } from '@/components/ui/Checkbox';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { PageHeader } from '@/components/ui/PageHeader';import { useCan } from '@/features/auth/session';import { problemMessage } from '@/lib/api/problem';import { pickLabel, useLocale } from '@/lib/i18n';import { useLocations, useSaveLocation, type Location, type LocationInput } from './api';
function LocationDialog({ location, onClose }: { location?: Location; onClose(): void }) { const { t } = useTranslation('inventory'); const { t: tc } = useTranslation(); const save = useSaveLocation(); const [v, setV] = useState<LocationInput>({ code: location?.code ?? '', names: location?.names ?? {}, is_active: location?.is_active ?? true, }); const [error, setError] = useState(''); const name = (loc: 'en' | 'ms' | 'zh') => ({ value: v.names[loc] ?? '', onChange: (e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, names: { ...s.names, [loc]: e.target.value } })), }); async function submit() { setError(''); try { await save.mutateAsync({ id: location?.id, revision: location?.revision, body: v }); onClose(); } catch (e) { setError(problemMessage(e, tc)); } } return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={location ? t('locations.edit') : t('locations.new')} footer={ <Button onClick={() => void submit()} loading={save.isPending}> {t('save')} </Button> } > <div className="grid gap-4 md:grid-cols-2"> <Field label={t('locations.fields.code')} id="lc-code"> <Input id="lc-code" maxLength={20} value={v.code} onChange={(e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, code: e.target.value }))} /> </Field> <Field label={t('locations.fields.nameEn')} id="lc-en"> <Input id="lc-en" {...name('en')} /> </Field> <Field label={t('locations.fields.nameMs')} id="lc-ms" optional> <Input id="lc-ms" {...name('ms')} /> </Field> <Field label={t('locations.fields.nameZh')} id="lc-zh" optional> <Input id="lc-zh" {...name('zh')} /> </Field> </div> {location && ( <div className="mt-4"> <Checkbox checked={v.is_active ?? true} onCheckedChange={(x: boolean) => setV((s) => ({ ...s, is_active: x }))} label={t('locations.fields.active')} /> </div> )} {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}
export function LocationsPage() { const { t } = useTranslation('inventory'); const [locale] = useLocale(); const canManage = useCan('inventory.item.manage'); const list = useLocations(); const [editing, setEditing] = useState<Location | 'new' | null>(null); const columns = useMemo<ColumnDef<Location>[]>( () => [ { id: 'code', meta: { required: true }, header: t('locations.columns.code'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.code}</span>, }, { id: 'name', meta: { required: true }, header: t('locations.columns.name'), cell: ({ row }) => pickLabel(row.original.names, locale), }, { id: 'status', header: t('locations.columns.status'), cell: ({ row }) => (row.original.is_active ? t('active') : t('inactive')), }, ], [t, locale], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('locations.title')} subtitle={t('locations.subtitle')} actions={ canManage && ( <Button onClick={() => setEditing('new')}> <Plus className="size-4" aria-hidden /> {t('locations.new')} </Button> ) } /> <DataTable tableId="inventory.locations" columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(l: Location) => l.id} onRowClick={(l: Location) => { if (canManage) setEditing(l); }} empty={ <EmptyState title={t('locations.empty.title')} description={t('locations.empty.description')} /> } /> {editing && ( <LocationDialog location={editing === 'new' ? undefined : editing} onClose={() => setEditing(null)} /> )} </div> );}- Step 6: Route, navigate and word it
Create web/src/features/inventory/routes.tsx:
import { createRoute, Outlet } from '@tanstack/react-router';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { useItem } from './api';import { ItemDialog } from './ItemDialog';import { ItemDrawer } from './ItemDrawer';import { ItemsPage } from './ItemsPage';import { LocationsPage } from './LocationsPage';
const itemsRoute = createRoute({ getParentRoute: () => appRoute, path: 'inventory/items', component: function R() { const navigate = itemsRoute.useNavigate(); return ( <RequirePermission permission="inventory.item.read"> <ItemsPage onOpen={(id) => void navigate({ to: '/inventory/items/$id', params: { id } })} onNew={() => void navigate({ to: '/inventory/items/new' })} > <Outlet /> </ItemsPage> </RequirePermission> ); },});const newItemRoute = createRoute({ getParentRoute: () => itemsRoute, path: 'new', component: function R() { const navigate = newItemRoute.useNavigate(); return ( <ItemDialog onClose={() => void navigate({ to: '/inventory/items' })} onDone={(id) => void navigate({ to: '/inventory/items/$id', params: { id } })} /> ); },});const itemRoute = createRoute({ getParentRoute: () => itemsRoute, path: '$id', component: function R() { const { id } = itemRoute.useParams(); const navigate = itemRoute.useNavigate(); return ( <ItemDrawer id={id} onClose={() => void navigate({ to: '/inventory/items' })} onEdit={() => void navigate({ to: '/inventory/items/$id/edit', params: { id } })} /> ); },});const editItemRoute = createRoute({ getParentRoute: () => itemsRoute, path: '$id/edit', component: function R() { const { id } = editItemRoute.useParams(); const navigate = editItemRoute.useNavigate(); const it = useItem(id); return it.data ? ( <ItemDialog item={it.data} onClose={() => void navigate({ to: '/inventory/items/$id', params: { id } })} onDone={() => void navigate({ to: '/inventory/items/$id', params: { id } })} /> ) : null; },});const locationsRoute = createRoute({ getParentRoute: () => appRoute, path: 'inventory/locations', component: function R() { return ( <RequirePermission permission="inventory.item.read"> <LocationsPage /> </RequirePermission> ); },});
export const routes = [ itemsRoute.addChildren([newItemRoute, editItemRoute, itemRoute]), locationsRoute,] as const;In web/src/app/routes.ts, add import { routes as inventoryRoutes } from '@/features/inventory/routes'; with the other imports and ...inventoryRoutes, as the last entry of appRoutes.
In web/src/app/shell/nav.ts, add Package, and Warehouse, to the lucide-react import (keep it alphabetical where it is), and add after the /receivables/receipts entry:
{ group: 'accounting', to: '/inventory/items', labelKey: 'inventory:nav.items', icon: Package, permission: 'inventory.item.read', }, { group: 'accounting', to: '/inventory/locations', labelKey: 'inventory:nav.locations', icon: Warehouse, permission: 'inventory.item.read', },Create web/src/lib/i18n/locales/en/inventory.json:
{ "nav": { "items": "Items", "locations": "Locations" }, "save": "Save", "active": "Active", "inactive": "Inactive", "items": { "title": "Items", "subtitle": "The goods and services you buy, sell or keep in stock. An item is retired, never deleted.", "new": "New item", "edit": "Edit item", "detailTitle": "Item", "lookups": "Units and categories", "none": "–", "empty": { "title": "No items yet", "description": "Add the goods and services you buy and sell." }, "columns": { "code": "Code", "name": "Name", "type": "Type", "unit": "Base unit", "category": "Category", "status": "Status" }, "types": { "stock": "Stock", "service": "Service", "non_stock": "Non-stock" }, "trackings": { "none": "Not tracked", "serial": "Serial number", "batch": "Batch" }, "fields": { "code": "Code", "nameEn": "Name (English)", "nameMs": "Name (Malay)", "nameZh": "Name (Chinese)", "description": "Description", "type": "Type", "baseUnit": "Base unit", "baseUnitHint": "Fixed once a document names the item.", "category": "Category", "taxCode": "Tax code", "inventoryAccount": "Inventory account", "cogsAccount": "Cost of sales account", "salesAccount": "Sales account", "purchaseAccount": "Purchase account", "tracking": "Tracking", "active": "Active" }, "units": { "title": "Other units", "hint": "How many base units one of this unit holds, such as a box of 12.", "unit": "Unit", "factor": "Base units", "add": "Add unit", "remove": "Remove" } }, "lookups": { "units": "Units", "categories": "Categories", "key": "Code", "name": "Name (English)", "add": "Add", "added": "Added", "global": "standard", "retire": "Retire", "restore": "Restore", "unitKeyHint": "A UN/ECE unit code of 2 or 3 letters or digits, such as CTN.", "categoryKeyHint": "Lower-case letters, digits and underscores." }, "locations": { "title": "Locations", "subtitle": "The warehouses where stock is received and kept.", "new": "New location", "edit": "Edit location", "empty": { "title": "No locations yet", "description": "Add the warehouse where you receive goods." }, "columns": { "code": "Code", "name": "Name", "status": "Status" }, "fields": { "code": "Code", "nameEn": "Name (English)", "nameMs": "Name (Malay)", "nameZh": "Name (Chinese)", "active": "Active" } }}Create web/src/lib/i18n/locales/ms/inventory.json:
{ "nav": { "items": "Item", "locations": "Lokasi" }, "save": "Simpan", "active": "Aktif", "inactive": "Tidak aktif", "items": { "title": "Item", "subtitle": "Barangan dan perkhidmatan yang anda beli, jual atau simpan dalam stok. Item dipersarakan, tidak pernah dipadam.", "new": "Item baharu", "edit": "Sunting item", "detailTitle": "Item", "lookups": "Unit dan kategori", "none": "–", "empty": { "title": "Belum ada item", "description": "Tambah barangan dan perkhidmatan yang anda beli dan jual." }, "columns": { "code": "Kod", "name": "Nama", "type": "Jenis", "unit": "Unit asas", "category": "Kategori", "status": "Status" }, "types": { "stock": "Stok", "service": "Perkhidmatan", "non_stock": "Bukan stok" }, "trackings": { "none": "Tidak dijejak", "serial": "Nombor siri", "batch": "Kelompok" }, "fields": { "code": "Kod", "nameEn": "Nama (Inggeris)", "nameMs": "Nama (Melayu)", "nameZh": "Nama (Cina)", "description": "Keterangan", "type": "Jenis", "baseUnit": "Unit asas", "baseUnitHint": "Tetap sebaik sahaja dokumen menamakan item ini.", "category": "Kategori", "taxCode": "Kod cukai", "inventoryAccount": "Akaun inventori", "cogsAccount": "Akaun kos jualan", "salesAccount": "Akaun jualan", "purchaseAccount": "Akaun belian", "tracking": "Penjejakan", "active": "Aktif" }, "units": { "title": "Unit lain", "hint": "Berapa unit asas dalam satu unit ini, seperti sekotak 12.", "unit": "Unit", "factor": "Unit asas", "add": "Tambah unit", "remove": "Buang" } }, "lookups": { "units": "Unit", "categories": "Kategori", "key": "Kod", "name": "Nama (Inggeris)", "add": "Tambah", "added": "Ditambah", "global": "piawai", "retire": "Persarakan", "restore": "Pulihkan", "unitKeyHint": "Kod unit UN/ECE 2 atau 3 huruf atau digit, seperti CTN.", "categoryKeyHint": "Huruf kecil, digit dan garis bawah." }, "locations": { "title": "Lokasi", "subtitle": "Gudang tempat stok diterima dan disimpan.", "new": "Lokasi baharu", "edit": "Sunting lokasi", "empty": { "title": "Belum ada lokasi", "description": "Tambah gudang tempat anda menerima barangan." }, "columns": { "code": "Kod", "name": "Nama", "status": "Status" }, "fields": { "code": "Kod", "nameEn": "Nama (Inggeris)", "nameMs": "Nama (Melayu)", "nameZh": "Nama (Cina)", "active": "Aktif" } }}Create web/src/lib/i18n/locales/zh/inventory.json:
{ "nav": { "items": "物品", "locations": "地点" }, "save": "保存", "active": "启用", "inactive": "停用", "items": { "title": "物品", "subtitle": "您采购、销售或库存的商品与服务。物品只会停用,不会删除。", "new": "新建物品", "edit": "编辑物品", "detailTitle": "物品", "lookups": "单位和类别", "none": "–", "empty": { "title": "尚无物品", "description": "添加您采购和销售的商品与服务。" }, "columns": { "code": "代码", "name": "名称", "type": "类型", "unit": "基本单位", "category": "类别", "status": "状态" }, "types": { "stock": "库存品", "service": "服务", "non_stock": "非库存品" }, "trackings": { "none": "不追踪", "serial": "序列号", "batch": "批次" }, "fields": { "code": "代码", "nameEn": "名称(英文)", "nameMs": "名称(马来文)", "nameZh": "名称(中文)", "description": "描述", "type": "类型", "baseUnit": "基本单位", "baseUnitHint": "一旦有单据引用此物品即不可更改。", "category": "类别", "taxCode": "税码", "inventoryAccount": "存货科目", "cogsAccount": "销售成本科目", "salesAccount": "销售科目", "purchaseAccount": "采购科目", "tracking": "追踪方式", "active": "启用" }, "units": { "title": "其他单位", "hint": "一个此单位包含多少基本单位,例如一盒 12 个。", "unit": "单位", "factor": "基本单位数", "add": "添加单位", "remove": "移除" } }, "lookups": { "units": "单位", "categories": "类别", "key": "代码", "name": "名称(英文)", "add": "添加", "added": "已添加", "global": "标准", "retire": "停用", "restore": "恢复", "unitKeyHint": "2 或 3 位字母或数字的 UN/ECE 单位代码,例如 CTN。", "categoryKeyHint": "小写字母、数字和下划线。" }, "locations": { "title": "地点", "subtitle": "接收和存放库存的仓库。", "new": "新建地点", "edit": "编辑地点", "empty": { "title": "尚无地点", "description": "添加您收货的仓库。" }, "columns": { "code": "代码", "name": "名称", "status": "状态" }, "fields": { "code": "代码", "nameEn": "名称(英文)", "nameMs": "名称(马来文)", "nameZh": "名称(中文)", "active": "启用" } }}- Step 7: Run the web checks
Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test
Expected: PASS, including the new screen tests, i18nparity, i18ndupkeys, nofloat, nohex, refusalcodes and schema.
- Step 8: Look at it
Start the stack (make dev-stack, then pnpm -C web dev), sign in as an Owner of a provisioned organization, open Items: create a stock item with an alternate unit (a box of 12) and a Malay and Chinese name, switch the UI locale and see the name follow it, add a unit and a category from “Units and categories”, create a location, and switch the Inventory module off in Settings → Modules to see both nav entries disappear. Note anything that looks wrong in the task report rather than fixing unrelated screens.
- Step 9: Commit
git add web/srcgit commit -m "Phase 4 O1: items and locations screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 11: Documentation and the full suite
Section titled “Task 11: Documentation and the full suite”Files:
- Modify:
docs/ROADMAP.md(Phase 4),docs/DEPLOY_NOTES.md(new entry, newest first),docs/LOCK_ORDER.md(per-module table)
Interfaces:
-
Consumes: everything above.
-
Produces: the roadmap tick, the operator notes, the lock-order row.
-
Step 1: Tick the roadmap
In docs/ROADMAP.md, under ## Phase 4 — Operations, insert as the first list item:
- [x] O1 — document chain (`platform/trade`, `00700`–`00709`) and item master (`platform/inventory`, `00710`–`00719`): the `committed` semantic; requisition, order, quotation and sales-order workflows; copy-forward, open quantity and close, guarded by the database; the invoice-line link; items, units, categories and locations with screens, custom fields and the `items` import; modules `inventory`, `procurement`, `sales` (plan `docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md`)Leave the four existing Phase 4 items unticked: each needs O2, O3 or O4.
- Step 2: Write the deploy notes
In docs/DEPLOY_NOTES.md, insert after the first --- line (before ## Phase 2d D1 — segregation of duties (X1, X2)):
## Phase 4 O1 — document chain and item master
Migrations `00700`–`00702` and `00710`–`00714`; permission keys `inventory.item.read` and`inventory.item.manage`; optional modules `inventory`, `procurement` and `sales`. No environmentvariable.
- **Re-run provisioning for every existing organization** (`POST /v1/org/provision`; see RUNBOOK §6). Step 60, `workflow_definitions`, now seeds four more graphs — `purchase_requisition`, `purchase_order`, `quotation`, `sales_order` — and reports the step incomplete until it has. Until then, creating one of those documents fails with "no published definition".- **Three optional modules arrive switched on** for every organization (no `module_install` row means on). `procurement` and `sales` have no screens until O3; `inventory` shows Items and Locations to Owners and Finance. An organization that should not see them switches them off in Settings → Modules; `procurement` and `sales` must go off before `inventory`.- **`00713` grants the Owner and Finance roles** both item keys and bumps `perm_version`, so members' tokens are reissued with them.- **Down-migrations refuse once O1 has been used:** `00700` while any published workflow state is `committed`, `00712` while an item custom field is defined, `00714` while an `items` import is recorded. Roll the code back, not these migrations.- **Receipts, deliveries, returns and stock adjustments** exist in the schema with no workflow until O2 and O3 register theirs. Nothing in O1 posts to the ledger.- Step 3: Record the lock order
In docs/LOCK_ORDER.md, add as the last row of the “Per module” table (after the SST returns and payments row):
| Trade documents (Phase 4 O1) | in a one-member organization, `organization` `FOR UPDATE` (`workflow.LockSoleOwner`, Ruling O1-18) → header `FOR UPDATE` → on a cancel or a manual close, the document's live lines `FOR UPDATE` in `id` order → `workflow.Transition` → audit. A child line's insert takes its source *line* `FOR UPDATE` (00701's `trade_line_guard`) and never the source header, so a cancel or close holding the source's lines makes the child wait and then see the new state; no cycle. Create and Update: number → `workflow.Start` → header → lines → audit | `trade/transition.go` `Transition`, `trade/chain.go` `Close`, `trade/document.go`, migration `00701` |- Step 4: Run everything once
Run, in order:
make generate && git status --porcelain # expected: no output — every generated file is committedmake lint # expected: no output from gofmt; go vet cleango test ./... -count=1 # expected: ok for every packagemake web # expected: typecheck, lint, test and build passIf a package outside O1’s files fails, read it before touching it: the usual cause is a drift test that lists tables, routes, modules or permissions (retention_test.go, routes_test.go, the cmd/*/wiring_test.go files) and needs the O1 entry this plan names in its task. Do not weaken an existing test to pass.
- Step 5: Commit
git add docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.mdgit commit -m "Phase 4 O1: roadmap, deploy notes and lock order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Self-review (done while writing; re-run before executing)
Section titled “Self-review (done while writing; re-run before executing)”Spec coverage (O1.1, O1.2, drift bullet):
| Spec requirement | Task |
|---|---|
trade_document kinds, party, currency, dates, number, location, closed_at/close_reason, instance |
2 (schema), 5 (service) |
trade_line item or text, quantity, UOM, base quantity, unit price NUMERIC(19,4), tax code, shares, source_line_id same org |
2, 3 (00711 FKs), 5 |
| Allowed pairs enforced in the schema by a trigger; same org and same party | 2 (trade_line_guard, composite FK), 6 (drift tests) |
Open quantity; refused past zero under the source line FOR UPDATE; over-receipt means amending |
2 (trade_open_quantity, guard), 6 (OpenQuantity, tests); amending is O3’s |
trade.Draft(ctx, tx, sourceDocID, targetKind) at open quantity |
6 |
| Auto close and manual close with reason, audited; closed takes no children | 6 (Ruling O1-5: auto is derived) |
| Reversal gives quantity back | 2 (live-child definition), 6 (withdrawn child test); posting reversal is O2’s |
Per-kind seeded workflow and numbering; four approvals end in committed |
1, 5 (commitment kinds); receipt/delivery/returns/adjustment graphs are O2/O3’s (Ruling O1-14) |
Invoice link trade_line_id on AP and AR lines |
7 |
item: code, names/descriptions as translation rows, type, base UOM, tax code, four accounts, tracking, active, retired |
3, 4 |
uom registry; item_uom factor NUMERIC(19,6); location, no bins; categories registry |
3, 4 |
item joins C2 custom fields; item import kind on M1 |
4 (00712), 9 (items) |
| Module install: inventory, procurement, sales; the latter two need inventory | 8 |
| Drift: link outside pairs / across orgs / across parties / past open quantity refused | 6 |
| Drift: every new table tenant-isolated | 2, 3 (existing drift tests extended by construction) |
Permissions inventory.item.*, granted by provisioning |
8 (O2 adds adjustment/transfer, O3 procurement/sales) |
Placeholder scan: no step says “similar to”, “TBD” or “handle errors”; every code step carries its code. Task 8’s http.go is produced by a shown shell command from AR’s file (a deliberate copy, as AR’s own file says of AP’s).
Type consistency: trade.DocumentInput/LineInput fields are the same in Tasks 5, 6, 7 and the tradetest fixture; inventory.ItemInput/ItemUOMInput/Names the same in Tasks 4, 5 (fixture), 8 and 9; posting.Committed and workflow.CommitmentKinds from Task 1 are what Tasks 5 and 8 use; constraint names raised in 00701 are the ones trade.mapPg switches on and Task 6 asserts.