Skip to content

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.

  • Migration ranges: O1 chain 00700–00709; O1 item master 00710–00719. Reserved (declared in Task 1, used by other lanes): O2 00720–00739, O3 00740–00769, O4 00770–00789.
  • A float never touches money. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire. Unit price is NUMERIC(19,4) (spec O1.1).
  • Quantities are NUMERIC(19,6) in Postgres, money.Rate in Go (the type sales_invoice_line.quantity already uses), a string on the wire, at most six decimal places. item_uom factor to base is NUMERIC(19,6) (spec O1.2).
  • Every business table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation. The two registry tables (uom, item_category) have a nullable org_id (a seeded global row) and are declared in db/org_id_nullable.txt.
  • Every constraint is named (CONSTRAINT <table>_<rule>); TestEveryConstraintIsNamed fails 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.). No SECURITY DEFINER.
  • Retention: every new table has DELETE revoked from erp_app and is listed in internal/kernel/db/retention_test.go’s amendable list. 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; field name or description), en/ms/zh.
  • accounting/ reacts to semantic tags, never to state names. committed posts 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 generate must 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/inventory never imports platform/trade. platform/trade imports platform/inventory, and never imports platform/payables or platform/receivables (O3’s three-way match will have those import trade). Posting a trade line’s stock effect lives on trade’s side (O2/O3), calling inventory.
  • 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.md in Task 11): in a one-member organization organization FOR UPDATE (workflow.LockSoleOwner) → trade header FOR UPDATE → on a cancel or close, its live lines FOR UPDATE in id order → 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.
  • O1-1 Numbering at create. A document is numbered when it is created (drafts included), PREFIX-YYYY-NNNN via numbering.Allocate(scope = kind, period = calendar year of creation). Prefixes: PR requisition, PO order, GRN goods receipt, PRN purchase return, QT quotation, SO sales order, DO delivery order, SRN sales return, ADJ stock 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 supplies due_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.Fulfilled is true when the document has lines and every live line’s open quantity is zero; Document.Closed() is ClosedAt != nil || Fulfilled. closed_at/close_reason/closed_by record 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, cancelled or dispute, 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 shape sales_invoice_line.unit_code already 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.Draft keeps 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_fixed trigger; O2 extends the trigger to stock_move).
  • O1-10 Permissions. inventory.item.read and inventory.item.manage cover items, units, categories and locations (all master data). Granted to Owner (every registered key) and Finance. inventory.adjustment.* and inventory.transfer.* are O2’s; procurement.* and sales.* are O3’s.
  • O1-11 The import kind is items (plural, like customers and suppliers). 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_first is vacuous (no posting states), as the purchase-request spec’s D4 argued.
  • O1-13 New optional modules default on (no module_install row means on). procurement and sales have no routes until O3.
  • O1-14 Receipt, delivery, returns and adjustment are declared (schema, trade kind constants, numbering prefixes, allowed pairs), but their workflow graphs and postings are O2’s (receipt accrual on a new grni control kind; delivery and adjustment on a new effect semantic) and O3’s. Until a lane registers a kind’s graph, trade.Create for it returns workflow.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 as authority_via = 'sole_owner_exception' on the decision and sole_owner_exception + reason on the trade audit row (workflow.SoleOwnerAudit). soleOwnerEdge gains committed: the edge into a committed state is covered under issue, and the reason-required cancel out of one under void (it reverses nothing). trade.Transition takes workflow.LockSoleOwner first. O2 edits the same soleOwnerEdge lines for effect (its Task 1); whichever lands second merges both conditions.
  • O1-15 Invoice link. The database checks that an AP line points at a goods_receipt line (invoice) or purchase_return line (credit note) of the same supplier, and an AR line at a delivery_order line (invoice) or sales_return line (credit note) of the same customer. Quantity matching and threading TradeLineID through payables/receivables Go inputs are O3’s.
  • O1-16 Trade shares are checked with analytic.Plan against expense (purchase kinds, adjustments) or income (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 the accounting nav group until O3 adds an operations group.

All in github.com/mcom/erp/internal/platform/.... Stable after this plan lands.

// package trade
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 = "trade_document" // audit entity kind
)
var Kinds []string
func 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() bool
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
}
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() string
func 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 nil
func 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) *Env
func (e *Env) Another(t *testing.T) *Env
func (e *Env) As(member uuid.UUID) context.Context
func (e *Env) UOM(t *testing.T, key string) uuid.UUID
func (e *Env) Account(t *testing.T, code string) uuid.UUID
func (e *Env) Supplier(t *testing.T, code string) uuid.UUID
func (e *Env) Customer(t *testing.T, code string) uuid.UUID
func (e *Env) StockItem(t *testing.T, code string, alternates ...inventory.ItemUOMInput) inventory.Item
func (e *Env) Location(t *testing.T, code string) inventory.Location
func (e *Env) PublishStub(t *testing.T, kind string)
func (e *Env) Create(t *testing.T, in trade.DocumentInput) trade.Document
func (e *Env) Move(t *testing.T, d trade.Document, by uuid.UUID, from, to string) trade.Document
func (e *Env) Commit(t *testing.T, d trade.Document) trade.Document
func TextLine(uom uuid.UUID, qty, price string) trade.LineInput
// package inventory
const (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]string
type 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 posting
const Committed Semantic = "committed"
// package workflow
var 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.

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 regenerate internal/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 (schemas Semantic, 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 codes committed_count, posting_in_commitment, committed_not_commitment_kind (rule 3); DB constraint workflow_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:

- committed

Run: 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
Terminal window
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/src
git commit -m "Phase 4 O1: the committed semantic (approved to act on; nothing posts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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 functions trade_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_id and trade_document.location_id get 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 StatementBegin
CREATE 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 StatementBegin
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE TRIGGER trade_line_guard BEFORE INSERT OR UPDATE ON trade_line
FOR EACH ROW EXECUTE FUNCTION trade_line_guard();
-- +goose Down
DROP 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
Terminal window
git add db/migrations/00701_trade_document.sql internal/kernel/db/retention_test.go
git 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 keys trade_line_item_fk, trade_line_uom_fk, trade_document_location_fk; trigger item_fixed_once_used raising constraint item_fixed. Package inventory’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 Down
DELETE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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.
uom
item_category

In 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
Terminal window
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/inventory
git 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 inventory function 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
Terminal window
git add internal/platform/inventory internal/platform/customfield/customfield.go db/migrations/00712_item_custom_fields.sql
git 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 (soleOwnerEdge covers committed, 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; in tradetest — Env and every helper listed in “Interfaces O2 and O3 consume”. Task 6 adds Draft, 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──▶ withdrawn
func 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──▶ withdrawn
func 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
Terminal window
git add internal/platform/trade
git 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
Terminal window
git add internal/platform/trade
git commit -m "Phase 4 O1: copy-forward, open quantity, manual close; chain drift tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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_id and sales_invoice_line.trade_line_id (foreign keys purchase_invoice_line_trade_line_fk, sales_invoice_line_trade_line_fk); refusals purchase_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 adds TradeLineID to payables.LineInput and receivables.LineInput with 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 StatementBegin
CREATE 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 StatementEnd
CREATE 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 Down
DROP 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
Terminal window
git add db/migrations/00702_invoice_trade_link.sql internal/platform/trade/invoicelink_test.go
git 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-field entity enums), regenerate
  • Modify: web/src/features/customfields/api.ts; locale files modules.json, team.json, customfields.json, common.json (en, ms, zh)

Interfaces:

  • Consumes: Task 4 (every inventory function), Task 5 (platform/trade’s init() 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); routes GET|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 codes duplicate_inventory_code, item_in_use; module keys inventory, 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.manage

In 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):

Terminal window
{ 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.go

Create 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 Down
DELETE 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/inventory

Append 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
Terminal window
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/src
git commit -m "Phase 4 O1: item master HTTP surface, permissions, grants and module install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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 (ImportKind enum 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 own loadLookups, accountRef, lineBad, inSavepoint, customFieldsOf, customFieldProblems.

  • Produces: imports.KindItems = "items" with fields 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 (+ cf.<key>); required columns code, 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
Terminal window
git add internal/platform/imports db/migrations/00714_import_items.sql api/openapi.yaml internal/gen/api web/src
git commit -m "Phase 4 O1: items import kind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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; hooks useItems, 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
Terminal window
git add web/src
git commit -m "Phase 4 O1: items and locations screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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 environment
variable.
- **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:

Terminal window
make generate && git status --porcelain # expected: no output — every generated file is committed
make lint # expected: no output from gofmt; go vet clean
go test ./... -count=1 # expected: ok for every package
make web # expected: typecheck, lint, test and build pass

If 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
Terminal window
git add docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.md
git 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.