Phase 4 O2: Stock and Costing 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: Stock moves in an append-only ledger with a balance that cannot go negative, costed per item by FIFO or weighted average (one method per organization, locked once stock moves), posting receipts to GRNI, deliveries and adjustments through a new effect semantic, with serial/batch enforcement, transfers, stock-take, stock reports and a valuation = GL integrity check.
Architecture: The workflow engine gains the effect semantic (“approved; posts with no control account”); accounting gains the grni control kind, a purchase-price-variance system role and an inventory owned-account module. internal/platform/inventory (O1’s package) gains the stock ledger (stock_move, trigger-maintained stock_balance), costing (item_cost, cost_layer), document application (Transition moves a receipt/delivery/return/adjustment’s stock, then runs workflow.Transition, whose Poster reads the moves), posting rules, transfers, reasons, reports and a reconcile check. internal/platform/inventory/adjustment creates adjustments and stock counts through O1’s trade.Create. internal/modules/inventory (O1’s module) serves twelve routes.
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · kernel/money
Spec: docs/superpowers/specs/2026-09-30-operations-design.md (O2.1–O2.5; OD-1, OD-2)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- O2 starts after O1 is merged, and consumes O1 as defined in
docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md(“Interfaces O2 and O3 consume”, Tasks 2–5). The names this plan uses from it:- Migration ranges: O1’s Task 1 already declares O2’s
00720–00739inmigrationranges_test.go; this plan adds none. It uses00720–00727. - Tables:
trade_document(kind, number, supplier_party_id, customer_party_id, currency, fx_rate, location_id, instance_id);trade_line(document_id, document_kind, line_no, item_id, description, quantity, uom_id, uom_factor, quantity_base, unit_price, net_amount, shares, source_line_id, withdrawn_at)— only astock_adjustmentline may be negative (direction by sign);item(code, type, base_uom_id, inventory_account_id, cogs_account_id, sales_account_id, purchase_account_id, tracking)withitem_accounts_by_type(a stock item names inventory and COGS accounts, others neither);location(code);uomregistry keyed by UN/ECE code (C62is “unit”). SQL functionstrade_semantic(org, instance); the triggeritem_fixed_once_used()raising constraintitem_fixed, which Task 3 replaces (O1 says O2 must). - Semantic: O1’s 00700 renames the inline check to
workflow_state_semantic_known(withcommitted);posting.Committed;workflow.CommitmentKinds. - Go:
trade.Create(ctx, tx, c, actor uuid.UUID, in trade.DocumentInput) (trade.Document, error),trade.DocumentInput{Kind; PartyID; Currency; FXRate *money.Rate; DueDate; LocationID; Lines []trade.LineInput},trade.LineInput{ItemID; Description; Quantity; UOMID; UnitPrice; TaxCodeID; Shares; SourceLineID},trade.Document{ID, Kind, Number, InstanceID, Lines []trade.Line{ID, QuantityBase, …}},trade.KindAdjustment,*trade.FieldError,trade.ErrNotFound,trade.ErrHasChildren;inventory.CreateItem/ItemInput,inventory.CreateLocation/LocationInput,inventory.Names,inventory.TypeStock/TypeService/TypeNonStock,inventory.TrackingNone/Serial/Batch,inventory.EntityItem; test fixturetrade/tradetest.Env(New,As,UOM,Account,Supplier,Customer,Location), whichstocktestembeds. - Module
internal/modules/inventory(O1 Task 8):Register(withh := handlers{deps: deps},pl := permissionLoader{db: deps.DB}),Deps{DB, Clock},decodeStrict,mapErr,PermissionItemRead = "inventory.item.read",PermissionItemManage = "inventory.item.manage", itsboot.go,permissions.goanderrors.go. O1’smapErrmaps item-master errors only;stockProblem(Task 12) maps workflow, ledger and trade refusals for O2’s routes. - Boundaries (O1’s Global Constraints):
platform/inventorynever importsplatform/trade;inventory/adjustmentand the tests import both. O1 seeds no graph for the five stock kinds (Ruling O1-14): O2 does (Task 5). O1 registers no provisioning step, so orders 81 and 83 are free. - Unexported helper names in package
inventoryare prefixed (stockOrg,stockLabels,strp…) to avoid O1’s (O1 already hasorgOfthere).
- Migration ranges: O1’s Task 1 already declares O2’s
- Quantities are
money.Ratein Go (as AR’s line quantity),NUMERIC(19,6)in SQL, strings on the wire. Money never float:money.Amount,NUMERIC(19,4), OpenAPIMoney. - Every new table:
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, policyorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid, composite(org_id, x)foreign keys only. Constraints named<table>_<rule>, none ending_check/_key/_excl. Trigger refusalsRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on the name. FunctionsSET search_path = pg_catalog, public, pg_temp; noSECURITY DEFINER. - Every SQL string ties each org-scoped table to
org_id = $n(directly or throughx.org_id = y.org_id); everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). - Retention (
internal/kernel/db/retention_test.go):stock_move,stock_transfer,receipt_valuation,stock_adjustment_detail,stock_count,stock_count_line→appendOnly(REVOKE UPDATE, DELETE);stock_balance,stock_batch,item_cost,cost_layer,stock_adjustment_reason→amendable(REVOKE DELETE);trade_line_lot→operational. - The move date is
clock.Today(ctx, c). No back-dated moves; a correction is a reversing move. - Accounting reacts to semantics, never state names. Stock entries post in the base currency. Rules return a
Draft; nothing here callsledger.Postexcept tests. - Lock order (inventory), written into
docs/LOCK_ORDER.mdin Task 13:trade_documentFOR UPDATE→ on a cancel, its livetrade_linerowsFOR UPDATEinidorder (O1’s order) →company_profileFOR SHARE→item_costrowsFOR UPDATEin item id order (created if missing) →cost_layerrowsFOR UPDATEinseqorder →stock_balance(upserted bystock_move’s trigger) →workflow.Transition(the Poster reads the moves) →audit.Write, last.company.SetCostingMethodtakescompany_profileFOR UPDATE.adjustment.Createwrites its detail rows aftertrade.Create’s audit: plain INSERTs whose only locks areFOR KEY SHAREon rows this transaction wrote or that nothing locksFOR UPDATE. make generateafter every migration, OpenAPI orconstants.yamlchange; commit the generated files;git statusclean.- Presence before absence. Every refusal test shows the accepted case first. Every new guard is watched failing once: each task with a guard has a Sabotage step — break it, run, read the red message, restore, quote the message in the commit body.
- Posting is process-global. A test that posts goes through
stocktest.New(which registers the Poster overinventory.Rules()andpayables.Rules()); nevert.Parallel(). - Tests: while iterating,
go test ./internal/platform/inventory/... -count=1(plus the package a task touches); one fullgo test -p 2 -count=1 ./...in Task 13 only. - Never bare
git stash. Use a WIP commit. Commits end withCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
Rulings
Section titled “Rulings”Where the spec is silent. R-O2-n: decision — why.
- R-O2-1
effectshares the instance’saccrual_openflag: entering it opens the instance’s posting, enteringrejected/cancelledafter it reverses (the Poster’s mirror). The validator treats it as an accrual without a control account (no cycle, no repeat, reversal flag required). An effect rule may return aDraftwith no lines (a delivery of service items): nothing posts, and the cancellation of an effect kind with nothing on the ledger reverses nothing instead of failing — the only way a service-only delivery could ever be cancelled. - R-O2-2
stock_movehas nojournal_entry_id. The moves are written before the entry (the rule reads them) and the table is append-only, so the id cannot be stamped afterwards. The link isstock_move.trade_line_id → trade_document, andjournal_entry(source_kind, source_id)is the document. - R-O2-3 Stock entries post in base currency. A receipt line is valued at its
net_amount(O1’sunit_price × quantity, to the cent) in the document currency, converted at the receipt date’s rate (fx.Resolve), and recorded in append-onlyreceipt_valuation(a reversal adds a negating row). O3’s purchase invoice clears GRNI from it (inventory.ReceivedValue). - R-O2-4
item_cost(quantity, value)is kept for both methods — it is the per-item lock and the valuation. The average is derived (value ÷ quantity, 4 places), never stored. FIFO layers carryremaining_value. The issue that empties an item (weighted average) or a layer (FIFO) takes the residue; an issue never takes more than the value held. - R-O2-5 A transfer is one item between two locations, carrying zero cost (cost is per item across the organization, spec O2.2).
- R-O2-6 Stock that comes back (a sales return, a reversed issue) re-enters FIFO as a new layer at the cost it left at, not back into the layers it came from. A reversed purchase return’s layer keeps the receipt line it came from, so that receipt can be returned again.
- R-O2-7 The posting rules and the seeded workflow graphs of the five stock kinds are O2’s. Commitment kinds (requisition, order, quotation, sales order) go through O1’s
trade.Transition; the five stock kinds go throughinventory.Transition, which does whattrade.Transitiondoes (header lock; a document with live children cannot be cancelled,trade.ErrHasChildren’s rule, checked with O1’strade_semantic) and moves or reverses the stock between the header lock andworkflow.Transition. It cannot wraptrade.Transition(no hook between its lock and the engine, andinventorymay not importtrade). O3 adds its own checks (separation of duties, three-way match) under the same header lock and callsinventory.Transition. - R-O2-8 The purchase-invoice side of O2.3’s table (Dr GRNI / Cr AP, price difference to PPV, FX to realised FX) is O3’s: it needs the invoiced quantity O3 adds to the invoice line for three-way match. O2 provides the
grnicontrol kind,chart.PurchasePriceVarianceandinventory.ReceivedValue. - R-O2-9 The default costing method is
weighted_average.PUT /inventory/costing-method(org.profile.manage) changes it until the first move. - R-O2-10 O1’s
item_fixed_once_used()fixes type, base unit and tracking once a trade line names the item. Task 3 replaces it (constraint name kept:item_fixed) so those three are also fixed once astock_moveexists (a transfer names no trade line), and the inventory account is fixed once the item has moved: valuation compares each inventory account with its items’ value. Provisioning’s Inventory account is owned by moduleinventory, so no manual journal moves it. - R-O2-11 “Serial unique per item” means one location holds a serial at a time (a partial unique index on
stock_balance); a serial may leave and come back (a return). - R-O2-12 Adjustments and stock counts are in the item’s base UOM and the base currency; an adjustment line’s direction is its quantity’s sign (O1’s
trade_line_quantity_sign), and the API’sdirectionmaps onto it. Reasons are their own table with a GL account; provisioning seedscountanddamageon Stock adjustment (5400). Managing reasons needsaccounting.account.manage(it chooses a GL account). - R-O2-13 Seeded adjustment flow:
draft → submitted → approved (effect), approval and cancellation granted to the Owner seat. The sole-owner exception (Ruling O-3a) covers the five stock kinds’ void and the adjustment’s approval, so a one-member organization can use them. - R-O2-14 Stock reports are
GET /inventory/stock/{on-hand,movements,valuation}underinventory.item.read, not/reports(they have no export kind). Movements are capped at 5,000 rows per request (422too_many_rows; narrow the dates). - R-O2-15 A purchase return debits GRNI at the returned receipt’s value pro rata; the difference from the stock cost it leaves at goes to PPV (consistent with spec Ruling R-1).
- R-O2-16 A stock count is one request: it records counted and system quantities and creates a draft adjustment for the differences, “in” lines at the current unit cost (average, or the newest FIFO layer’s; 0 when none). The adjustment then goes through its own approval.
- R-O2-17 Spec O3.1 receives service and non-stock lines on a GRN (acceptance): O2 does — they post Expense (the item’s purchase account) / GRNI with no stock move. Only a description-only text line (no item) is refused on a receipt or purchase return (
ErrLineNeedsItem): it has no account to post to. A service or non-stock item received needs a purchase account (ErrItemAccountMissing). - R-O2-18 Provisioning steps:
inventory_stock_accountsorder 81,inventory_adjustment_reasonsorder 83. - R-O2-19 No web UI in O2 (API only); the web gets only the
effecttone and thegrnimanual-line flag its typecheck requires.
Interfaces O3 consumes
Section titled “Interfaces O3 consumes”package inventory // internal/platform/inventory
const ( KindGoodsReceipt = "goods_receipt" KindPurchaseReturn = "purchase_return" KindDeliveryOrder = "delivery_order" KindSalesReturn = "sales_return" KindStockAdjustment = "stock_adjustment" ControlKindGRNI = "grni")
// A serial (Quantity 1) or batch a trade line moves.type Lot struct { SerialNo string `json:"serial_no,omitempty"` BatchNo string `json:"batch_no,omitempty"` ExpiresOn *time.Time `json:"expires_on,omitempty"` Quantity money.Rate `json:"quantity"`}
// SetLineLots replaces a draft line's serials/batches (caller holds the// document header FOR UPDATE). ErrAlreadyApplied once the line has moved.func SetLineLots(ctx context.Context, tx pgx.Tx, lineID uuid.UUID, lots []Lot) error
type TransitionInput struct { DocumentID uuid.UUID From, To string Actor uuid.UUID Reason string}
// Transition moves a stock document (the five stock kinds; the commitment// kinds use O1's trade.Transition, R-O2-7): into its posting state it moves// the stock (receipt in at value, delivery out at FIFO/average cost, returns// at the cost of the line they return), then workflow.Transition posts;// into rejected/cancelled after posting it reverses the moves first. A// document with live children is not cancelled (ErrHasChildren, O1-7).func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (workflow.Decision, error)
// OnTransition is Transition's stock half, for a caller with its own// wrapper: call it after locking the header and before workflow.Transition.// posted is the instance's AccrualOpen before the move.func OnTransition(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID, to posting.Semantic, posted bool) error
// What a goods-receipt line brought in, net of reversals.type Received struct { Quantity money.Rate // base UOM Amount money.Amount // document currency Currency string Base money.Amount // base currency: what GRNI was credited}
func ReceivedValue(ctx context.Context, tx pgx.Tx, receiptLineID uuid.UUID) (Received, error)
func Rules() []accounting.PostingRule // registered by internal/modules/inventory's bootpackage chart // internal/platform/accounting/chartconst RolePurchasePriceVariance = "purchase_price_variance"func PurchasePriceVariance(ctx context.Context, tx pgx.Tx) (Account, error) // ErrNoPurchasePriceVariancePlus posting.Effect, ledger.SemanticEffect, the GRNI control resolved by chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI).
File Structure
Section titled “File Structure”| Path | Responsibility |
|---|---|
db/migrations/00720_effect_semantic.sql |
effect on workflow_state and journal_entry CHECKs |
db/migrations/00721_grni_and_stock_accounts.sql |
grni control kind, purchase_price_variance role, inventory owner module |
db/migrations/00722_stock_ledger.sql |
stock_transfer, stock_move, stock_balance, stock_batch, their triggers; replaces O1’s item_fixed_once_used() |
db/migrations/00723_costing_method.sql |
company_profile.costing_method and its lock trigger |
db/migrations/00724_cost_layers.sql |
item_cost, cost_layer |
db/migrations/00725_stock_documents.sql |
trade_line_lot, receipt_valuation, stock_adjustment_reason, stock_adjustment_detail |
db/migrations/00726_stock_count.sql |
stock_count, stock_count_line, stock_adjustment_detail.count_id |
db/migrations/00727_inventory_stock_grants.sql |
existing Owners get the four new keys |
internal/platform/posting/posting.go |
Effect |
internal/platform/accounting/{rules,poster}.go, ledger/{types,post,query}.go |
effect accepted, empty effect, effect reversal |
internal/platform/workflow/{validate,instance,authorize,actors,soleowner}.go |
effect in the engine |
internal/platform/accounting/chart/{account,errors}.go |
grni subledger, PPV role |
internal/platform/company/company.go |
CostingMethod, SetCostingMethod |
internal/kernel/money/rate.go |
Rate.Sub, Neg, IsNegative |
internal/platform/inventory/stock.go |
constants, errors, mapStockError, insertMove |
internal/platform/inventory/step.go |
the stock-accounts provisioning step |
internal/platform/inventory/graphs.go |
the five seeded graphs, sole-owner opt-in |
internal/platform/inventory/costing.go |
beginStock, costIn, costOut, layers, CurrentUnitCost |
internal/platform/inventory/stockline.go |
reading trade lines, lots |
internal/platform/inventory/apply.go |
apply, reverse, OnTransition, Transition, ReceivedValue |
internal/platform/inventory/posting.go |
the five posting rules |
internal/platform/inventory/transfer.go |
TransferStock |
internal/platform/inventory/reason.go |
adjustment reasons, their provisioning step |
internal/platform/inventory/adjustment/adjustment.go |
Create, Get, RecordCount |
internal/platform/inventory/integrity.go |
stock_valuation reconcile check |
internal/platform/inventory/report.go |
on hand, movements, valuation |
internal/platform/inventory/stocktest/stocktest.go |
the test world |
internal/modules/inventory/stock_{permissions,boot,handlers}.go |
permissions, rule registration, routes |
api/openapi.yaml, constants.yaml, web/src/... |
contract and vocabulary |
Task 1: The effect semantic, end to end
Section titled “Task 1: The effect semantic, end to end”This task is canonical for effect; O4’s Task 1 carries it over as 00770. If O4 landed first (git grep -n 'Effect Semantic = "effect"' internal/platform/posting/ hits), keep only this task’s tests that O4 lacks, add no 00720, and go to Task 2.
Files:
- Create:
db/migrations/00720_effect_semantic.sql - Modify:
constants.yaml(workflowSemantics), thenmake generate - Modify:
internal/platform/posting/posting.go,internal/platform/posting/posting_test.go - Modify:
internal/platform/accounting/ledger/types.go,post.go(validateHeader),query.go(Unreversed) - Modify:
internal/platform/accounting/rules.go(Register),poster.go(Post,reverse) - Modify:
internal/platform/accounting/testrules_test.go(registertestEffect) - Create:
internal/platform/accounting/poster_effect_test.go,internal/platform/accounting/ledger/effect_test.go - Modify:
internal/platform/workflow/validate.go(semantics,rule3,rule5),instance.go(flag),authorize.go(reporterMayPost),actors.go(FinalApproval),soleowner.go(soleOwnerEdge) - Create:
internal/platform/workflow/effect_test.go,internal/platform/workflow/soleowner_effect_export_test.go - Modify:
api/openapi.yaml(enumsSemantic,WorkflowSemantic),web/src/components/workflow/semanticTone.ts
Interfaces:
-
Produces:
posting.Effect Semantic = "effect";ledger.SemanticEffect = "effect";RuleSet.Registeraccepts a rule whoseSemantic()isposting.Effect;Poster.Postposts nothing for an effectDraftwith no lines;Poster.reversereturns nil when nothing is open and(kind, effect)has a rule;workflow.Transitionsetsaccrual_openon entering an effect state. -
Step 1: Write the failing tests
internal/platform/posting/posting_test.go — replace TestConstantsMatchTheVocabulary’s list and add the effect case to TestPostsToLedger:
func TestConstantsMatchTheVocabulary(t *testing.T) { want := []string{} for _, s := range []posting.Semantic{posting.Draft, posting.Pending, posting.Accrual, posting.Payout, posting.Acknowledged, posting.Dispute, posting.Rejected, posting.Cancelled, posting.Custom, posting.Committed, posting.Effect} { want = append(want, string(s)) } require.Equal(t, vocab.Semantics, want)}and inside TestPostsToLedger’s cases, after {posting.Event{Semantic: posting.Payout}, true},:
// Phase 4 O2.3: an effect posts with no control account. {posting.Event{Semantic: posting.Effect}, true},internal/platform/accounting/testrules_test.go — append:
const effectKind = "test_effect"
// testEffect: Dr Travel / Cr Cash at the document's amount, with no control// account. A zero amount builds no lines: a delivery of services only.type testEffect struct { docs docStore travel, cash uuid.UUID}
func (testEffect) Kind() string { return effectKind }func (testEffect) Semantic() posting.Semantic { return posting.Effect }
func (r testEffect) Build(_ context.Context, _ pgx.Tx, src accounting.Source) (accounting.Draft, error) { d, err := r.docs.get(src.Event.SourceID) if err != nil { return accounting.Draft{}, err } out := accounting.Draft{JournalType: chart.JournalMisc, Currency: d.Currency, Description: "Test effect " + src.Event.SourceID.String()} if !d.Amount.IsZero() { out.Lines = []ledger.Line{{AccountID: r.travel, Debit: d.Amount}, {AccountID: r.cash, Credit: d.Amount}} } return out, nil}and in poster_test.go’s newFixture, after rules.Register(testPayout{}):
rules.Register(testEffect{docs: docs, travel: b.Chart.Travel.ID, cash: b.Chart.Cash.ID})internal/platform/accounting/poster_effect_test.go:
package accounting_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/accounting" "github.com/mcom/erp/internal/platform/accounting/accountingtest" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/posting")
// effect files a test_effect document and returns its instance entering an// effect state: no control kind.func (f *fixture) effect(currency, amount string) posting.Event { src := uuid.New() f.docs[src] = doc{Currency: currency, Amount: amt(amount)} return posting.Event{OrgID: f.e.Org, Kind: effectKind, InstanceID: uuid.New(), SourceID: src, Semantic: posting.Effect, BusinessDate: accountingtest.Date("2026-09-20"), ActorMemberID: f.member}}
// effectBalances is balances for effectKind: amount per account, debit positive.func (f *fixture) effectBalances(t *testing.T, sourceID uuid.UUID) map[uuid.UUID]money.Amount { t.Helper() out := map[uuid.UUID]money.Amount{} f.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT l.account_id, SUM(l.debit - l.credit) FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id WHERE l.org_id = $1 AND e.source_kind = $2 AND e.source_id = $3 GROUP BY l.account_id`, f.e.Org, effectKind, sourceID) require.NoError(t, err) defer rows.Close() for rows.Next() { var id uuid.UUID var a money.Amount require.NoError(t, rows.Scan(&id, &a)) out[id] = a } return rows.Err() }) return out}
func TestAnEffectPostsWithNoControlAccountAndReversesOnCancel(t *testing.T) { f := newFixture(t) ch := f.b.Chart
ev := f.effect("MYR", "40") f.mustPost(t, ev) got := f.entries(t, ev.InstanceID) require.Len(t, got, 1) require.Equal(t, ledger.SemanticEffect, got[0].Semantic) before := f.effectBalances(t, ev.SourceID) requireAmount(t, "40", before[ch.Travel.ID], "PRESENCE: travel") requireAmount(t, "-40", before[ch.Cash.ID], "PRESENCE: cash")
require.ErrorIs(t, f.post(ev), accounting.ErrAlreadyPosted, "an effect posts once")
f.mustPost(t, then(ev, posting.Cancelled, "2026-09-20")) got = f.entries(t, ev.InstanceID) require.Len(t, got, 2) require.Equal(t, ledger.SemanticReversal, got[1].Semantic) for id, a := range f.effectBalances(t, ev.SourceID) { require.True(t, a.IsZero(), "account %s still holds %s", id, a) }}
func TestAnEffectWithNothingToPostPostsAndReversesNothing(t *testing.T) { f := newFixture(t)
// PRESENCE: a non-zero effect posts. posted := f.effect("MYR", "5") f.mustPost(t, posted) require.Len(t, f.entries(t, posted.InstanceID), 1)
empty := f.effect("MYR", "0") f.mustPost(t, empty) require.Empty(t, f.entries(t, empty.InstanceID), "no lines, no entry") f.mustPost(t, then(empty, posting.Cancelled, "2026-09-20")) require.Empty(t, f.entries(t, empty.InstanceID), "and its cancellation reverses nothing")
// An accrual kind still refuses a reversal the ledger cannot back. ghost := then(f.claim("MYR", "80"), posting.Rejected, "2026-09-20") require.ErrorIs(t, f.post(ghost), accounting.ErrNothingToReverse)}internal/platform/accounting/ledger/effect_test.go:
package ledger_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/accounting/accountingtest" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger")
func TestAnEffectEntryNamesItsSourceAndStaysOpenUntilReversed(t *testing.T) { e := accountingtest.New(t) b := e.SeedBooks(t) in := ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: accountingtest.Date("2026-09-20"), Description: "Stock effect", Semantic: ledger.SemanticEffect, Lines: []ledger.Line{ {AccountID: b.Chart.Travel.ID, Debit: money.MustParse("10")}, {AccountID: b.Chart.Cash.ID, Credit: money.MustParse("10")}, }} err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := ledger.Post(ctx, tx, e.Clock, in) return err }) require.ErrorIs(t, err, ledger.ErrMissingSource)
src, inst := uuid.New(), uuid.New() in.SourceKind, in.SourceID, in.InstanceID = "delivery_order", &src, &inst e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { got, err := ledger.Post(ctx, tx, e.Clock, in) require.NoError(t, err, "journal_entry_semantic_known accepts effect") require.Equal(t, ledger.SemanticEffect, got.Semantic) open, err := ledger.Unreversed(ctx, tx, inst) require.NoError(t, err) require.Len(t, open, 1) return nil })}internal/platform/workflow/soleowner_effect_export_test.go:
package workflow
// SoleOwnerEdgeForTest exposes soleOwnerEdge to effect_test.go.var SoleOwnerEdgeForTest = soleOwnerEdgeinternal/platform/workflow/effect_test.go:
package workflow_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/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
// effectFlow is a stock adjustment's shape: submit, approve into effect,// reject back to draft, cancel after posting (reversing).func effectFlow() workflow.Graph { return workflow.Graph{ Kind: "effect_flow", PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st("draft", posting.Draft, false), st("pending", posting.Pending, false), st("posted", posting.Effect, false), st("rejected", posting.Rejected, false), st("cancelled", posting.Cancelled, true), }, Edges: []workflow.EdgeSpec{ ed("draft", "pending", workflow.GateReporter), ed("pending", "posted", workflow.GateTarget), rejecting("pending", "rejected", workflow.GateAdvance, false), ed("rejected", "draft", workflow.GateReporter), rejecting("posted", "cancelled", workflow.GateTarget, true), }, Grants: []workflow.GrantSpec{anyone("posted"), anyone("cancelled")}, }}
func requireEffectOnly(t *testing.T, g workflow.Graph, rule int, code string) { t.Helper() require.Empty(t, workflow.ValidateGraph(effectFlow(), "MYR"), "the effect fixture must be valid, or every absence below is vacuous") vs := workflow.ValidateGraph(g, "MYR") require.NotEmpty(t, vs, "expected %s", code) for _, v := range vs { require.Equal(t, rule, v.Rule, "unexpected violation %+v", v) } require.Contains(t, codes(vs), code)}
func TestAnEffectNamesNoControlAccount(t *testing.T) { g := effectFlow() g.States[2].ControlKind = "ap" requireEffectOnly(t, g, 0, "control_kind_forbidden")}
func TestCancellingAfterAnEffectMustReverseIt(t *testing.T) { g := effectFlow() g.Edges[4].ReversesAccrual = false requireEffectOnly(t, g, 5, "reversal_missing")}
func TestAnEffectCannotBeEnteredTwice(t *testing.T) { g := effectFlow() g.Edges = append(g.Edges, ed("posted", "pending", workflow.GateReporter)) requireEffectOnly(t, g, 3, "effect_on_cycle")}
type effectRecorder struct{ got []posting.Event }
func (r *effectRecorder) Post(_ context.Context, _ pgx.Tx, ev posting.Event) error { r.got = append(r.got, ev) return nil}
func TestEnteringAnEffectOpensThePostingAndCancellingReversesIt(t *testing.T) { w := workflowtest.NewWorld(t) ann := w.Member("Ann") w.Sit(ann, w.Seat("OWNER", nil)) rec := &effectRecorder{} posting.ResetForTest() t.Cleanup(posting.ResetForTest) posting.Register(rec)
g := workflow.Graph{Kind: "effect_probe", PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{st("draft", posting.Draft, false), st("posted", posting.Effect, false), st("cancelled", posting.Cancelled, true)}, Edges: []workflow.EdgeSpec{ed("draft", "posted", workflow.GateReporter), rejecting("posted", "cancelled", workflow.GateReporter, true)}} var inst workflow.Instance w.MustTx(func(ctx context.Context, tx pgx.Tx) error { if _, err := workflow.PublishGraph(ctx, tx, w.Clock, g, nil); err != nil { return err } var err error inst, err = workflow.Start(ctx, tx, w.Clock, workflow.StartInput{Kind: "effect_probe", SourceID: uuid.New(), Reporter: ann, Amount: money.Zero()}) return err }) w.MustTx(func(ctx context.Context, tx pgx.Tx) error { if _, err := workflow.Transition(ctx, tx, w.Clock, workflow.TransitionInput{InstanceID: inst.ID, From: "draft", To: "posted", Actor: ann}); err != nil { return err } got, err := workflow.Get(ctx, tx, inst.ID) require.NoError(t, err) require.True(t, got.AccrualOpen, "entering an effect opens the instance's posting") if _, err := workflow.Transition(ctx, tx, w.Clock, workflow.TransitionInput{InstanceID: inst.ID, From: "posted", To: "cancelled", Actor: ann, Reason: "Counted twice"}); err != nil { return err } got, err = workflow.Get(ctx, tx, inst.ID) require.NoError(t, err) require.False(t, got.AccrualOpen) return nil }) require.Len(t, rec.got, 2) require.Equal(t, posting.Effect, rec.got[0].Semantic) require.False(t, rec.got[0].PastAccrual) require.Equal(t, posting.Cancelled, rec.got[1].Semantic) require.True(t, rec.got[1].PastAccrual, "the cancellation is a reversal")}
func TestTheSoleOwnerExceptionCoversAnEffectKindsIssueAndVoid(t *testing.T) { g := effectFlow() g.Kind = "effect_sole_owner" def := workflow.Definition{Graph: g} issue, void := g.Edges[1], g.Edges[4] // PRESENCE of the absence: an unregistered kind gets nothing. require.False(t, workflow.SoleOwnerEdgeForTest(def, issue)) workflow.AllowSoleOwnerException(g.Kind, true) require.True(t, workflow.SoleOwnerEdgeForTest(def, issue), "the edge into the effect state") require.True(t, workflow.SoleOwnerEdgeForTest(def, void), "the reversing exit from the effect state") require.False(t, workflow.SoleOwnerEdgeForTest(def, g.Edges[0]), "submitting is not covered")}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/posting/... ./internal/platform/accounting/... ./internal/platform/workflow/... -count=1
Expected: build failure undefined: posting.Effect / undefined: ledger.SemanticEffect.
- Step 3: Vocabulary and migration (O1’s Task 1 already declared the
00720–00739range)
constants.yaml, under workflowSemantics: after - committed:
- effectdb/migrations/00720_effect_semantic.sql:
-- +goose Up-- Phase 4 O2.3 (docs/superpowers/specs/2026-09-30-operations-design.md): the-- effect semantic — approved, and its effect posts with no control account-- (a delivery's cost of sales, a stock adjustment, O4's asset disposal).-- O1's 00700 named the check workflow_state_semantic_known (with committed).ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom', 'committed', 'effect'));
ALTER TABLE journal_entry DROP CONSTRAINT journal_entry_semantic_known, ADD CONSTRAINT journal_entry_semantic_known CHECK (semantic IN ('accrual', 'payout', 'reversal', 'manual', 'year_end_close', 'effect')), DROP CONSTRAINT journal_entry_workflow_source, ADD CONSTRAINT journal_entry_workflow_source CHECK (semantic NOT IN ('accrual', 'payout', 'effect') OR (source_kind IS NOT NULL AND source_id IS NOT NULL AND instance_id IS NOT NULL));
-- +goose Down-- Fails while any state or entry says effect: roll the code back first.ALTER TABLE journal_entry DROP CONSTRAINT journal_entry_workflow_source, ADD CONSTRAINT journal_entry_workflow_source CHECK (semantic NOT IN ('accrual', 'payout') OR (source_kind IS NOT NULL AND source_id IS NOT NULL AND instance_id IS NOT NULL)), DROP CONSTRAINT journal_entry_semantic_known, ADD CONSTRAINT journal_entry_semantic_known CHECK (semantic IN ('accrual', 'payout', 'reversal', 'manual', 'year_end_close'));ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN ('draft', 'pending', 'accrual', 'payout', 'acknowledged', 'dispute', 'rejected', 'cancelled', 'custom', 'committed'));- Step 4: The seam, the ledger and the Poster
internal/platform/posting/posting.go — after Custom Semantic = "custom" (and O1’s Committed):
// Effect (Phase 4 O2.3): approved, and its effect posts with no control // account — a delivery's cost of sales, a stock adjustment, an asset // disposal. It opens the instance's posting as an accrual does // (Event.PastAccrual), so rejected/cancelled after it reverses. Effect Semantic = "effect"and in PostsToLedger:
case Accrual, Payout, Effect: return trueinternal/platform/accounting/ledger/types.go — in the semantics block:
SemanticEffect = "effect"internal/platform/accounting/ledger/post.go validateHeader — both switches:
switch in.Semantic { case SemanticAccrual, SemanticPayout, SemanticEffect, SemanticManual, SemanticYearEndClose: default: case SemanticAccrual, SemanticPayout, SemanticEffect: if in.SourceKind == "" || in.SourceID == nil || in.InstanceID == nil {internal/platform/accounting/ledger/query.go Unreversed:
AND e.semantic IN ('accrual', 'payout', 'effect')internal/platform/accounting/rules.go Register:
if sem != posting.Accrual && sem != posting.Payout && sem != posting.Effect { panic(fmt.Sprintf("accounting: posting rule for %q: semantic %q is not accrual, payout or effect (Ruling R7)", kind, sem)) }(update the PostingRule doc comment: “Semantic is Accrual, Payout or Effect only”.)
internal/platform/accounting/poster.go — in Post, right after draft, err := rule.Build(...)’s error check:
// R-O2-1: an effect with nothing to post (a delivery of service items // only) writes no entry; reverse below accepts its cancellation. if ev.Semantic == posting.Effect && len(draft.Lines) == 0 { return nil }and in reverse, replace the len(open) == 0 branch:
if len(open) == 0 { // An effect kind may have posted nothing (R-O2-1): nothing to undo. if _, effect := p.rules.Lookup(ev.Kind, posting.Effect); effect { return nil } return fmt.Errorf("accounting: reverse on %s of %s %s: %w", ev.Semantic, ev.Kind, ev.SourceID, ErrNothingToReverse) }- Step 5: The engine
internal/platform/workflow/validate.go:
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, posting.Effect: true,}In rule3 (a), after the payout_on_cycle block:
if c.is(i, posting.Effect) { c.add(3, "effect_on_cycle", c.g.States[i].Key, "%s can be entered twice without a reversal", c.g.States[i].Key) cyclic = true }In rule3 (b):
for _, sem := range []posting.Semantic{posting.Accrual, posting.Payout, posting.Effect} {In rule5:
if c.is(i, posting.Accrual) || c.is(i, posting.Payout) || c.is(i, posting.Effect) {(and its comment: “at or past an accrual, a payout or an effect”).
internal/platform/workflow/instance.go — in Transition’s flag switch:
switch to.Semantic { case posting.Accrual, posting.Effect: // R-O2-1: an effect is an accrual with no control open = trueinternal/platform/workflow/authorize.go reporterMayPost:
case posting.Accrual, posting.Effect: return trueinternal/platform/workflow/actors.go FinalApproval:
case to.Semantic != posting.Accrual && to.Semantic != posting.Payout && to.Semantic != posting.Effect: return falseinternal/platform/workflow/soleowner.go soleOwnerEdge:
posts := func(s posting.Semantic) bool { return s == posting.Accrual || s == posting.Effect } void := posts(from.Semantic) && to.Semantic == posting.Cancelled && e.ReversesAccrual return (m.void && void) || (m.issue && posts(to.Semantic)) || (m.payout && to.Semantic == posting.Payout)- Step 6: Contract and web
api/openapi.yaml: append effect to the enum of components.schemas.Semantic and of components.schemas.WorkflowSemantic (after committed). In WorkflowState.control_kind’s description keep “Required on, and only on, an accrual state.”
web/src/components/workflow/semanticTone.ts, in TONE after payout:
// Phase 4 O2.3: an approved stock effect (a delivery, an adjustment) — posted, like accrual. effect: 'green',Run: make generate && git status --short
Expected: internal/kernel/vocab/vocab.gen.go, web vocab/schema and internal/gen/* change; nothing else unexpected.
- Step 7: Run the tests to verify they pass
Run: go test ./internal/platform/posting/... ./internal/platform/accounting/... ./internal/platform/workflow/... ./internal/kernel/... -count=1 && pnpm -C web typecheck
Expected: PASS.
- Step 8: Sabotage
Remove posting.Effect from rule5’s condition; run go test ./internal/platform/workflow/ -run TestCancellingAfterAnEffectMustReverseIt -count=1; read the red (expected reversal_missing); restore. Then delete the len(draft.Lines) == 0 early return in Poster.Post; run go test ./internal/platform/accounting/ -run TestAnEffectWithNothingToPost -count=1; read the red (too few lines); restore.
- Step 9: Commit
git add constants.yaml db/migrations/00720_effect_semantic.sql internal/ api/openapi.yaml web/git commit -m "Phase 4 O2: the effect semantic
Approved, and its effect posts with no control account (spec O2.3).Shares accrual_open; an effect draft with no lines posts nothing andits cancellation reverses nothing (R-O2-1).
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 2: grni, the purchase-price-variance role, the inventory owned account, and the stock accounts step
Section titled “Task 2: grni, the purchase-price-variance role, the inventory owned account, and the stock accounts step”Files:
- Create:
db/migrations/00721_grni_and_stock_accounts.sql - Modify:
constants.yaml(controlKinds), thenmake generate - Modify:
internal/platform/accounting/chart/account.go(IsSubledgerKind, role, resolver),internal/platform/accounting/chart/errors.go - Modify:
internal/platform/accounting/chart/account_test.go(the two classification maps; one new test) - Create:
internal/platform/inventory/step.go,internal/platform/inventory/step_test.go - Modify:
api/openapi.yaml(Account.system_role,Account.owner_module,WorkflowState.control_kindenums),web/src/features/accounting/controlKinds.ts
Interfaces:
-
Consumes: nothing from Task 1.
-
Produces:
inventory.ControlKindGRNI = "grni",inventory.OwnerModule = "inventory",chart.RolePurchasePriceVariance,chart.PurchasePriceVariance(ctx, tx) (chart.Account, error),chart.ErrNoPurchasePriceVariance; provisioning creates 1580 Inventory (owned), 2170 GRNI (grni), 5200 Cost of goods sold, 5300 Purchase price variance (role), 5400 Stock adjustment. -
Step 1: Write the failing tests
internal/platform/accounting/chart/account_test.go — in TestControlKindsMatchTheVocabulary’s typeFor add "grni": chart.TypeLiability,; in TestEveryControlKindSaysWhetherManualLinesMayTouchIt’s refused add "grni": true,. Append:
func TestPurchasePriceVarianceIsOneExpenseAccount(t *testing.T) { e := accountingtest.New(t) role := chart.RolePurchasePriceVariance e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := chart.PurchasePriceVariance(ctx, tx) require.ErrorIs(t, err, chart.ErrNoPurchasePriceVariance) a, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{Code: "5300", Name: "Purchase price variance", Type: chart.TypeExpense, SystemRole: &role}) require.NoError(t, err) got, err := chart.PurchasePriceVariance(ctx, tx) require.NoError(t, err) require.Equal(t, a.ID, got.ID) return nil }) err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{Code: "3300", Name: "Variance", Type: chart.TypeEquity, SystemRole: &role}) return err }) var pg *pgconn.PgError require.ErrorAs(t, err, &pg) require.Equal(t, "account_system_role_type", pg.ConstraintName)}(add "github.com/jackc/pgx/v5/pgconn" to the imports if absent.)
internal/platform/inventory/step_test.go:
package inventory_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/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/i18n" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/payables/payablestest")
func TestProvisioningGivesTheStockAccounts(t *testing.T) { e := payablestest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { inv, err := chart.GetAccountByCode(ctx, tx, "1580") require.NoError(t, err) require.Equal(t, chart.TypeAsset, inv.Type) require.NotNil(t, inv.OwnerModule) require.Equal(t, inventory.OwnerModule, *inv.OwnerModule) grni, err := chart.ResolveControl(ctx, tx, inventory.ControlKindGRNI) require.NoError(t, err) require.Equal(t, "2170", grni.Code) require.Equal(t, chart.TypeLiability, grni.Type) ppv, err := chart.PurchasePriceVariance(ctx, tx) require.NoError(t, err) require.Equal(t, "5300", ppv.Code) for _, code := range []string{"5200", "5400"} { a, err := chart.GetAccountByCode(ctx, tx, code) require.NoError(t, err) require.Equal(t, chart.TypeExpense, a.Type) } names, err := i18n.Load(ctx, tx, "account", []uuid.UUID{grni.ID}, "name") require.NoError(t, err) require.NotEmpty(t, names[grni.ID], "the account is named in translation rows") return nil })}
func TestNoManualLineMovesTheInventoryAccount(t *testing.T) { e := payablestest.New(t) manual := func(code string) error { return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.GetAccountByCode(ctx, tx, code) if err != nil { return err } capital, err := chart.GetAccountByCode(ctx, tx, "3100") if err != nil { return err } owner := e.Owner _, err = ledger.Post(ctx, tx, e.Clock, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: expensestest.Today, Description: "Stock found", Semantic: ledger.SemanticManual, PostedBy: &owner, Lines: []ledger.Line{{AccountID: a.ID, Debit: money.MustParse("5")}, {AccountID: capital.ID, Credit: money.MustParse("5")}}}) return err }) } // PRESENCE: the same entry against an unowned asset posts. require.NoError(t, manual("1570")) var owned *ledger.OwnedAccountManualError require.ErrorAs(t, manual("1580"), &owned)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/accounting/chart/... ./internal/platform/inventory/ -run 'ControlKind|PurchasePriceVariance|StockAccounts|InventoryAccount' -count=1
Expected: build failure undefined: chart.RolePurchasePriceVariance.
- Step 3: Migration and vocabulary
constants.yaml, under controlKinds: after - wht_payable:
- grnidb/migrations/00721_grni_and_stock_accounts.sql:
-- +goose Up-- Phase 4 O2.3: goods received not invoiced is a control kind (a receipt's-- accrual credits it, the purchase invoice clears it; no manual line may-- move it). Purchase price variance is a system role, one active expense-- account. The provisioned Inventory account is owned by module inventory,-- so only stock postings move it and its balance can equal the stock's-- value (spec O2.5).ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable','grni') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known, ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));
ALTER TABLE account DROP CONSTRAINT account_system_role_known, ADD CONSTRAINT account_system_role_known CHECK (system_role IN ('retained_earnings', 'migration_clearing', 'purchase_price_variance'));ALTER TABLE account DROP CONSTRAINT account_system_role_equity, ADD CONSTRAINT account_system_role_type CHECK (system_role IS NULL OR (system_role IN ('retained_earnings', 'migration_clearing') AND type = 'equity') OR (system_role = 'purchase_price_variance' AND type = 'expense'));CREATE UNIQUE INDEX account_one_purchase_price_variance ON account (org_id) WHERE is_active AND system_role = 'purchase_price_variance';
ALTER TABLE account DROP CONSTRAINT account_owner_module_known, ADD CONSTRAINT account_owner_module_known CHECK (owner_module IS NULL OR owner_module IN ('pettycash', 'inventory'));
-- +goose Down-- Fails while an account or state carries grni, the role or the owner:-- roll the code back first.ALTER TABLE account DROP CONSTRAINT account_owner_module_known, ADD CONSTRAINT account_owner_module_known CHECK (owner_module IS NULL OR owner_module IN ('pettycash'));DROP INDEX account_one_purchase_price_variance;ALTER TABLE account DROP CONSTRAINT account_system_role_type, ADD CONSTRAINT account_system_role_equity CHECK (system_role IS NULL OR type = 'equity');ALTER TABLE account DROP CONSTRAINT account_system_role_known, ADD CONSTRAINT account_system_role_known CHECK (system_role IN ('retained_earnings', 'migration_clearing'));ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known, ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable'));- Step 4: Chart
internal/platform/accounting/chart/account.go — after RoleMigrationClearing:
// RolePurchasePriceVariance is the expense account a purchase invoice's// price difference from its goods receipt goes to (Phase 4 spec O2.3,// Ruling R-1: to P&L, not back into stock). One active per organization// (00721's account_one_purchase_price_variance).const RolePurchasePriceVariance = "purchase_price_variance"after MigrationClearing:
// PurchasePriceVariance answers the organization's active purchase price// variance account, else ErrNoPurchasePriceVariance.func PurchasePriceVariance(ctx context.Context, tx pgx.Tx) (Account, error) { return bySystemRole(ctx, tx, RolePurchasePriceVariance, ErrNoPurchasePriceVariance)}and in IsSubledgerKind add "grni" to the true case (update its comment: “…and grni, owned by inventory’s receipts and the purchase invoices that clear them”).
internal/platform/accounting/chart/errors.go — with the other sentinels:
// ErrNoPurchasePriceVariance: no active account carries the // purchase_price_variance role (provisioning step inventory_stock_accounts). ErrNoPurchasePriceVariance = errors.New("chart: no active account is designated purchase price variance")- Step 5: The provisioning step
internal/platform/inventory/step.go:
package inventory
import ( "context" "errors" "fmt"
"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/tenancy" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/provisioning")
const ( // OwnerModule owns the provisioned Inventory account: no manual journal // line may move it (00054's owner_module, 00721). OwnerModule = "inventory" // ControlKindGRNI is goods received not invoiced (spec O2.3). ControlKindGRNI = "grni"
// The stock accounts step, order 81 (R-O2-18): after the chart (70) and // every account step before it (75–79). A new organization gets the // accounts on its first provisioning, an existing one on its next // POST /v1/org/provision. StepKeyStockAccounts = "inventory_stock_accounts" StepOrderStockAccounts = 81)
func init() { provisioning.Register(stockAccountsStep{}) }
func strp(s string) *string { return &s }
// stockAccount is one account the step gives an organization that lacks// it. A code already taken by something else counts as present (as// payables' steps do): chart_of_accounts' control_kind_unmapped warning is// what an administrator then sees.type stockAccount struct { code, parent, typ string names map[string]string controlKind, systemRole, ownerModule *string}
var stockAccounts = []stockAccount{ {code: "1580", parent: "1500", typ: chart.TypeAsset, ownerModule: strp(OwnerModule), names: map[string]string{"en": "Inventory", "ms": "Inventori", "zh": "存货"}}, {code: "2170", parent: "2100", typ: chart.TypeLiability, controlKind: strp(ControlKindGRNI), names: map[string]string{"en": "Goods received not invoiced", "ms": "Barang diterima belum diinvois", "zh": "已收货未开票"}}, {code: "5200", parent: "5000", typ: chart.TypeExpense, names: map[string]string{"en": "Cost of goods sold", "ms": "Kos barang dijual", "zh": "销货成本"}}, {code: "5300", parent: "5000", typ: chart.TypeExpense, systemRole: strp(chart.RolePurchasePriceVariance), names: map[string]string{"en": "Purchase price variance", "ms": "Varians harga belian", "zh": "采购价格差异"}}, {code: "5400", parent: "5000", typ: chart.TypeExpense, names: map[string]string{"en": "Stock adjustment", "ms": "Pelarasan stok", "zh": "存货调整"}},}
func (a stockAccount) present(ctx context.Context, tx pgx.Tx) (bool, error) { switch { case a.controlKind != nil: if _, err := chart.ResolveControl(ctx, tx, *a.controlKind); err == nil { return true, nil } case a.systemRole != nil: if _, err := chart.PurchasePriceVariance(ctx, tx); err == nil { return true, nil } } _, err := chart.GetAccountByCode(ctx, tx, a.code) if errors.Is(err, chart.ErrNotFound) { return false, nil } return err == nil, err}
type stockAccountsStep struct{}
func (stockAccountsStep) Key() string { return StepKeyStockAccounts }func (stockAccountsStep) Order() int { return StepOrderStockAccounts }
func (stockAccountsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) { for _, a := range stockAccounts { ok, err := a.present(ctx, tx) if err != nil { return false, nil, fmt.Errorf("inventory: stock accounts check: %w", err) } if !ok { return false, nil, nil } } return true, nil, nil}
func (stockAccountsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error { org, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("inventory: stock accounts: %w", tenancy.ErrNoOrg) } for _, s := range stockAccounts { ok, err := s.present(ctx, tx) if err != nil { return fmt.Errorf("inventory: stock accounts: %w", err) } if ok { continue } in := chart.AccountInput{Code: s.code, Name: s.names["en"], Type: s.typ, ControlKind: s.controlKind, SystemRole: s.systemRole, OwnerModule: s.ownerModule} if parent, err := chart.GetAccountByCode(ctx, tx, s.parent); err == nil && !parent.IsPostable && parent.Type == s.typ { in.ParentID = &parent.ID } a, err := chart.CreateAccount(ctx, tx, c, in) if err != nil { return fmt.Errorf("inventory: create account %s: %w", s.code, err) } for locale, name := range s.names { if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, 'account', $2, 'name', $3, $4) ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO NOTHING`, org, a.ID, locale, name); err != nil { return fmt.Errorf("inventory: account %s translation: %w", s.code, err) } } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "account", EntityID: a.ID, Action: "translate", After: s.names}); err != nil { return err } } return nil}(The step writes several audit rows: each chart.CreateAccount audits after the previous account’s translation rows, which are INSERTs of new rows only — the pattern of imports’ clearing step.)
- Step 6: Contract and web
api/openapi.yaml: Account.system_role enum → [retained_earnings, migration_clearing, purchase_price_variance] (description: “purchase_price_variance is the expense account a purchase invoice’s price difference from its receipt goes to.”); Account.owner_module enum → [pettycash, inventory]; WorkflowState.control_kind enum append grni.
web/src/features/accounting/controlKinds.ts, in MANUAL_REFUSED after wht_payable: true,:
grni: true,Run: make generate && git status --short
- Step 7: Run the tests to verify they pass
Run: go test ./internal/platform/accounting/... ./internal/platform/inventory/ ./internal/platform/countrypack/... ./internal/app/ -count=1 && pnpm -C web typecheck
Expected: PASS (TestProvisioningStepOrdersAreUnique included).
- Step 8: Sabotage
Remove "grni" from IsSubledgerKind; run go test ./internal/platform/accounting/chart/ -run TestEveryControlKindSaysWhether -count=1; read the red; restore. Change the 1580 seed’s ownerModule to nil; run go test ./internal/platform/inventory/ -run TestNoManualLine -count=1; read the red; restore.
- Step 9: Commit
git add constants.yaml db/migrations/00721_grni_and_stock_accounts.sql internal/ api/openapi.yaml web/git commit -m "Phase 4 O2: grni, purchase price variance, the owned Inventory account
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: The stock ledger — moves, balances, lots, the test world
Section titled “Task 3: The stock ledger — moves, balances, lots, the test world”Files:
- Create:
db/migrations/00722_stock_ledger.sql - Modify:
internal/kernel/money/rate.go,internal/kernel/money/rate_test.go - Create:
internal/platform/inventory/stock.go - Create:
internal/platform/inventory/stock_export_test.go - Create:
internal/platform/inventory/stocktest/stocktest.go - Create:
internal/platform/inventory/stock_ledger_test.go - Modify:
internal/kernel/db/retention_test.go
Interfaces:
-
Consumes: Task 2’s accounts (1580, 2170, 5100, 5200, 5300, 5400); O1’s
tradetest.Env,inventory.CreateItem, theC62unit,item_fixed_once_used(). -
Produces:
money.Rate.Sub/Neg/IsNegative; in package inventory:CostingFIFO,CostingWeightedAverage, the fiveKind*constants, everyErr*of this task,mapStockError(err error) error,stockOrg(ctx) (uuid.UUID, error),type moveInput,insertMove(ctx, tx, c, moveInput) (uuid.UUID, error);stocktest.New(t) *Env(embeds*tradetest.Env) withWH1, WH2, Unit, InventoryAccount, PurchaseAccount, COGSAccount, GRNI, PPV, AdjustAccount,Item,ItemOn,TransferRow,OnHand,RequireBalancesAddUp,Qty,Amt,RequireQty,RequireAmount,RequirePg. -
Step 1: Write the failing tests
internal/kernel/money/rate_test.go — append:
func TestRateArithmeticForQuantities(t *testing.T) { a, b := MustParseRate("10.5"), MustParseRate("4") require.Zero(t, a.Sub(b).Cmp(MustParseRate("6.5"))) require.Zero(t, b.Neg().Cmp(MustParseRate("-4"))) require.True(t, b.Neg().IsNegative()) require.False(t, b.IsNegative()) require.False(t, a.Sub(a).IsNegative(), "zero is not negative")}(if rate_test.go is in package money_test, prefix with money..)
internal/platform/inventory/stock_export_test.go:
package inventory
import ( "context"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money")
// InsertMoveForTest is insertMove for the schema tests: a costless move// sourced from a transfer row, with no document behind it.func InsertMoveForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, item, location, transfer uuid.UUID, qty money.Rate, serial, batch string) (uuid.UUID, error) { return insertMove(ctx, tx, c, moveInput{ItemID: item, LocationID: location, Quantity: qty, Cost: money.Zero(), SerialNo: serial, BatchNo: batch, TransferID: &transfer})}internal/platform/inventory/stocktest/stocktest.go:
// Package stocktest is the stock test world: O1's tradetest organization// (provisioned Malaysian books, Owner in the OWNER seat, Staff below it)// with two warehouses, the C62 unit and the stock accounts provisioning// gave it, and items and stock documents one call away. Items and// locations go through O1's inventory.CreateItem and tradetest.Location.package stocktest
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/inventory" "github.com/mcom/erp/internal/platform/trade/tradetest")
type Env struct { *tradetest.Env WH1, WH2 uuid.UUID Unit uuid.UUID InventoryAccount, PurchaseAccount, COGSAccount uuid.UUID GRNI, PPV, AdjustAccount uuid.UUID}
func New(t *testing.T) *Env { t.Helper() e := &Env{Env: tradetest.New(t)} e.WH1, e.WH2 = e.Location(t, "WH1").ID, e.Location(t, "WH2").ID e.Unit = e.UOM(t, "C62") e.InventoryAccount, e.PurchaseAccount, e.COGSAccount = e.Account(t, "1580"), e.Account(t, "5100"), e.Account(t, "5200") e.GRNI, e.PPV, e.AdjustAccount = e.Account(t, "2170"), e.Account(t, "5300"), e.Account(t, "5400") return e}
// Item is a C62 item on the provisioned accounts: a stock item on inventory// 1580 and cost of goods sold 5200; every item on purchases 5100 (a service// received on a GRN is expensed there, R-O2-17).func (e *Env) Item(t *testing.T, code, typ, tracking string) uuid.UUID { t.Helper() return e.ItemOn(t, code, typ, tracking, e.InventoryAccount)}
// ItemOn is Item with another inventory account.func (e *Env) ItemOn(t *testing.T, code, typ, tracking string, inventoryAccount uuid.UUID) uuid.UUID { t.Helper() purchase := e.PurchaseAccount in := inventory.ItemInput{Code: code, Type: typ, Tracking: tracking, Names: inventory.Names{"en": code + " item"}, BaseUOMID: e.Unit, PurchaseAccountID: &purchase, IsActive: true} if typ == inventory.TypeStock { // O1's item_accounts_by_type inv, cogs := inventoryAccount, e.COGSAccount in.InventoryAccountID, in.COGSAccountID = &inv, &cogs } var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { it, err := inventory.CreateItem(ctx, tx, e.Clock, in) id = it.ID return err }) return id}
// TransferRow is a bare stock_transfer row, a source for schema-test moves.func (e *Env) TransferRow(t *testing.T, item, from, to uuid.UUID) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), ` INSERT INTO stock_transfer (org_id, item_id, from_location_id, to_location_id, quantity, transferred_on, created_by, created_at) VALUES ($1, $2, $3, $4, 1, DATE '2026-09-21', $5, now()) RETURNING id`, e.Org, item, from, to, e.Owner).Scan(&id)) return id}
// OnHand is stock_balance.on_hand for (item, location, lot), zero when absent.func (e *Env) OnHand(t *testing.T, item, location uuid.UUID, lot string) money.Rate { t.Helper() q := money.MustParseRate("0") err := e.H.Admin.QueryRow(context.Background(), ` SELECT on_hand FROM stock_balance WHERE org_id = $1 AND item_id = $2 AND location_id = $3 AND lot = $4`, e.Org, item, location, lot).Scan(&q) if !errors.Is(err, pgx.ErrNoRows) { require.NoError(t, err) } return q}
// RequireBalancesAddUp is O2's drift check: every stock_balance row equals// the sum of its moves, and every (item, location, lot) that moved has one.func (e *Env) RequireBalancesAddUp(t *testing.T) { t.Helper() var rows, bad int require.NoError(t, e.H.Admin.QueryRow(context.Background(), ` WITH moved AS ( SELECT item_id, location_id, coalesce(serial_no, batch_no, '') AS lot, sum(quantity) AS q FROM stock_move WHERE org_id = $1 GROUP BY 1, 2, 3), held AS (SELECT item_id, location_id, lot, on_hand FROM stock_balance WHERE org_id = $1) SELECT count(*), count(*) FILTER (WHERE h.on_hand IS DISTINCT FROM m.q) FROM moved m FULL JOIN held h USING (item_id, location_id, lot)`, e.Org).Scan(&rows, &bad)) require.Positive(t, rows, "no stock moved; the comparison would be vacuous") require.Zero(t, bad, "stock_balance disagrees with the sum of stock_move")}
func Qty(s string) money.Rate { return money.MustParseRate(s) }func Amt(s string) money.Amount { return money.MustParse(s) }
func RequireQty(t *testing.T, want string, got money.Rate, msg ...any) { t.Helper() require.Zero(t, Qty(want).Cmp(got), append([]any{"want %s, got %s", want, got}, msg...)...)}
func RequireAmount(t *testing.T, want string, got money.Amount, msg ...any) { t.Helper() require.Zero(t, Amt(want).Cmp(got), append([]any{"want %s, got %s", want, got}, msg...)...)}
// RequirePg asserts err is a Postgres error with this SQLSTATE and, when// constraint is not "", this constraint.func RequirePg(t *testing.T, err error, code, constraint string) { t.Helper() var pg *pgconn.PgError require.ErrorAs(t, err, &pg) require.Equal(t, code, pg.Code, pg.Message) if constraint != "" { require.Equal(t, constraint, pg.ConstraintName) }}internal/platform/inventory/stock_ledger_test.go:
package inventory_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
// mover inserts one costless move per call, in its own transaction.func mover(e *stocktest.Env, transfer uuid.UUID) func(item, loc uuid.UUID, q, serial, batch string) error { return func(item, loc uuid.UUID, q, serial, batch string) error { return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.InsertMoveForTest(ctx, tx, e.Clock, item, loc, transfer, stocktest.Qty(q), serial, batch) return err }) }}
func TestTheBalanceIsTheSumOfItsMoves(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2)) require.NoError(t, move(bolt, e.WH1, "10", "", "")) require.NoError(t, move(bolt, e.WH1, "-4", "", "")) require.NoError(t, move(bolt, e.WH2, "3", "", "")) stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, "")) stocktest.RequireQty(t, "3", e.OnHand(t, bolt, e.WH2, "")) e.RequireBalancesAddUp(t)}
func TestStockCannotGoBelowZero(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2)) require.NoError(t, move(bolt, e.WH1, "5", "", "")) // PRESENCE: taking everything held is allowed. require.NoError(t, move(bolt, e.WH1, "-5", "", "")) require.ErrorIs(t, move(bolt, e.WH1, "-1", "", ""), inventory.ErrInsufficientStock) require.ErrorIs(t, move(bolt, e.WH2, "-1", "", ""), inventory.ErrInsufficientStock, "a location never stocked") stocktest.RequireQty(t, "0", e.OnHand(t, bolt, e.WH1, ""))}
func TestATrackedItemMovesWithItsLot(t *testing.T) { e := stocktest.New(t) phone := e.Item(t, "PHONE", "stock", "serial") rice := e.Item(t, "RICE", "stock", "batch") bolt := e.Item(t, "BOLT", "stock", "none") labour := e.Item(t, "LABOUR", "service", "none") move := mover(e, e.TransferRow(t, phone, e.WH1, e.WH2))
// PRESENCE: each item moves with exactly its lot. require.NoError(t, move(phone, e.WH1, "1", "SN-1", "")) require.NoError(t, move(rice, e.WH1, "25", "", "B-7")) require.NoError(t, move(bolt, e.WH1, "3", "", ""))
require.ErrorIs(t, move(phone, e.WH1, "1", "", ""), inventory.ErrSerialRequired) require.ErrorIs(t, move(phone, e.WH1, "2", "SN-2", ""), inventory.ErrSerialRequired, "one unit per serial") require.ErrorIs(t, move(rice, e.WH1, "1", "", ""), inventory.ErrBatchRequired) require.ErrorIs(t, move(bolt, e.WH1, "1", "", "B-1"), inventory.ErrUntrackedLot) require.ErrorIs(t, move(labour, e.WH1, "1", "", ""), inventory.ErrNotStockItem)
// One place holds a serial at a time; it may leave and arrive elsewhere. require.ErrorIs(t, move(phone, e.WH2, "1", "SN-1", ""), inventory.ErrSerialHeld) require.NoError(t, move(phone, e.WH1, "-1", "SN-1", "")) require.NoError(t, move(phone, e.WH2, "1", "SN-1", "")) stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH2, "SN-1")) stocktest.RequireQty(t, "25", e.OnHand(t, rice, e.WH1, "B-7")) e.RequireBalancesAddUp(t)}
func TestOnlyAMoveWritesTheBalanceAndNoMoveIsEdited(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") move := mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2)) require.NoError(t, move(bolt, e.WH1, "2", "", "")) // PRESENCE: the rows exist for the app role to try. stocktest.RequireQty(t, "2", e.OnHand(t, bolt, e.WH1, ""))
exec := func(sql string) error { return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, sql, e.Org) return err }) } stocktest.RequirePg(t, exec(`UPDATE stock_balance SET on_hand = 100 WHERE org_id = $1`), "23514", "stock_balance_by_move") stocktest.RequirePg(t, exec(`INSERT INTO stock_balance (org_id, item_id, location_id, lot, lot_kind, on_hand) SELECT org_id, item_id, $1::uuid, lot, lot_kind, 5 FROM stock_balance WHERE org_id = $1`), "23514", "") stocktest.RequirePg(t, exec(`UPDATE stock_move SET quantity = 100 WHERE org_id = $1`), "42501", "") stocktest.RequirePg(t, exec(`DELETE FROM stock_move WHERE org_id = $1`), "42501", "") stocktest.RequireQty(t, "2", e.OnHand(t, bolt, e.WH1, ""))}
func TestAnItemsInventoryAccountIsFixedOnceItHasMoved(t *testing.T) { e := stocktest.New(t) still := e.Item(t, "STILL", "stock", "none") moved := e.Item(t, "MOVED", "stock", "none") require.NoError(t, mover(e, e.TransferRow(t, moved, e.WH1, e.WH2))(moved, e.WH1, "1", "", "")) other := e.Account(t, "1570") repoint := func(item uuid.UUID) error { return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE item SET inventory_account_id = $3 WHERE org_id = $1 AND id = $2`, e.Org, item, other) return err }) } require.NoError(t, repoint(still), "PRESENCE: an item that never moved may change account") stocktest.RequirePg(t, repoint(moved), "23514", "item_fixed") // O1's rule looked only at trade lines; a transfer names none (R-O2-10). err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE item SET tracking = 'batch' WHERE org_id = $1 AND id = $2`, e.Org, moved) return err }) stocktest.RequirePg(t, err, "23514", "item_fixed")}(The second exec passes e.Org as $1 and as a bogus location id: the guard refuses before the foreign key is checked — stock_balance_guard is BEFORE INSERT.)
- Step 2: Run the tests to verify they fail
Run: go test ./internal/kernel/money/ ./internal/platform/inventory/ -count=1
Expected: build failure a.Sub undefined / undefined: inventory.InsertMoveForTest.
- Step 3:
money.Ratehelpers
internal/kernel/money/rate.go, after Add:
// Sub, Neg and IsNegative make a Rate usable as a stock quantity (Phase 4// O2): quantities are NUMERIC(19,6), carried as Rate like AR's line// quantity — dimensionless, and never money.func (r Rate) Sub(o Rate) Rate { return Rate{d: r.d.Sub(o.d)} }func (r Rate) Neg() Rate { return Rate{d: r.d.Neg()} }func (r Rate) IsNegative() bool { return r.d.IsNegative() }- Step 4: Migration
db/migrations/00722_stock_ledger.sql:
-- +goose Up-- Phase 4 O2.1 (docs/superpowers/specs/2026-09-30-operations-design.md): the-- stock ledger. stock_move is append-only; stock_balance is its running sum,-- written only by stock_move's own trigger, and refuses to go below zero-- (OD-2). Cost is per item across the organization (item_cost, 00724), so a-- transfer moves quantity and no value (R-O2-5). A move has no journal-- entry column: it is written before the entry that reads it (R-O2-2).
CREATE TABLE stock_transfer ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), item_id UUID NOT NULL, from_location_id UUID NOT NULL, to_location_id UUID NOT NULL, quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_transfer_quantity_positive CHECK (quantity > 0), transferred_on DATE NOT NULL, note TEXT NOT NULL DEFAULT '' CONSTRAINT stock_transfer_note_language CHECK (note !~ '[-ヿ㐀-䶿一-鿿가-]'), created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT stock_transfer_tenant_id UNIQUE (org_id, id), CONSTRAINT stock_transfer_two_locations CHECK (from_location_id <> to_location_id), CONSTRAINT stock_transfer_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id), CONSTRAINT stock_transfer_from_fk FOREIGN KEY (org_id, from_location_id) REFERENCES location (org_id, id), CONSTRAINT stock_transfer_to_fk FOREIGN KEY (org_id, to_location_id) REFERENCES location (org_id, id), CONSTRAINT stock_transfer_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id));
CREATE TABLE stock_move ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, item_id UUID NOT NULL, location_id UUID NOT NULL, quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_move_quantity_nonzero CHECK (quantity <> 0), unit_cost NUMERIC(19,4) NOT NULL CONSTRAINT stock_move_unit_cost_nonnegative CHECK (unit_cost >= 0), total_cost NUMERIC(19,4) NOT NULL, move_date DATE NOT NULL, serial_no TEXT CONSTRAINT stock_move_serial_shape CHECK (serial_no IS NULL OR btrim(serial_no) <> ''), batch_no TEXT CONSTRAINT stock_move_batch_shape CHECK (batch_no IS NULL OR btrim(batch_no) <> ''), trade_line_id UUID, transfer_id UUID, reverses_move_id UUID, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT stock_move_tenant_id UNIQUE (org_id, id), CONSTRAINT stock_move_cost_follows_quantity CHECK (total_cost = 0 OR sign(total_cost) = sign(quantity)), CONSTRAINT stock_move_one_source CHECK (num_nonnulls(trade_line_id, transfer_id) = 1), CONSTRAINT stock_move_transfer_moves_no_value CHECK (transfer_id IS NULL OR total_cost = 0), CONSTRAINT stock_move_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id), CONSTRAINT stock_move_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id), CONSTRAINT stock_move_trade_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id), CONSTRAINT stock_move_transfer_fk FOREIGN KEY (org_id, transfer_id) REFERENCES stock_transfer (org_id, id), CONSTRAINT stock_move_reverses_fk FOREIGN KEY (org_id, reverses_move_id) REFERENCES stock_move (org_id, id));CREATE UNIQUE INDEX stock_move_reversed_once ON stock_move (org_id, reverses_move_id) WHERE reverses_move_id IS NOT NULL;CREATE INDEX stock_move_line_idx ON stock_move (org_id, trade_line_id) WHERE trade_line_id IS NOT NULL;CREATE INDEX stock_move_item_idx ON stock_move (org_id, item_id, move_date, seq);
-- lot is '' for an untracked item, else the serial or batch number. A-- serial is held by one location at a time (R-O2-11).CREATE TABLE stock_balance ( org_id UUID NOT NULL REFERENCES organization (id), item_id UUID NOT NULL, location_id UUID NOT NULL, lot TEXT NOT NULL, lot_kind TEXT NOT NULL CONSTRAINT stock_balance_lot_kind_known CHECK (lot_kind IN ('none', 'batch', 'serial')), on_hand NUMERIC(19,6) NOT NULL, CONSTRAINT stock_balance_pkey PRIMARY KEY (org_id, item_id, location_id, lot), CONSTRAINT stock_balance_on_hand_nonnegative CHECK (on_hand >= 0), CONSTRAINT stock_balance_serial_is_one CHECK (lot_kind <> 'serial' OR on_hand <= 1), CONSTRAINT stock_balance_lot_matches_kind CHECK ((lot_kind = 'none') = (lot = '')), CONSTRAINT stock_balance_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id), CONSTRAINT stock_balance_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id));CREATE UNIQUE INDEX stock_balance_serial_held_once ON stock_balance (org_id, item_id, lot) WHERE lot_kind = 'serial' AND on_hand > 0;
-- A batch's expiry, the first one a receipt gave it.CREATE TABLE stock_batch ( org_id UUID NOT NULL REFERENCES organization (id), item_id UUID NOT NULL, batch_no TEXT NOT NULL, expires_on DATE, CONSTRAINT stock_batch_pkey PRIMARY KEY (org_id, item_id, batch_no), CONSTRAINT stock_batch_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id));
-- A move names the lot its item's tracking demands, and only a stock item-- moves (spec O2.4: refused by the database).-- +goose StatementBeginCREATE FUNCTION stock_move_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE item_type TEXT; item_tracking TEXT;BEGIN SELECT i.type, i.tracking INTO item_type, item_tracking FROM public.item i WHERE i.org_id = NEW.org_id AND i.id = NEW.item_id; IF item_type IS DISTINCT FROM 'stock' THEN RAISE EXCEPTION 'item %: only a stock item moves stock', NEW.item_id USING ERRCODE = '23514', CONSTRAINT = 'stock_move_stock_item'; END IF; IF item_tracking = 'serial' AND (NEW.serial_no IS NULL OR NEW.batch_no IS NOT NULL OR abs(NEW.quantity) <> 1) THEN RAISE EXCEPTION 'item %: a serial-tracked item moves one unit per serial number', NEW.item_id USING ERRCODE = '23514', CONSTRAINT = 'stock_move_serial_required'; ELSIF item_tracking = 'batch' AND (NEW.batch_no IS NULL OR NEW.serial_no IS NOT NULL) THEN RAISE EXCEPTION 'item %: a batch-tracked item moves with its batch number', NEW.item_id USING ERRCODE = '23514', CONSTRAINT = 'stock_move_batch_required'; ELSIF item_tracking = 'none' AND (NEW.serial_no IS NOT NULL OR NEW.batch_no IS NOT NULL) THEN RAISE EXCEPTION 'item %: an untracked item takes no serial or batch number', NEW.item_id USING ERRCODE = '23514', CONSTRAINT = 'stock_move_untracked'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER stock_move_guard BEFORE INSERT ON stock_move FOR EACH ROW EXECUTE FUNCTION stock_move_guard();
-- The balance moves with every move, in the same statement, under the row-- lock the upsert takes (spec O2.1).-- +goose StatementBeginCREATE FUNCTION stock_move_apply() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE kind TEXT;BEGIN SELECT CASE i.tracking WHEN 'serial' THEN 'serial' WHEN 'batch' THEN 'batch' ELSE 'none' END INTO kind FROM public.item i WHERE i.org_id = NEW.org_id AND i.id = NEW.item_id; INSERT INTO public.stock_balance (org_id, item_id, location_id, lot, lot_kind, on_hand) VALUES (NEW.org_id, NEW.item_id, NEW.location_id, coalesce(NEW.serial_no, NEW.batch_no, ''), kind, NEW.quantity) ON CONFLICT ON CONSTRAINT stock_balance_pkey DO UPDATE SET on_hand = public.stock_balance.on_hand + EXCLUDED.on_hand; RETURN NULL;END $$;-- +goose StatementEndCREATE TRIGGER stock_move_apply AFTER INSERT ON stock_move FOR EACH ROW EXECUTE FUNCTION stock_move_apply();
-- Anything else that writes stock_balance is refused: only the trigger-- above (depth 2) may — the pattern of 00150's petty_cash_float guard.-- +goose StatementBeginCREATE FUNCTION stock_balance_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF pg_trigger_depth() < 2 THEN RAISE EXCEPTION 'stock_balance moves only with a stock_move' USING ERRCODE = '23514', CONSTRAINT = 'stock_balance_by_move'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER stock_balance_guard BEFORE INSERT OR UPDATE ON stock_balance FOR EACH ROW EXECUTE FUNCTION stock_balance_guard();
-- R-O2-10: O1's item_fixed_once_used() (00711) fixes an item's type, base-- unit and tracking once a trade line names it. Replaced (O1 asks O2 to) so-- they are also fixed once a stock_move exists — a transfer names no trade-- line — and the inventory account once the item has moved: valuation-- compares each inventory account with its items' value. The constraint-- name stays item_fixed, which O1's errors already map.-- +goose StatementBeginCREATE OR REPLACE FUNCTION item_fixed_once_used() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE moved BOOLEAN;BEGIN moved := EXISTS (SELECT 1 FROM public.stock_move m WHERE m.org_id = NEW.org_id AND m.item_id = NEW.id); IF (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) AND (moved OR EXISTS (SELECT 1 FROM public.trade_line l WHERE l.org_id = NEW.org_id AND l.item_id = NEW.id)) THEN RAISE EXCEPTION 'item %: a document or a stock move names it, so its type, base unit and tracking are fixed', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'item_fixed'; END IF; IF NEW.inventory_account_id IS DISTINCT FROM OLD.inventory_account_id AND moved THEN RAISE EXCEPTION 'item %: its stock has moved, so its inventory account is fixed', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'item_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndDROP TRIGGER item_fixed_once_used ON item;CREATE TRIGGER item_fixed_once_used BEFORE UPDATE OF type, base_uom_id, tracking, inventory_account_id 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 OR NEW.inventory_account_id IS DISTINCT FROM OLD.inventory_account_id) EXECUTE FUNCTION item_fixed_once_used();
ALTER TABLE stock_transfer ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_transfer FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_transfer USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_move ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_move FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_move USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_balance ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_balance FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_balance USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_batch ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_batch FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_batch USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON stock_move, stock_transfer FROM erp_app;REVOKE DELETE ON stock_balance, stock_batch FROM erp_app;GRANT USAGE ON SEQUENCE stock_move_seq_seq TO erp_app;
-- +goose Down-- O1's 00711 function and trigger, as they were.-- +goose StatementBeginCREATE OR REPLACE 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 StatementEndDROP TRIGGER item_fixed_once_used ON item;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();DROP TABLE stock_balance;DROP TABLE stock_batch;DROP TABLE stock_move;DROP TABLE stock_transfer;DROP FUNCTION stock_balance_guard();DROP FUNCTION stock_move_apply();DROP FUNCTION stock_move_guard();(GENERATED ALWAYS AS IDENTITY owns an implicit sequence; if \d stock_move shows another name than stock_move_seq_seq, use that name — journal_entry_posted_seq_seq in 00052 is the precedent.)
- Step 5:
stock.go
internal/platform/inventory/stock.go:
package inventory
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/tenancy")
// Costing methods, company_profile.costing_method (OD-1).const ( CostingFIFO = "fifo" CostingWeightedAverage = "weighted_average")
// The trade document kinds whose posting moves stock (spec O2.3). Literals:// the trade package's constants are O1's, and inventory does not import trade.const ( KindGoodsReceipt = "goods_receipt" KindPurchaseReturn = "purchase_return" KindDeliveryOrder = "delivery_order" KindSalesReturn = "sales_return" KindStockAdjustment = "stock_adjustment")
const itemTypeStock = "stock"
var ( ErrInsufficientStock = errors.New("inventory: not enough stock") ErrSerialRequired = errors.New("inventory: a serial-tracked item moves one unit per serial number") ErrBatchRequired = errors.New("inventory: a batch-tracked item moves with its batch number") ErrUntrackedLot = errors.New("inventory: an untracked item takes no serial or batch number") ErrSerialHeld = errors.New("inventory: that serial number is already in stock") ErrNotStockItem = errors.New("inventory: only a stock item moves stock") ErrItemFixed = errors.New("inventory: an item's type, tracking and inventory account are fixed once it has moved") ErrLotsMismatch = errors.New("inventory: the serials or batches do not add up to the quantity") ErrAlreadyApplied = errors.New("inventory: this line has already moved stock") ErrLayerConsumed = errors.New("inventory: the goods this receipt brought in have already been issued") ErrValueExhausted = errors.New("inventory: this would leave the item with a negative value; post an adjustment instead") ErrNoSourceLine = errors.New("inventory: a return line names the received or delivered line it returns") ErrWrongKind = errors.New("inventory: the document is not a stock document") ErrLineNeedsItem = errors.New("inventory: a receipt or return line needs an item") ErrNoLocation = errors.New("inventory: the document names no location") ErrNoDirection = errors.New("inventory: an adjustment line says whether stock comes in or goes out") ErrSameLocation = errors.New("inventory: a transfer moves stock between two different locations") ErrQuantityNotPositive = errors.New("inventory: the quantity must be positive") ErrBatchExpiryMismatch = errors.New("inventory: that batch is already held with another expiry date") ErrDocumentNotFound = errors.New("inventory: no such document in this organization") ErrItemNotFound = errors.New("inventory: no such item in this organization") ErrHasChildren = errors.New("inventory: other documents draw from this one; cancel them first"))
func stockOrg(ctx context.Context) (uuid.UUID, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("inventory: %w", tenancy.ErrNoOrg) } return org, nil}
// mapStockError turns the database's refusals into this package's// sentinels, keeping the database's message beside them.func mapStockError(err error) error { var pg *pgconn.PgError if !errors.As(err, &pg) { return err } var sentinel error switch pg.ConstraintName { case "stock_balance_on_hand_nonnegative", "item_cost_quantity_nonnegative": sentinel = ErrInsufficientStock case "stock_balance_serial_is_one", "stock_balance_serial_held_once": sentinel = ErrSerialHeld case "stock_move_serial_required": sentinel = ErrSerialRequired case "stock_move_batch_required": sentinel = ErrBatchRequired case "stock_move_untracked": sentinel = ErrUntrackedLot case "stock_move_stock_item": sentinel = ErrNotStockItem case "item_fixed": sentinel = ErrItemFixed case "item_cost_value_nonnegative", "item_cost_empty_is_worthless": sentinel = ErrValueExhausted case "trade_line_lot_serial_once", "trade_line_lot_batch_once", "trade_line_lot_one_kind", "trade_line_lot_serial_is_one", "trade_line_lot_expiry_on_batch": sentinel = ErrLotsMismatch default: return err } return fmt.Errorf("%w (%s)", sentinel, pg.Message)}
// moveInput is one stock_move. Quantity and Cost are signed alike: in is// positive, out negative. SerialNo and BatchNo are "" when absent.type moveInput struct { ItemID, LocationID uuid.UUID Quantity money.Rate Cost money.Amount SerialNo, BatchNo string TradeLineID *uuid.UUID TransferID *uuid.UUID ReversesID *uuid.UUID}
// insertMove writes one move dated the business date (spec O2.1: no// back-dated moves). Its trigger checks the lot and moves the balance.func insertMove(ctx context.Context, tx pgx.Tx, c clock.Clock, in moveInput) (uuid.UUID, error) { org, err := stockOrg(ctx) if err != nil { return uuid.Nil, err } unit := money.Zero() if !in.Cost.IsZero() { cost, qty := in.Cost, in.Quantity if cost.IsNegative() { cost, qty = cost.Neg(), qty.Neg() } if unit, err = cost.MulDiv(money.OneRate(), qty); err != nil { return uuid.Nil, err } } var id uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO stock_move (org_id, item_id, location_id, quantity, unit_cost, total_cost, move_date, serial_no, batch_no, trade_line_id, transfer_id, reverses_move_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''), $10, $11, $12, $13) RETURNING id`, org, in.ItemID, in.LocationID, in.Quantity, unit, in.Cost, clock.Today(ctx, c), in.SerialNo, in.BatchNo, in.TradeLineID, in.TransferID, in.ReversesID, c.Now()).Scan(&id) if err != nil { return uuid.Nil, mapStockError(fmt.Errorf("inventory: move: %w", err)) } return id, nil}- Step 6: Retention
internal/kernel/db/retention_test.go: add "stock_move" and "stock_transfer" to appendOnly with the comment // Phase 4 O2.1: the stock ledger and its transfers; a correction is a reversing move.; add "stock_balance" and "stock_batch" to amendable with // Phase 4 O2.1: a move's running balance (written by its trigger) and a batch's expiry; never deleted.
Run: make generate && git status --short
- Step 7: Run the tests to verify they pass
Run: go test ./internal/kernel/money/ ./internal/kernel/db/ ./internal/platform/inventory/ -count=1
Expected: PASS (TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestOnlyOperationalTablesGrantDeleteToTheAppRole included).
- Step 8: Sabotage
Comment out the stock_balance_on_hand_nonnegative line in 00722 (and its trailing comma); run go test ./internal/platform/inventory/ -run TestStockCannotGoBelowZero -count=1; read the red; restore. Change pg_trigger_depth() < 2 to < 1; run -run TestOnlyAMoveWritesTheBalance; read the red; restore.
- Step 9: Commit
git add db/migrations/00722_stock_ledger.sql internal/git commit -m "Phase 4 O2: the stock ledger, its balance and lot guards
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: The costing method, locked by the first move
Section titled “Task 4: The costing method, locked by the first move”Files:
- Create:
db/migrations/00723_costing_method.sql - Modify:
internal/platform/company/company.go(Profile,get, newSetCostingMethod) - Create:
internal/platform/inventory/costing_method_test.go - Modify:
internal/platform/inventory/stocktest/stocktest.go(UseCosting)
Interfaces:
-
Consumes:
inventory.CostingFIFO/CostingWeightedAverage(Task 3),stocktest(Task 3). -
Produces:
company.Profile.CostingMethod string;company.SetCostingMethod(ctx context.Context, tx pgx.Tx, c clock.Clock, method string) (company.Profile, error);company.ErrCostingMethodUnknown,company.ErrCostingMethodLocked;stocktest.Env.UseCosting(t, method). -
Step 1: Write the failing test
internal/platform/inventory/costing_method_test.go:
package inventory_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestTheCostingMethodLocksOnTheFirstMove(t *testing.T) { e := stocktest.New(t) set := func(m string) error { return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := company.SetCostingMethod(ctx, tx, e.Clock, m) return err }) } method := func() string { var m string e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { p, err := company.Get(ctx, tx) m = p.CostingMethod return err }) return m } require.Equal(t, inventory.CostingWeightedAverage, method(), "the default (R-O2-9)") require.ErrorIs(t, set("lifo"), company.ErrCostingMethodUnknown)
// PRESENCE: before any stock moves, the method changes both ways. require.NoError(t, set(inventory.CostingFIFO)) require.Equal(t, inventory.CostingFIFO, method()) require.NoError(t, set(inventory.CostingWeightedAverage))
bolt := e.Item(t, "BOLT", "stock", "none") require.NoError(t, mover(e, e.TransferRow(t, bolt, e.WH1, e.WH2))(bolt, e.WH1, "1", "", ""))
require.ErrorIs(t, set(inventory.CostingFIFO), company.ErrCostingMethodLocked) // The database refuses it whatever path writes (OD-1). err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE company_profile SET costing_method = 'fifo' WHERE org_id = $1`, e.Org) return err }) stocktest.RequirePg(t, err, "23514", "company_profile_costing_method_locked") require.NoError(t, set(inventory.CostingWeightedAverage), "setting the method it already has is no change") require.Equal(t, inventory.CostingWeightedAverage, method())}- Step 2: Run the test to verify it fails
Run: go test ./internal/platform/inventory/ -run TestTheCostingMethod -count=1
Expected: build failure undefined: company.SetCostingMethod.
- Step 3: Migration
db/migrations/00723_costing_method.sql:
-- +goose Up-- Phase 4 OD-1: one costing method per organization, FIFO or weighted-- average, locked once the first stock movement posts (MFRS 102 ¶25; a-- change would be an MFRS 108 policy change). Weighted average by default-- (R-O2-9).ALTER TABLE company_profile ADD COLUMN costing_method TEXT NOT NULL DEFAULT 'weighted_average' CONSTRAINT company_profile_costing_method_known CHECK (costing_method IN ('fifo', 'weighted_average'));
-- +goose StatementBeginCREATE FUNCTION company_profile_costing_method_locked() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF EXISTS (SELECT 1 FROM public.stock_move m WHERE m.org_id = NEW.org_id) THEN RAISE EXCEPTION 'the costing method cannot change once stock has moved' USING ERRCODE = '23514', CONSTRAINT = 'company_profile_costing_method_locked'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER company_profile_costing_method_locked BEFORE UPDATE OF costing_method ON company_profile FOR EACH ROW WHEN (NEW.costing_method IS DISTINCT FROM OLD.costing_method) EXECUTE FUNCTION company_profile_costing_method_locked();
-- +goose DownDROP TRIGGER company_profile_costing_method_locked ON company_profile;DROP FUNCTION company_profile_costing_method_locked();ALTER TABLE company_profile DROP COLUMN costing_method;- Step 4:
company
internal/platform/company/company.go:
In Profile, after Timezone string:
// CostingMethod is fifo or weighted_average (Phase 4 OD-1). Read here; // written only by SetCostingMethod, never by Update. CostingMethod stringIn get, add costing_method to the SELECT after timezone and &p.CostingMethod to the Scan after &p.Timezone.
After the sentinels:
// ErrCostingMethodUnknown: the costing method is fifo or weighted_average.var ErrCostingMethodUnknown = errors.New("company: the costing method is fifo or weighted_average")
// ErrCostingMethodLocked: stock has moved, so the method is fixed (OD-1,// MFRS 102 ¶25); 00723's trigger is what refuses it.var ErrCostingMethodLocked = errors.New("company: the costing method cannot change once stock has moved")At the end of the file:
// SetCostingMethod changes the organization's costing method until the// first stock movement (OD-1). The profile row is taken FOR UPDATE first:// every stock movement reads the method under FOR SHARE (inventory's// beginStock), so the change and a first move are serialized, and 00723's// trigger then sees the move or the move sees the new method.func SetCostingMethod(ctx context.Context, tx pgx.Tx, c clock.Clock, method string) (Profile, error) { if method != "fifo" && method != "weighted_average" { return Profile{}, ErrCostingMethodUnknown } before, err := GetForUpdate(ctx, tx) if err != nil { return Profile{}, err } if before.CostingMethod == method { return before, nil } if _, err := tx.Exec(ctx, `UPDATE company_profile SET costing_method = $2, updated_at = $3 WHERE org_id = $1`, before.OrgID, method, c.Now()); err != nil { var pg *pgconn.PgError if errors.As(err, &pg) && pg.ConstraintName == "company_profile_costing_method_locked" { return Profile{}, ErrCostingMethodLocked } return Profile{}, fmt.Errorf("company: set costing method: %w", err) } after := before after.CostingMethod = method return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "company_profile", EntityID: before.OrgID, Action: "set_costing_method", Before: map[string]string{"costing_method": before.CostingMethod}, After: map[string]string{"costing_method": method}})}(import "github.com/jackc/pgx/v5/pgconn".)
internal/platform/inventory/stocktest/stocktest.go — append:
// UseCosting sets the organization's costing method; call it before any// stock moves.func (e *Env) UseCosting(t *testing.T, method string) { t.Helper() e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := company.SetCostingMethod(ctx, tx, e.Clock, method) return err })}(import "github.com/mcom/erp/internal/platform/company".)
Run: make generate && git status --short
- Step 5: Run the tests to verify they pass
Run: go test ./internal/platform/company/... ./internal/platform/inventory/ -count=1
Expected: PASS.
- Step 6: Sabotage
Delete the CREATE TRIGGER company_profile_costing_method_locked … statement; run -run TestTheCostingMethod; read the red (ErrCostingMethodLocked expected, nil got); restore.
- Step 7: Commit
git add db/migrations/00723_costing_method.sql internal/git commit -m "Phase 4 O2: the costing method, locked by the first stock move (OD-1)
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: The five stock flows, seeded
Section titled “Task 5: The five stock flows, seeded”Files:
- Create:
internal/platform/inventory/graphs.go - Create:
internal/platform/inventory/graphs_test.go - Modify (only if O1 registered them): O1’s
workflow.RegisterSeededGraphcalls for the five kinds — delete them and their builders.
Interfaces:
-
Consumes:
posting.Effect(Task 1),ControlKindGRNI(Task 2), theKind*constants (Task 3). -
Produces: seeded graphs, state keys the tests use —
goods_receipt:draft → received (accrual, grni) → cancelled;purchase_return:draft → returned (accrual, grni) → cancelled;delivery_order:draft → delivered (effect) → cancelled;sales_return:draft → received (effect) → cancelled;stock_adjustment:draft → submitted → approved (effect) → cancelled,submitted → rejected → draft. Eachdraft → cancelledis a reporter’s discard. The sole-owner exception covers all five voids and the adjustment’s approval (R-O2-13). -
Step 1: Write the failing tests
internal/platform/inventory/graphs_test.go:
package inventory_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
var stockPosting = map[string]struct { sem posting.Semantic control string}{ inventory.KindGoodsReceipt: {posting.Accrual, inventory.ControlKindGRNI}, inventory.KindPurchaseReturn: {posting.Accrual, inventory.ControlKindGRNI}, inventory.KindDeliveryOrder: {posting.Effect, ""}, inventory.KindSalesReturn: {posting.Effect, ""}, inventory.KindStockAdjustment: {posting.Effect, ""},}
func TestEveryStockFlowIsValidAndPostsOnce(t *testing.T) { found := 0 for _, g := range workflow.SeededGraphs(uuid.New()) { want, ok := stockPosting[g.Kind] if !ok { continue } found++ workflowtest.RequireSeededGraphValid(t, g.Kind) n := 0 for _, s := range g.States { if s.Semantic == posting.Accrual || s.Semantic == posting.Effect { n++ require.Equal(t, want.sem, s.Semantic, g.Kind) require.Equal(t, want.control, s.ControlKind, g.Kind) } } require.Equal(t, 1, n, "%s posts exactly once", g.Kind) } require.Equal(t, len(stockPosting), found, "every stock kind is seeded")}
func TestAProvisionedOrganizationHasTheStockFlows(t *testing.T) { e := payablestest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { for kind := range stockPosting { d, err := workflow.Latest(ctx, tx, kind) require.NoError(t, err, kind) require.Equal(t, 1, d.Version, kind) } return nil })}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/ -run 'StockFlow' -count=1
Expected: FAIL every stock kind is seeded: expected 5, actual 0.
- Step 3:
graphs.go
internal/platform/inventory/graphs.go:
package inventory
import ( "github.com/google/uuid"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
func init() { for _, k := range []struct { kind string build func(uuid.UUID) workflow.Graph }{ {KindGoodsReceipt, stockGraph(KindGoodsReceipt, "received", posting.Accrual, ControlKindGRNI, stockLabels("Received", "已收货", "Diterima"), stockLabels("Receive", "收货", "Terima"))}, {KindPurchaseReturn, stockGraph(KindPurchaseReturn, "returned", posting.Accrual, ControlKindGRNI, stockLabels("Returned", "已退货", "Dipulangkan"), stockLabels("Return", "退货", "Pulangkan"))}, {KindDeliveryOrder, stockGraph(KindDeliveryOrder, "delivered", posting.Effect, "", stockLabels("Delivered", "已发货", "Dihantar"), stockLabels("Deliver", "发货", "Hantar"))}, {KindSalesReturn, stockGraph(KindSalesReturn, "received", posting.Effect, "", stockLabels("Received back", "已退回", "Diterima semula"), stockLabels("Receive back", "收回", "Terima semula"))}, {KindStockAdjustment, adjustmentGraph}, } { workflow.RegisterSeededGraph(k.kind, k.build) // R-O2-13: a one-member organization voids its own stock documents, // and approves its own adjustments, with a reason. workflow.AllowSoleOwnerException(k.kind, k.kind == KindStockAdjustment) }}
func stockLabels(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
var ( labelDraft = stockLabels("Draft", "草稿", "Draf") labelCancelled = stockLabels("Cancelled", "已取消", "Dibatalkan") labelDiscard = stockLabels("Discard", "作废", "Buang") labelCancel = stockLabels("Cancel", "取消", "Batal"))
// stockGraph is a receipt's, delivery's or return's flow: the storekeeper// who prepared it posts it (its semantic moves the stock and posts), a// draft is discarded, and cancelling after posting — the Owner seat's —// reverses the entry and the moves. posted stays non-terminal so the// cancellation can leave it.func stockGraph(kind, posted string, sem posting.Semantic, control string, postedLabel, action workflow.Labels) func(uuid.UUID) workflow.Graph { return func(owner uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: kind, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ {Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: labelDraft}, {Key: posted, Semantic: sem, ControlKind: control, DisplayOrder: 2, Labels: postedLabel}, {Key: "cancelled", Semantic: posting.Cancelled, DisplayOrder: 3, IsTerminal: true, Labels: labelCancelled}, }, Edges: []workflow.EdgeSpec{ {From: "draft", To: posted, Gate: workflow.GateReporter, ActionKey: "post", DisplayOrder: 1, Labels: action}, {From: "draft", To: "cancelled", Gate: workflow.GateReporter, ActionKey: "discard", DisplayOrder: 2, RequiresReason: true, Labels: labelDiscard}, {From: posted, To: "cancelled", Gate: workflow.GateTarget, ActionKey: "cancel", DisplayOrder: 3, RequiresReason: true, ReversesAccrual: true, Labels: labelCancel}, }, Grants: []workflow.GrantSpec{{StateKey: "cancelled", GranteeKind: workflow.GranteePosition, PositionID: &owner}}, }.Normalized() }}
// adjustmentGraph (R-O2-13): an adjustment is submitted and approved by// the Owner seat — a write-off is never its preparer's own decision.func adjustmentGraph(owner uuid.UUID) workflow.Graph { ownerOn := func(state string) workflow.GrantSpec { return workflow.GrantSpec{StateKey: state, GranteeKind: workflow.GranteePosition, PositionID: &owner} } return workflow.Graph{ Kind: KindStockAdjustment, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ {Key: "draft", Semantic: posting.Draft, DisplayOrder: 1, Labels: labelDraft}, {Key: "submitted", Semantic: posting.Pending, DisplayOrder: 2, Labels: stockLabels("Submitted", "已提交", "Dihantar")}, {Key: "approved", Semantic: posting.Effect, DisplayOrder: 3, Labels: stockLabels("Approved", "已批准", "Diluluskan")}, {Key: "rejected", Semantic: posting.Rejected, DisplayOrder: 4, Labels: stockLabels("Rejected", "已拒绝", "Ditolak")}, {Key: "cancelled", Semantic: posting.Cancelled, DisplayOrder: 5, IsTerminal: true, Labels: labelCancelled}, }, Edges: []workflow.EdgeSpec{ {From: "draft", To: "submitted", Gate: workflow.GateReporter, ActionKey: "submit", DisplayOrder: 1, Labels: stockLabels("Submit", "提交", "Hantar")}, {From: "submitted", To: "approved", Gate: workflow.GateTarget, ActionKey: "approve", DisplayOrder: 2, Labels: stockLabels("Approve", "批准", "Luluskan")}, {From: "submitted", To: "rejected", Gate: workflow.GateAdvance, ActionKey: "reject", DisplayOrder: 3, RequiresReason: true, Labels: stockLabels("Reject", "拒绝", "Tolak")}, {From: "rejected", To: "draft", Gate: workflow.GateReporter, ActionKey: "revise", DisplayOrder: 4, Labels: stockLabels("Revise", "修改", "Semak semula")}, {From: "draft", To: "cancelled", Gate: workflow.GateReporter, ActionKey: "discard", DisplayOrder: 5, RequiresReason: true, Labels: labelDiscard}, {From: "approved", To: "cancelled", Gate: workflow.GateTarget, ActionKey: "cancel", DisplayOrder: 6, RequiresReason: true, ReversesAccrual: true, Labels: labelCancel}, }, Grants: []workflow.GrantSpec{ownerOn("approved"), ownerOn("cancelled")}, }.Normalized()}- Step 4: Run the tests to verify they pass
Run: go test ./internal/platform/inventory/ ./internal/app/ -run 'StockFlow|SeededGraph' -count=1
Expected: PASS (TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales included).
- Step 5: Sabotage
Set the posted → cancelled edge’s ReversesAccrual to false; run -run TestEveryStockFlowIsValid; read the red (reversal_missing); restore.
- Step 6: Commit
git add internal/platform/inventory/graphs.go internal/platform/inventory/graphs_test.gogit commit -m "Phase 4 O2: seeded flows for receipts, deliveries, returns and adjustments
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: Costing and applying stock documents
Section titled “Task 6: Costing and applying stock documents”Files:
- Create:
db/migrations/00724_cost_layers.sql,db/migrations/00725_stock_documents.sql - Create:
internal/platform/inventory/costing.go,internal/platform/inventory/stockline.go,internal/platform/inventory/apply.go - Modify:
internal/platform/inventory/stock_export_test.go(ApplyForTest,ReverseForTest) - Modify:
internal/platform/inventory/stocktest/stocktest.go(documents, costs, rates) - Create:
internal/platform/inventory/stock_documents_test.go - Modify:
internal/kernel/db/retention_test.go
Interfaces:
-
Consumes:
insertMove,moveInput,mapStockError,stockOrg, the errors and kinds (Task 3);company.Profile.CostingMethod(Task 4); the seeded flows (Task 5, sotrade.Createcan start an instance). -
Produces (package inventory):
type Lot,type Received,type TransitionInput,SetLineLots,ReceivedValue,OnTransition,Transition(signatures in “Interfaces O3 consumes”);CurrentUnitCost(ctx, tx, itemID uuid.UUID) (money.Amount, error);CostingLocked(ctx, tx) (bool, error); unexportedtype stockLine(fields below),documentLines(ctx, tx, org, docID) ([]stockLine, error),receivedValue(ctx, tx, org, lineID) (Received, error),liveLineCost(ctx, tx, org, lineID) (money.Amount, error)(signed sum of a line’s unreversed moves),checkLots(tracking string, qty money.Rate, lots []Lot) ([]Lot, error),beginStock(ctx, tx, org, items []uuid.UUID) (string, error); tablesstock_adjustment_reason(id, org_id, code, account_id, is_active, created_at),stock_adjustment_detail(org_id, document_id, reason_id). -
Produces (stocktest):
SupplierParty,CustomerParty,MoveErr,Post,Cancel(throughinventory.Transition; a no-op Poster until Task 7),type DocLine,Line(item, qty, price),type DocRef{ID, InstanceID uuid.UUID; Kind string; Lines []uuid.UUID},Doc,DocAt,Apply,ApplyErr,Reverse,ReverseErr,ItemCost,LineCost,Received,SeedRate,RequireCostsAddUp. -
Step 1: Write the failing tests
internal/platform/inventory/stock_export_test.go — append:
func ApplyForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, doc uuid.UUID) error { return apply(ctx, tx, c, doc)}
func ReverseForTest(ctx context.Context, tx pgx.Tx, c clock.Clock, doc uuid.UUID) error { return reverse(ctx, tx, c, doc)}internal/platform/inventory/stocktest/stocktest.go — in New, before return e, add e.nopPoster(t); then append (imports: strings, time, github.com/mcom/erp/internal/platform/accounting/fx, github.com/mcom/erp/internal/platform/posting, github.com/mcom/erp/internal/platform/trade):
// SupplierParty is a new MYR supplier's party id (tradetest.Supplier).func (e *Env) SupplierParty(t *testing.T) uuid.UUID { t.Helper() return e.Supplier(t, "SUP-"+strings.ToUpper(uuid.NewString()[:6]))}
// CustomerParty is a new MYR customer's party id (tradetest.Customer).func (e *Env) CustomerParty(t *testing.T) uuid.UUID { t.Helper() return e.Customer(t, "CUS-"+strings.ToUpper(uuid.NewString()[:6]))}
// DocLine is one line of a test document, quantity in the item's base UOM.type DocLine struct { Item uuid.UUID Qty, Price string Source *uuid.UUID Lots []inventory.Lot}
func Line(item uuid.UUID, qty, price string) DocLine { return DocLine{Item: item, Qty: qty, Price: price} }
type DocRef struct { ID, InstanceID uuid.UUID Kind string Lines []uuid.UUID}
// Doc is a draft stock document at WH1, created by Staff through O1's// trade.Create. A foreign document carries a nominal header rate (O1-3):// O2 values a receipt at the receipt date's rate, never the header's.func (e *Env) Doc(t *testing.T, kind string, party *uuid.UUID, currency string, lines ...DocLine) DocRef { t.Helper() return e.DocAt(t, kind, e.WH1, party, currency, lines...)}
func (e *Env) DocAt(t *testing.T, kind string, location uuid.UUID, party *uuid.UUID, currency string, lines ...DocLine) DocRef { t.Helper() ref := DocRef{Kind: kind} require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { in := trade.DocumentInput{Kind: kind, PartyID: party, Currency: currency, LocationID: &location} if currency != "MYR" { rate := money.MustParseRate("4.2") in.FXRate = &rate } for _, l := range lines { item := l.Item in.Lines = append(in.Lines, trade.LineInput{ItemID: &item, Quantity: Qty(l.Qty), UOMID: e.Unit, UnitPrice: Amt(l.Price), SourceLineID: l.Source}) } d, err := trade.Create(ctx, tx, e.Clock, e.Staff, in) if err != nil { return err } ref.ID, ref.InstanceID = d.ID, d.InstanceID for i, l := range d.Lines { ref.Lines = append(ref.Lines, l.ID) if len(lines[i].Lots) > 0 { if err := inventory.SetLineLots(ctx, tx, l.ID, lines[i].Lots); err != nil { return err } } } return nil })) return ref}
// nopPoster lets Task 6's documents move through their workflow before the// posting rules exist (Task 7 replaces it with the real rules): O1 lets a// return name only an approved source (Ruling O1-6).type nopPoster struct{}
func (nopPoster) Post(context.Context, pgx.Tx, posting.Event) error { return nil }
func (e *Env) nopPoster(t *testing.T) { t.Helper() posting.ResetForTest() posting.Register(nopPoster{}) t.Cleanup(posting.ResetForTest)}
// postedState is each flow's posting state (Task 5's seeded graphs).var postedState = map[string]string{inventory.KindGoodsReceipt: "received", inventory.KindPurchaseReturn: "returned", inventory.KindDeliveryOrder: "delivered", inventory.KindSalesReturn: "received"}
func (e *Env) MoveErr(d DocRef, by uuid.UUID, from, to, reason string) error { return e.DB.InTx(e.As(by), func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.Transition(ctx, tx, e.Clock, inventory.TransitionInput{DocumentID: d.ID, From: from, To: to, Actor: by, Reason: reason}) return err })}
// Post moves a receipt, delivery or return from draft into its posting// state, as its preparer (Staff) does.func (e *Env) Post(t *testing.T, d DocRef) { t.Helper() require.NoError(t, e.MoveErr(d, e.Staff, "draft", postedState[d.Kind], ""))}
// Cancel cancels a posted receipt, delivery or return, as the Owner seat.func (e *Env) Cancel(t *testing.T, d DocRef) { t.Helper() require.NoError(t, e.MoveErr(d, e.Owner, postedState[d.Kind], "cancelled", "Entered in error"))}
func (e *Env) ApplyErr(d DocRef) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { return inventory.ApplyForTest(ctx, tx, e.Clock, d.ID) })}
func (e *Env) Apply(t *testing.T, d DocRef) { t.Helper(); require.NoError(t, e.ApplyErr(d)) }
func (e *Env) ReverseErr(d DocRef) error { return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { return inventory.ReverseForTest(ctx, tx, e.Clock, d.ID) })}
func (e *Env) Reverse(t *testing.T, d DocRef) { t.Helper(); require.NoError(t, e.ReverseErr(d)) }
// ItemCost is the item's item_cost row: quantity and value, zero when absent.func (e *Env) ItemCost(t *testing.T, item uuid.UUID) (money.Rate, money.Amount) { t.Helper() q, v := Qty("0"), Amt("0") err := e.H.Admin.QueryRow(context.Background(), `SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`, e.Org, item).Scan(&q, &v) if !errors.Is(err, pgx.ErrNoRows) { require.NoError(t, err) } return q, v}
// LineCost is the signed cost of a line's unreversed moves: in positive,// out negative, zero once reversed.func (e *Env) LineCost(t *testing.T, line uuid.UUID) money.Amount { t.Helper() v := Amt("0") require.NoError(t, e.H.Admin.QueryRow(context.Background(), ` SELECT coalesce(sum(m.total_cost), 0) FROM stock_move m WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`, e.Org, line).Scan(&v)) return v}
func (e *Env) Received(t *testing.T, line uuid.UUID) inventory.Received { t.Helper() var r inventory.Received e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error r, err = inventory.ReceivedValue(ctx, tx, line) return err }) return r}
// SeedRate stores one Bank Negara reference rate (MYR per unit).func (e *Env) SeedRate(t *testing.T, currency, day, rate string) { t.Helper() d, err := time.Parse("2006-01-02", day) require.NoError(t, err) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := fx.StoreReference(ctx, tx, e.Clock, []fx.ReferenceRate{{Source: "bnm", Currency: currency, RateDate: d, Session: "1130", Rate: money.MustParseRate(rate)}}) return err })}
// RequireCostsAddUp is the costing drift check: every item_cost's value// is the sum of its moves' cost and its quantity the sum of their// quantity; under FIFO, the open layers hold exactly that.func (e *Env) RequireCostsAddUp(t *testing.T) { t.Helper() var items, bad, badLayers int require.NoError(t, e.H.Admin.QueryRow(context.Background(), ` WITH moved AS (SELECT item_id, sum(quantity) AS q, sum(total_cost) AS v FROM stock_move WHERE org_id = $1 GROUP BY 1), layered AS (SELECT item_id, sum(remaining_quantity) AS q, sum(remaining_value) AS v FROM cost_layer WHERE org_id = $1 GROUP BY 1) SELECT count(*), count(*) FILTER (WHERE c.quantity <> m.q OR c.value <> m.v), count(*) FILTER (WHERE p.costing_method = 'fifo' AND (coalesce(l.q, 0) <> c.quantity OR coalesce(l.v, 0) <> c.value)) FROM item_cost c JOIN moved m ON m.item_id = c.item_id JOIN company_profile p ON p.org_id = c.org_id LEFT JOIN layered l ON l.item_id = c.item_id WHERE c.org_id = $1`, e.Org).Scan(&items, &bad, &badLayers)) require.Positive(t, items, "no item was costed; the comparison would be vacuous") require.Zero(t, bad, "item_cost disagrees with the sum of stock_move") require.Zero(t, badLayers, "the FIFO layers disagree with item_cost")}internal/platform/inventory/stock_documents_test.go:
package inventory_test
import ( "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestWeightedAverageIssuesAtTheAverageAndTheLastIssueTakesTheResidue(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "1", "1.00"))) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "2.00"))) q, v := e.ItemCost(t, bolt) stocktest.RequireQty(t, "3", q) stocktest.RequireAmount(t, "5", v)
issue := func() stocktest.DocRef { d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "1", "9.00")) e.Post(t, d) return d } stocktest.RequireAmount(t, "-1.6667", e.LineCost(t, issue().Lines[0]), "5 / 3, rounded") stocktest.RequireAmount(t, "-1.6667", e.LineCost(t, issue().Lines[0]), "3.3333 / 2, rounded") stocktest.RequireAmount(t, "-1.6666", e.LineCost(t, issue().Lines[0]), "the issue that empties the item takes the residue") q, v = e.ItemCost(t, bolt) stocktest.RequireQty(t, "0", q) stocktest.RequireAmount(t, "0", v, "valuation never drifts") e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t)}
func TestFIFOIssuesTheOldestLayersFirst(t *testing.T) { e := stocktest.New(t) e.UseCosting(t, inventory.CostingFIFO) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "2.00"))) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "15", "5.00")) e.Post(t, d) stocktest.RequireAmount(t, "-20", e.LineCost(t, d.Lines[0]), "10 × 1.00 + 5 × 2.00") q, v := e.ItemCost(t, bolt) stocktest.RequireQty(t, "5", q) stocktest.RequireAmount(t, "10", v) e.RequireCostsAddUp(t)}
func TestAPurchaseReturnLeavesFromItsOwnReceiptsLayer(t *testing.T) { e := stocktest.New(t) e.UseCosting(t, inventory.CostingFIFO) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")) e.Post(t, a) b := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "2.00")) e.Post(t, b)
ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR", stocktest.DocLine{Item: bolt, Qty: "4", Price: "2.00", Source: &b.Lines[0]}) e.Post(t, ret) stocktest.RequireAmount(t, "-8", e.LineCost(t, ret.Lines[0]), "the returned receipt's own layer") d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "10", "5.00")) e.Post(t, d) stocktest.RequireAmount(t, "-10", e.LineCost(t, d.Lines[0]), "the older receipt is still issued first")
// a's goods are all issued: returning one of them is refused. gone := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR", stocktest.DocLine{Item: bolt, Qty: "1", Price: "1.00", Source: &a.Lines[0]}) require.ErrorIs(t, e.ApplyErr(gone), inventory.ErrLayerConsumed) e.RequireCostsAddUp(t)}
func TestASalesReturnComesBackAtTheCostItLeftAt(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "3.00"))) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00")) e.Post(t, d) stocktest.RequireAmount(t, "-8", e.LineCost(t, d.Lines[0])) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "5.00")))
r := e.Doc(t, inventory.KindSalesReturn, &cust, "MYR", stocktest.DocLine{Item: bolt, Qty: "2", Price: "9.00", Source: &d.Lines[0]}) e.Post(t, r) stocktest.RequireAmount(t, "4", e.LineCost(t, r.Lines[0]), "2.00 a unit, as delivered — not today's average") e.RequireCostsAddUp(t)}
func TestReversingADocumentPutsItsStockAndValueBack(t *testing.T) { e := stocktest.New(t) e.UseCosting(t, inventory.CostingFIFO) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t)
// PRESENCE: a receipt nothing was issued from reverses. spare := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "3", "1.00")) e.Post(t, spare) e.Reverse(t, spare) stocktest.RequireAmount(t, "0", e.LineCost(t, spare.Lines[0]))
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")) e.Post(t, r) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00")) e.Post(t, d) require.ErrorIs(t, e.ReverseErr(r), inventory.ErrLayerConsumed, "its goods were issued")
e.Reverse(t, d) stocktest.RequireQty(t, "10", e.OnHand(t, bolt, e.WH1, "")) q, v := e.ItemCost(t, bolt) stocktest.RequireQty(t, "10", q) stocktest.RequireAmount(t, "10", v) stocktest.RequireAmount(t, "0", e.LineCost(t, d.Lines[0])) e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t)}
func TestASerialReceiptMovesOneUnitPerSerial(t *testing.T) { e := stocktest.New(t) phone := e.Item(t, "PHONE", "stock", "serial") sup := e.SupplierParty(t) missing := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(phone, "2", "100.00")) require.ErrorIs(t, e.ApplyErr(missing), inventory.ErrLotsMismatch)
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.DocLine{Item: phone, Qty: "2", Price: "100.00", Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}, {SerialNo: "SN-2", Quantity: stocktest.Qty("1")}}}) e.Post(t, r) stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-1")) stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-2")) stocktest.RequireAmount(t, "200", e.LineCost(t, r.Lines[0]))
err := e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { return inventory.SetLineLots(ctx, tx, r.Lines[0], nil) }) require.ErrorIs(t, err, inventory.ErrAlreadyApplied, "a moved line's lots are its moves") e.RequireBalancesAddUp(t)}
func TestAReceiptIsValuedInBaseAtItsDatesRate(t *testing.T) { e := stocktest.New(t) e.SeedRate(t, "USD", "2026-09-18", "4.2000") bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "USD", stocktest.Line(bolt, "10", "2.00")) e.Post(t, r) got := e.Received(t, r.Lines[0]) stocktest.RequireQty(t, "10", got.Quantity) stocktest.RequireAmount(t, "20", got.Amount) require.Equal(t, "USD", got.Currency) stocktest.RequireAmount(t, "84", got.Base) stocktest.RequireAmount(t, "84", e.LineCost(t, r.Lines[0]), "stock is valued in base")
require.ErrorIs(t, e.ApplyErr(r), inventory.ErrAlreadyApplied) e.Reverse(t, r) stocktest.RequireQty(t, "0", e.Received(t, r.Lines[0]).Quantity, "a reversed receipt received nothing")}(add "context" and "github.com/jackc/pgx/v5" to that file’s imports.)
- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/ -count=1
Expected: build failure undefined: apply / undefined: inventory.SetLineLots.
- Step 3: Migrations
db/migrations/00724_cost_layers.sql:
-- +goose Up-- Phase 4 O2.2: cost is per item across the organization. item_cost is the-- item's quantity and value under either method (R-O2-4): the row every-- movement of the item locks first, and the valuation the GL is checked-- against (O2.5). The average is value / quantity, never stored. Under-- FIFO each inbound move adds a cost_layer; an issue consumes the oldest.CREATE TABLE item_cost ( org_id UUID NOT NULL REFERENCES organization (id), item_id UUID NOT NULL, quantity NUMERIC(19,6) NOT NULL DEFAULT 0 CONSTRAINT item_cost_quantity_nonnegative CHECK (quantity >= 0), value NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT item_cost_value_nonnegative CHECK (value >= 0), CONSTRAINT item_cost_pkey PRIMARY KEY (org_id, item_id), CONSTRAINT item_cost_empty_is_worthless CHECK (quantity > 0 OR value = 0), CONSTRAINT item_cost_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id));
CREATE TABLE cost_layer ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, item_id UUID NOT NULL, move_id UUID NOT NULL, -- The goods-receipt line whose goods this is; a purchase return consumes -- only its receipt's layers (spec O3.1). receipt_line_id UUID, unit_cost NUMERIC(19,4) NOT NULL CONSTRAINT cost_layer_unit_cost_nonnegative CHECK (unit_cost >= 0), quantity NUMERIC(19,6) NOT NULL CONSTRAINT cost_layer_quantity_positive CHECK (quantity > 0), remaining_quantity NUMERIC(19,6) NOT NULL, remaining_value NUMERIC(19,4) NOT NULL CONSTRAINT cost_layer_remaining_value_nonnegative CHECK (remaining_value >= 0), CONSTRAINT cost_layer_tenant_id UNIQUE (org_id, id), CONSTRAINT cost_layer_move_once UNIQUE (org_id, move_id), CONSTRAINT cost_layer_remaining_within CHECK (remaining_quantity >= 0 AND remaining_quantity <= quantity), CONSTRAINT cost_layer_empty_is_worthless CHECK (remaining_quantity > 0 OR remaining_value = 0), CONSTRAINT cost_layer_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id), CONSTRAINT cost_layer_move_fk FOREIGN KEY (org_id, move_id) REFERENCES stock_move (org_id, id), CONSTRAINT cost_layer_receipt_line_fk FOREIGN KEY (org_id, receipt_line_id) REFERENCES trade_line (org_id, id));CREATE INDEX cost_layer_open_idx ON cost_layer (org_id, item_id, seq) WHERE remaining_quantity > 0;
ALTER TABLE item_cost ENABLE ROW LEVEL SECURITY;ALTER TABLE item_cost FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON item_cost USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE cost_layer ENABLE ROW LEVEL SECURITY;ALTER TABLE cost_layer FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON cost_layer USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON item_cost, cost_layer FROM erp_app;GRANT USAGE ON SEQUENCE cost_layer_seq_seq TO erp_app;
-- +goose DownDROP TABLE cost_layer;DROP TABLE item_cost;db/migrations/00725_stock_documents.sql:
-- +goose Up-- Phase 4 O2: what stock documents carry beyond O1's trade_line.-- trade_line_lot: the serials and batches a draft line will move (spec-- O2.4), replaced while the document is a draft; the moves are the record.CREATE TABLE trade_line_lot ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), trade_line_id UUID NOT NULL, serial_no TEXT CONSTRAINT trade_line_lot_serial_shape CHECK (serial_no IS NULL OR btrim(serial_no) <> ''), batch_no TEXT CONSTRAINT trade_line_lot_batch_shape CHECK (batch_no IS NULL OR btrim(batch_no) <> ''), expires_on DATE, quantity NUMERIC(19,6) NOT NULL CONSTRAINT trade_line_lot_quantity_positive CHECK (quantity > 0), CONSTRAINT trade_line_lot_one_kind CHECK (num_nonnulls(serial_no, batch_no) = 1), CONSTRAINT trade_line_lot_serial_is_one CHECK (serial_no IS NULL OR quantity = 1), CONSTRAINT trade_line_lot_expiry_on_batch CHECK (expires_on IS NULL OR batch_no IS NOT NULL), CONSTRAINT trade_line_lot_serial_once UNIQUE (org_id, trade_line_id, serial_no), CONSTRAINT trade_line_lot_batch_once UNIQUE (org_id, trade_line_id, batch_no), CONSTRAINT trade_line_lot_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id));
-- receipt_valuation (R-O2-3): what a goods-receipt line brought in, in its-- currency and in base at the receipt date's rate. Append-only; a-- reversal is a negating row. O3's purchase invoice clears GRNI from it.CREATE TABLE receipt_valuation ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), trade_line_id UUID NOT NULL, quantity NUMERIC(19,6) NOT NULL, amount NUMERIC(19,4) NOT NULL, currency TEXT NOT NULL CONSTRAINT receipt_valuation_currency_code CHECK (currency ~ '^[A-Z]{3}$'), base_amount NUMERIC(19,4) NOT NULL, valued_on DATE NOT NULL, reverses_id UUID, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT receipt_valuation_tenant_id UNIQUE (org_id, id), CONSTRAINT receipt_valuation_sign CHECK (CASE WHEN reverses_id IS NULL THEN quantity > 0 AND amount >= 0 AND base_amount >= 0 ELSE quantity < 0 AND amount <= 0 AND base_amount <= 0 END), CONSTRAINT receipt_valuation_line_fk FOREIGN KEY (org_id, trade_line_id) REFERENCES trade_line (org_id, id), CONSTRAINT receipt_valuation_reverses_fk FOREIGN KEY (org_id, reverses_id) REFERENCES receipt_valuation (org_id, id));CREATE UNIQUE INDEX receipt_valuation_reversed_once ON receipt_valuation (org_id, reverses_id) WHERE reverses_id IS NOT NULL;CREATE INDEX receipt_valuation_line_idx ON receipt_valuation (org_id, trade_line_id);
-- An adjustment's reason and the account it posts against (spec O2.3,-- R-O2-12). Names are translation rows (entity kind stock_adjustment_reason).CREATE TABLE stock_adjustment_reason ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT stock_adjustment_reason_code_shape CHECK (code ~ '^[a-z][a-z0-9_]{0,31}$'), account_id UUID NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT stock_adjustment_reason_tenant_id UNIQUE (org_id, id), CONSTRAINT stock_adjustment_reason_code_once UNIQUE (org_id, code), CONSTRAINT stock_adjustment_reason_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id));
-- O2's own row beside O1's document: never an ALTER of O1's tables.-- Written once with the document. A line's direction is its sign (O1).CREATE TABLE stock_adjustment_detail ( org_id UUID NOT NULL REFERENCES organization (id), document_id UUID NOT NULL, reason_id UUID NOT NULL, CONSTRAINT stock_adjustment_detail_pkey PRIMARY KEY (org_id, document_id), CONSTRAINT stock_adjustment_detail_document_fk FOREIGN KEY (org_id, document_id) REFERENCES trade_document (org_id, id), CONSTRAINT stock_adjustment_detail_reason_fk FOREIGN KEY (org_id, reason_id) REFERENCES stock_adjustment_reason (org_id, id));
ALTER TABLE trade_line_lot ENABLE ROW LEVEL SECURITY;ALTER TABLE trade_line_lot FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON trade_line_lot USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE receipt_valuation ENABLE ROW LEVEL SECURITY;ALTER TABLE receipt_valuation FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON receipt_valuation USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_adjustment_reason ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_adjustment_reason FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_adjustment_reason USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_adjustment_detail ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_adjustment_detail FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_adjustment_detail USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON receipt_valuation, stock_adjustment_detail FROM erp_app;REVOKE DELETE ON stock_adjustment_reason FROM erp_app;
-- +goose DownDROP TABLE stock_adjustment_detail;DROP TABLE stock_adjustment_reason;DROP TABLE receipt_valuation;DROP TABLE trade_line_lot;internal/kernel/db/retention_test.go: appendOnly gains "receipt_valuation", "stock_adjustment_detail" (// Phase 4 O2: a receipt's valuation (a reversal is a negating row) and an adjustment's reason, written with the document.); amendable gains "item_cost", "cost_layer", "stock_adjustment_reason" (// Phase 4 O2.2: an item's cost and its FIFO layers move with every issue; a reason is retired, never deleted.); operational gains "trade_line_lot" (// Phase 4 O2.4: a draft line's serials and batches, replaced while it is a draft; the moves are the record.).
- Step 4:
costing.go
internal/platform/inventory/costing.go:
package inventory
import ( "bytes" "context" "errors" "fmt" "slices"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/company")
// beginStock opens every stock movement in LOCK_ORDER.md's inventory order:// company_profile FOR SHARE (company.SetCostingMethod takes it FOR UPDATE,// so the method cannot change under a move), then each item's item_cost// row, created if missing, FOR UPDATE in item id order — every movement of// an item happens under that lock. It returns the costing method.func beginStock(ctx context.Context, tx pgx.Tx, org uuid.UUID, items []uuid.UUID) (string, error) { p, err := company.GetForShare(ctx, tx) if err != nil { return "", err } ids := slices.Clone(items) slices.SortFunc(ids, func(a, b uuid.UUID) int { return bytes.Compare(a[:], b[:]) }) ids = slices.Compact(ids) if len(ids) == 0 { return p.CostingMethod, nil } for _, id := range ids { if _, err := tx.Exec(ctx, ` INSERT INTO item_cost (org_id, item_id) VALUES ($1, $2) ON CONFLICT ON CONSTRAINT item_cost_pkey DO NOTHING`, org, id); err != nil { return "", fmt.Errorf("inventory: item cost: %w", err) } } if _, err := tx.Exec(ctx, ` SELECT 1 FROM item_cost WHERE org_id = $1 AND item_id = ANY($2) ORDER BY item_id FOR UPDATE`, org, ids); err != nil { return "", fmt.Errorf("inventory: lock item costs: %w", err) } return p.CostingMethod, nil}
// costIn adds an inbound move's quantity and cost to its item; under FIFO// it also opens a layer for the move (receiptLine: the goods-receipt line// the goods came from, for a later purchase return).func costIn(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, item, move uuid.UUID, qty money.Rate, cost money.Amount, receiptLine *uuid.UUID) error { if _, err := tx.Exec(ctx, ` UPDATE item_cost SET quantity = quantity + $3, value = value + $4 WHERE org_id = $1 AND item_id = $2`, org, item, qty, cost); err != nil { return mapStockError(fmt.Errorf("inventory: cost in: %w", err)) } if method != CostingFIFO { return nil } unit, err := cost.MulDiv(money.OneRate(), qty) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO cost_layer (org_id, item_id, move_id, receipt_line_id, unit_cost, quantity, remaining_quantity, remaining_value) VALUES ($1, $2, $3, $4, $5, $6, $6, $7)`, org, item, move, receiptLine, unit, qty, cost) if err != nil { return fmt.Errorf("inventory: open layer: %w", err) } return nil}
// outRule narrows an issue: a purchase return consumes only its receipt's// FIFO layers (receiptLine) and, under weighted average, leaves at its// receipt's cost (atCost) rather than the average.type outRule struct { receiptLine *uuid.UUID atCost *money.Amount}
// costOut takes qty of item out of its cost and returns what it cost// (positive). Weighted average: the average, rounded to 4 places, times// qty; the issue that empties the item takes the residue (spec O2.2).// FIFO: the oldest layers. Never more than the value held (R-O2-4).func costOut(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, item uuid.UUID, qty money.Rate, r outRule) (money.Amount, error) { var held money.Rate var value money.Amount if err := tx.QueryRow(ctx, `SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`, org, item).Scan(&held, &value); err != nil { return money.Amount{}, fmt.Errorf("inventory: cost out: %w", err) } if qty.Cmp(held) > 0 { return money.Amount{}, ErrInsufficientStock } var cost money.Amount switch { case method == CostingFIFO: c, err := consumeLayers(ctx, tx, org, item, qty, r.receiptLine) if err != nil { return money.Amount{}, err } cost = c case qty.Cmp(held) == 0: cost = value case r.atCost != nil: cost = *r.atCost default: avg, err := value.MulDiv(money.OneRate(), held) if err != nil { return money.Amount{}, err } cost = avg.MulRate(qty) } if cost.Cmp(value) > 0 { cost = value } if _, err := tx.Exec(ctx, ` UPDATE item_cost SET quantity = quantity - $3, value = value - $4 WHERE org_id = $1 AND item_id = $2`, org, item, qty, cost); err != nil { return money.Amount{}, mapStockError(fmt.Errorf("inventory: cost out: %w", err)) } return cost, nil}
type layer struct { id uuid.UUID remaining money.Rate remainingValue money.Amount unit money.Amount}
// consumeLayers takes qty from the item's open layers, oldest first (only// receiptLine's when given). A layer emptied gives its whole remaining// value: the residue stays with the layer, never with the next one.func consumeLayers(ctx context.Context, tx pgx.Tx, org, item uuid.UUID, qty money.Rate, receiptLine *uuid.UUID) (money.Amount, error) { rows, err := tx.Query(ctx, ` SELECT id, remaining_quantity, remaining_value, unit_cost FROM cost_layer WHERE org_id = $1 AND item_id = $2 AND remaining_quantity > 0 AND ($3::uuid IS NULL OR receipt_line_id = $3) ORDER BY seq FOR UPDATE`, org, item, receiptLine) if err != nil { return money.Amount{}, fmt.Errorf("inventory: layers: %w", err) } var layers []layer for rows.Next() { var l layer if err := rows.Scan(&l.id, &l.remaining, &l.remainingValue, &l.unit); err != nil { rows.Close() return money.Amount{}, err } layers = append(layers, l) } rows.Close() if err := rows.Err(); err != nil { return money.Amount{}, err } need, cost := qty, money.Zero() for _, l := range layers { if !need.IsPositive() { break } take, v := l.remaining, l.remainingValue if need.Cmp(take) < 0 { take = need if v = l.unit.MulRate(take); v.Cmp(l.remainingValue) > 0 { v = l.remainingValue } } if _, err := tx.Exec(ctx, ` UPDATE cost_layer SET remaining_quantity = remaining_quantity - $3, remaining_value = remaining_value - $4 WHERE org_id = $1 AND id = $2`, org, l.id, take, v); err != nil { return money.Amount{}, fmt.Errorf("inventory: consume layer: %w", err) } cost, need = cost.Add(v), need.Sub(take) } if need.IsPositive() { if receiptLine != nil { return money.Amount{}, ErrLayerConsumed } return money.Amount{}, ErrInsufficientStock } return cost, nil}
// uncostIn takes an inbound move back out at exactly the cost it came in// at (a reversal mirrors). Under FIFO its layer must be untouched.func uncostIn(ctx context.Context, tx pgx.Tx, org uuid.UUID, method string, m liveMove) error { if method == CostingFIFO { var qty, remaining money.Rate err := tx.QueryRow(ctx, ` SELECT quantity, remaining_quantity FROM cost_layer WHERE org_id = $1 AND move_id = $2 FOR UPDATE`, org, m.ID).Scan(&qty, &remaining) if err != nil { return fmt.Errorf("inventory: layer of move %s: %w", m.ID, err) } if qty.Cmp(remaining) != 0 { return ErrLayerConsumed } if _, err := tx.Exec(ctx, ` UPDATE cost_layer SET remaining_quantity = 0, remaining_value = 0 WHERE org_id = $1 AND move_id = $2`, org, m.ID); err != nil { return fmt.Errorf("inventory: close layer: %w", err) } } if _, err := tx.Exec(ctx, ` UPDATE item_cost SET quantity = quantity - $3, value = value - $4 WHERE org_id = $1 AND item_id = $2`, org, m.ItemID, m.Quantity, m.Cost); err != nil { return mapStockError(fmt.Errorf("inventory: reverse cost: %w", err)) } return nil}
// CurrentUnitCost is what one more unit of item would be valued at today// (R-O2-16): the average under weighted average, the newest layer's unit// cost under FIFO; zero when the item has never been costed.func CurrentUnitCost(ctx context.Context, tx pgx.Tx, itemID uuid.UUID) (money.Amount, error) { org, err := stockOrg(ctx) if err != nil { return money.Amount{}, err } p, err := company.Get(ctx, tx) if err != nil { return money.Amount{}, err } unit := money.Zero() if p.CostingMethod == CostingFIFO { err = tx.QueryRow(ctx, ` SELECT unit_cost FROM cost_layer WHERE org_id = $1 AND item_id = $2 ORDER BY (remaining_quantity > 0) DESC, seq DESC LIMIT 1`, org, itemID).Scan(&unit) } else { var qty money.Rate var value money.Amount err = tx.QueryRow(ctx, `SELECT quantity, value FROM item_cost WHERE org_id = $1 AND item_id = $2`, org, itemID).Scan(&qty, &value) if err == nil && qty.IsPositive() { unit, err = value.MulDiv(money.OneRate(), qty) } } if errors.Is(err, pgx.ErrNoRows) { return money.Zero(), nil } return unit, err}
// CostingLocked reports whether stock has moved, so the costing method is// fixed (OD-1).func CostingLocked(ctx context.Context, tx pgx.Tx) (bool, error) { org, err := stockOrg(ctx) if err != nil { return false, err } var moved bool err = tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_move WHERE org_id = $1)`, org).Scan(&moved) return moved, err}- Step 5:
stockline.go
internal/platform/inventory/stockline.go:
package inventory
import ( "context" "encoding/json" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/analytic")
// Lot is one serial (Quantity 1) or batch a trade line moves (spec O2.4).type Lot struct { SerialNo string `json:"serial_no,omitempty"` BatchNo string `json:"batch_no,omitempty"` ExpiresOn *time.Time `json:"expires_on,omitempty"` Quantity money.Rate `json:"quantity"`}
// stockLine is a trade line with what moving and posting it needs from its// header and its item. It reads O1's tables directly (inventory does not// import trade): withdrawn lines are skipped, the party is the supplier or// the customer, and an adjustment line's sign is its direction (R-O2-12).type stockLine struct { ID, DocumentID uuid.UUID LineNo int Kind, Number string Currency string PartyID *uuid.UUID LocationID *uuid.UUID ItemID *uuid.UUID ItemType, Tracking string InventoryAccountID *uuid.UUID COGSAccountID *uuid.UUID PurchaseAccountID *uuid.UUID Quantity money.Rate // in the line's UOM BaseQuantity money.Rate // trade_line.quantity_base; negative only on an adjustment going out UnitPrice money.Amount // per line UOM, in Currency NetAmount money.Amount // trade_line.net_amount: unit price × quantity, to the cent SourceLineID *uuid.UUID Shares []analytic.Share}
func (l stockLine) stock() bool { return l.ItemID != nil && l.ItemType == itemTypeStock }
const stockLineSelect = ` SELECT l.id, l.document_id, l.line_no, d.kind, d.number, d.currency, coalesce(d.supplier_party_id, d.customer_party_id), d.location_id, l.item_id, coalesce(i.type, ''), coalesce(i.tracking, ''), i.inventory_account_id, i.cogs_account_id, i.purchase_account_id, l.quantity, l.quantity_base, l.unit_price, l.net_amount, l.source_line_id, l.shares FROM trade_line l JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id LEFT JOIN item i ON i.org_id = l.org_id AND i.id = l.item_id WHERE l.org_id = $1 AND l.withdrawn_at IS NULL`
func scanStockLine(row pgx.Row) (stockLine, error) { var l stockLine var shares []byte if err := row.Scan(&l.ID, &l.DocumentID, &l.LineNo, &l.Kind, &l.Number, &l.Currency, &l.PartyID, &l.LocationID, &l.ItemID, &l.ItemType, &l.Tracking, &l.InventoryAccountID, &l.COGSAccountID, &l.PurchaseAccountID, &l.Quantity, &l.BaseQuantity, &l.UnitPrice, &l.NetAmount, &l.SourceLineID, &shares); err != nil { return stockLine{}, err } if len(shares) > 0 { var stored []struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"` } if err := json.Unmarshal(shares, &stored); err != nil { return stockLine{}, fmt.Errorf("inventory: line shares: %w", err) } for _, s := range stored { l.Shares = append(l.Shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } } return l, nil}
// documentLines is doc's lines in line order.func documentLines(ctx context.Context, tx pgx.Tx, org, docID uuid.UUID) ([]stockLine, error) { rows, err := tx.Query(ctx, stockLineSelect+` AND l.document_id = $2 ORDER BY l.line_no`, org, docID) if err != nil { return nil, fmt.Errorf("inventory: document lines: %w", err) } defer rows.Close() var out []stockLine for rows.Next() { l, err := scanStockLine(rows) if err != nil { return nil, err } out = append(out, l) } return out, rows.Err()}
// checkLots is what lots must be for an item's tracking: none for an// untracked item (one lot, the whole quantity), one serial per unit for a// serial item, batches adding up to the quantity for a batch item.func checkLots(tracking string, qty money.Rate, lots []Lot) ([]Lot, error) { if tracking == "none" { if len(lots) > 0 { return nil, ErrUntrackedLot } return []Lot{{Quantity: qty}}, nil } var sum money.Rate for _, lot := range lots { switch { case tracking == "serial" && (lot.SerialNo == "" || lot.BatchNo != "" || lot.Quantity.Cmp(money.OneRate()) != 0): return nil, ErrSerialRequired case tracking == "batch" && (lot.BatchNo == "" || lot.SerialNo != "" || !lot.Quantity.IsPositive()): return nil, ErrBatchRequired } sum = sum.Add(lot.Quantity) } if len(lots) == 0 || sum.Cmp(qty) != 0 { return nil, ErrLotsMismatch } return lots, nil}
// lineLots is l's captured lots, checked against its item.func lineLots(ctx context.Context, tx pgx.Tx, org uuid.UUID, l stockLine) ([]Lot, error) { rows, err := tx.Query(ctx, ` SELECT coalesce(serial_no, ''), coalesce(batch_no, ''), expires_on, quantity FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2 ORDER BY serial_no NULLS LAST, batch_no NULLS LAST`, org, l.ID) if err != nil { return nil, fmt.Errorf("inventory: lots: %w", err) } defer rows.Close() var lots []Lot for rows.Next() { var lot Lot if err := rows.Scan(&lot.SerialNo, &lot.BatchNo, &lot.ExpiresOn, &lot.Quantity); err != nil { return nil, err } lots = append(lots, lot) } if err := rows.Err(); err != nil { return nil, err } return checkLots(l.Tracking, l.BaseQuantity, lots)}
// SetLineLots replaces the serials and batches of a line that has not// moved (a draft). The caller holds the document header FOR UPDATE.func SetLineLots(ctx context.Context, tx pgx.Tx, lineID uuid.UUID, lots []Lot) error { org, err := stockOrg(ctx) if err != nil { return err } var moved bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_move WHERE org_id = $1 AND trade_line_id = $2)`, org, lineID).Scan(&moved); err != nil { return fmt.Errorf("inventory: lots: %w", err) } if moved { return ErrAlreadyApplied } if _, err := tx.Exec(ctx, `DELETE FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2`, org, lineID); err != nil { return fmt.Errorf("inventory: lots: %w", err) } for _, lot := range lots { if _, err := tx.Exec(ctx, ` INSERT INTO trade_line_lot (org_id, trade_line_id, serial_no, batch_no, expires_on, quantity) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5, $6)`, org, lineID, lot.SerialNo, lot.BatchNo, lot.ExpiresOn, lot.Quantity); err != nil { return mapStockError(fmt.Errorf("inventory: lot: %w", err)) } } return nil}
// recordBatch keeps a batch's first known expiry, and refuses another.func recordBatch(ctx context.Context, tx pgx.Tx, org, item uuid.UUID, lot Lot) error { var stored *time.Time err := tx.QueryRow(ctx, ` INSERT INTO stock_batch (org_id, item_id, batch_no, expires_on) VALUES ($1, $2, $3, $4) ON CONFLICT ON CONSTRAINT stock_batch_pkey DO UPDATE SET expires_on = coalesce(stock_batch.expires_on, EXCLUDED.expires_on) RETURNING expires_on`, org, item, lot.BatchNo, lot.ExpiresOn).Scan(&stored) if err != nil { return fmt.Errorf("inventory: batch: %w", err) } if lot.ExpiresOn != nil && (stored == nil || !stored.Equal(*lot.ExpiresOn)) { return ErrBatchExpiryMismatch } return nil}- Step 6:
apply.go
internal/platform/inventory/apply.go:
package inventory
import ( "context" "errors" "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/kernel/money" "github.com/mcom/erp/internal/platform/accounting/fx" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
var stockKinds = map[string]bool{KindGoodsReceipt: true, KindPurchaseReturn: true, KindDeliveryOrder: true, KindSalesReturn: true, KindStockAdjustment: true}
// Received is what a goods-receipt line brought in, net of any reversal.type Received struct { Quantity money.Rate // base UOM Amount money.Amount // document currency Currency string Base money.Amount // base currency: what GRNI was credited}
// ReceivedValue is Received for one goods-receipt line; zero when it was// never received or its receipt was cancelled.func ReceivedValue(ctx context.Context, tx pgx.Tx, receiptLineID uuid.UUID) (Received, error) { org, err := stockOrg(ctx) if err != nil { return Received{}, err } return receivedValue(ctx, tx, org, receiptLineID)}
func receivedValue(ctx context.Context, tx pgx.Tx, org, line uuid.UUID) (Received, error) { var r Received err := tx.QueryRow(ctx, ` SELECT coalesce(sum(quantity), 0), coalesce(sum(amount), 0), coalesce(max(currency), ''), coalesce(sum(base_amount), 0) FROM receipt_valuation WHERE org_id = $1 AND trade_line_id = $2`, org, line). Scan(&r.Quantity, &r.Amount, &r.Currency, &r.Base) if err != nil { return Received{}, fmt.Errorf("inventory: received value: %w", err) } return r, nil}
// liveLineCost is the signed cost of a line's moves that are neither// reversals nor reversed: positive in, negative out.func liveLineCost(ctx context.Context, tx pgx.Tx, org, line uuid.UUID) (money.Amount, error) { var v money.Amount err := tx.QueryRow(ctx, ` SELECT coalesce(sum(m.total_cost), 0) FROM stock_move m WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`, org, line).Scan(&v) if err != nil { return money.Amount{}, fmt.Errorf("inventory: line cost: %w", err) } return v, nil}
// toBase converts amount at today's rate, the rate a receipt is valued at// (R-O2-3) and the one ledger.Post would use for the same date.func toBase(ctx context.Context, tx pgx.Tx, c clock.Clock, amount money.Amount, currency string) (money.Amount, error) { p, err := company.Get(ctx, tx) if err != nil { return money.Amount{}, err } q, err := fx.Resolve(ctx, tx, currency, p.BaseCurrency, clock.Today(ctx, c)) if err != nil { return money.Amount{}, err } return amount.MulRate(q.Rate), nil}
// apply moves the stock of every line of doc. Lines are taken in line// order after every item's cost row is locked (beginStock).func apply(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID) error { org, err := stockOrg(ctx) if err != nil { return err } lines, err := documentLines(ctx, tx, org, docID) if err != nil { return err } var items []uuid.UUID for _, l := range lines { if l.stock() { items = append(items, *l.ItemID) } } method, err := beginStock(ctx, tx, org, items) if err != nil { return err } for _, l := range lines { var err error switch l.Kind { case KindGoodsReceipt: err = receiveLine(ctx, tx, c, org, method, l) case KindDeliveryOrder: err = issueLine(ctx, tx, c, org, method, l) case KindPurchaseReturn: err = returnOutLine(ctx, tx, c, org, method, l) case KindSalesReturn: err = returnInLine(ctx, tx, c, org, method, l) case KindStockAdjustment: err = adjustLine(ctx, tx, c, org, method, l) default: return fmt.Errorf("%w: %s", ErrWrongKind, l.Kind) } if err != nil { return fmt.Errorf("inventory: line %d: %w", l.LineNo, err) } } return nil}
// receiveLine values a receipt line at its net amount converted at today's// rate (R-O2-3), records it, and brings a stock item in at that// value. A service or non-stock line is received too (spec O3.1) and moves// nothing.func receiveLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error { if l.ItemID == nil { return ErrLineNeedsItem } held, err := receivedValue(ctx, tx, org, l.ID) if err != nil { return err } if !held.Quantity.IsZero() { return ErrAlreadyApplied } amount := l.NetAmount base, err := toBase(ctx, tx, c, amount, l.Currency) if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO receipt_valuation (org_id, trade_line_id, quantity, amount, currency, base_amount, valued_on, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, org, l.ID, l.BaseQuantity, amount, l.Currency, base, clock.Today(ctx, c), c.Now()); err != nil { return fmt.Errorf("inventory: receipt valuation: %w", err) } if !l.stock() { return nil } line := l.ID return moveIn(ctx, tx, c, org, method, l, base, &line)}
func issueLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error { if !l.stock() { return nil // a service or text line delivers no stock } return moveOut(ctx, tx, c, org, method, l, outRule{})}
// returnOutLine sends a purchase return's goods back at their receipt's// cost: under FIFO from that receipt's own layers (the goods must still be// held), under weighted average at the receipt's unit value (spec O3.1).func returnOutLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error { if l.ItemID == nil { return ErrLineNeedsItem } if l.SourceLineID == nil { return ErrNoSourceLine } if !l.stock() { return nil } src, err := receivedValue(ctx, tx, org, *l.SourceLineID) if err != nil { return err } if !src.Quantity.IsPositive() { return ErrNoSourceLine } at, err := src.Base.MulDiv(l.BaseQuantity, src.Quantity) if err != nil { return err } return moveOut(ctx, tx, c, org, method, l, outRule{receiptLine: l.SourceLineID, atCost: &at})}
// returnInLine brings a sales return's goods back at the cost they left at:// the delivery line's own moves, pro rata (spec O3.2).func returnInLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error { if !l.stock() { return nil } if l.SourceLineID == nil { return ErrNoSourceLine } var qty money.Rate var cost money.Amount if err := tx.QueryRow(ctx, ` SELECT coalesce(sum(m.quantity), 0), coalesce(sum(m.total_cost), 0) FROM stock_move m WHERE m.org_id = $1 AND m.trade_line_id = $2 AND m.reverses_move_id IS NULL AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id)`, org, *l.SourceLineID).Scan(&qty, &cost); err != nil { return fmt.Errorf("inventory: delivered cost: %w", err) } if !qty.IsNegative() { return ErrNoSourceLine } back, err := cost.Neg().MulDiv(l.BaseQuantity, qty.Neg()) if err != nil { return err } return moveIn(ctx, tx, c, org, method, l, back, nil)}
// adjustLine: the quantity's sign is the direction (R-O2-12, O1's// trade_line_quantity_sign). In at the line's unit cost (base currency,// base UOM), out at FIFO or average cost; the moves then carry the size.func adjustLine(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine) error { if !l.stock() { return ErrNotStockItem } if l.BaseQuantity.IsNegative() { l.BaseQuantity = l.BaseQuantity.Neg() return moveOut(ctx, tx, c, org, method, l, outRule{}) } return moveIn(ctx, tx, c, org, method, l, l.UnitPrice.MulRate(l.BaseQuantity), nil)}
// moveIn brings l's base quantity into its location at total, one move per// lot, the cost split pro rata with the last lot taking the residue.func moveIn(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine, total money.Amount, receiptLine *uuid.UUID) error { if l.LocationID == nil { return ErrNoLocation } lots, err := lineLots(ctx, tx, org, l) if err != nil { return err } allocated := money.Zero() for i, lot := range lots { cost := total.Sub(allocated) if i < len(lots)-1 { if cost, err = total.MulDiv(lot.Quantity, l.BaseQuantity); err != nil { return err } } allocated = allocated.Add(cost) line := l.ID move, err := insertMove(ctx, tx, c, moveInput{ItemID: *l.ItemID, LocationID: *l.LocationID, Quantity: lot.Quantity, Cost: cost, SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, TradeLineID: &line}) if err != nil { return err } if lot.BatchNo != "" { if err := recordBatch(ctx, tx, org, *l.ItemID, lot); err != nil { return err } } if err := costIn(ctx, tx, org, method, *l.ItemID, move, lot.Quantity, cost, receiptLine); err != nil { return err } } return nil}
// moveOut takes l's base quantity out of its location: the cost is decided// once for the line (costOut), then split over its lots like moveIn.func moveOut(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, method string, l stockLine, r outRule) error { if l.LocationID == nil { return ErrNoLocation } lots, err := lineLots(ctx, tx, org, l) if err != nil { return err } total, err := costOut(ctx, tx, org, method, *l.ItemID, l.BaseQuantity, r) if err != nil { return err } allocated := money.Zero() for i, lot := range lots { cost := total.Sub(allocated) if i < len(lots)-1 { if cost, err = total.MulDiv(lot.Quantity, l.BaseQuantity); err != nil { return err } } allocated = allocated.Add(cost) line := l.ID if _, err := insertMove(ctx, tx, c, moveInput{ItemID: *l.ItemID, LocationID: *l.LocationID, Quantity: lot.Quantity.Neg(), Cost: cost.Neg(), SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, TradeLineID: &line}); err != nil { return err } } return nil}
// liveMove is one unreversed move of a document, with what reversing it needs.type liveMove struct { ID, ItemID, LocationID, LineID uuid.UUID Quantity money.Rate Cost money.Amount SerialNo, BatchNo string Kind string SourceLineID *uuid.UUID}
// reverse mirrors every unreversed move of doc, newest first, and takes// the costing back with it: an inbound move leaves at the cost it came in// at (FIFO: its layer must be untouched); an outbound move comes back at// the cost it left at, as a new layer under FIFO (R-O2-6). A receipt's// valuation gets its negating row.func reverse(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID) error { org, err := stockOrg(ctx) if err != nil { return err } rows, err := tx.Query(ctx, ` SELECT m.id, m.item_id, m.location_id, m.trade_line_id, m.quantity, m.total_cost, coalesce(m.serial_no, ''), coalesce(m.batch_no, ''), d.kind, l.source_line_id FROM stock_move m JOIN trade_line l ON l.org_id = m.org_id AND l.id = m.trade_line_id JOIN trade_document d ON d.org_id = l.org_id AND d.id = l.document_id WHERE m.org_id = $1 AND l.document_id = $2 AND m.reverses_move_id IS NULL AND NOT EXISTS (SELECT 1 FROM stock_move r WHERE r.org_id = m.org_id AND r.reverses_move_id = m.id) ORDER BY m.seq DESC`, org, docID) if err != nil { return fmt.Errorf("inventory: reverse: %w", err) } var moves []liveMove var items []uuid.UUID for rows.Next() { var m liveMove if err := rows.Scan(&m.ID, &m.ItemID, &m.LocationID, &m.LineID, &m.Quantity, &m.Cost, &m.SerialNo, &m.BatchNo, &m.Kind, &m.SourceLineID); err != nil { rows.Close() return err } moves = append(moves, m) items = append(items, m.ItemID) } rows.Close() if err := rows.Err(); err != nil { return err } method, err := beginStock(ctx, tx, org, items) if err != nil { return err } for _, m := range moves { if m.Quantity.IsPositive() { if err := uncostIn(ctx, tx, org, method, m); err != nil { return err } } move, line, reverses := m.ID, m.LineID, m.ID mirror, err := insertMove(ctx, tx, c, moveInput{ItemID: m.ItemID, LocationID: m.LocationID, Quantity: m.Quantity.Neg(), Cost: m.Cost.Neg(), SerialNo: m.SerialNo, BatchNo: m.BatchNo, TradeLineID: &line, ReversesID: &reverses}) if err != nil { return fmt.Errorf("inventory: reverse move %s: %w", move, err) } if m.Quantity.IsNegative() { var receiptLine *uuid.UUID if m.Kind == KindPurchaseReturn { receiptLine = m.SourceLineID } if err := costIn(ctx, tx, org, method, m.ItemID, mirror, m.Quantity.Neg(), m.Cost.Neg(), receiptLine); err != nil { return err } } } if _, err := tx.Exec(ctx, ` INSERT INTO receipt_valuation (org_id, trade_line_id, quantity, amount, currency, base_amount, valued_on, reverses_id, created_at) SELECT v.org_id, v.trade_line_id, -v.quantity, -v.amount, v.currency, -v.base_amount, $3, v.id, $4 FROM receipt_valuation v JOIN trade_line l ON l.org_id = v.org_id AND l.id = v.trade_line_id WHERE v.org_id = $1 AND l.document_id = $2 AND v.reverses_id IS NULL AND NOT EXISTS (SELECT 1 FROM receipt_valuation r WHERE r.org_id = v.org_id AND r.reverses_id = v.id)`, org, docID, clock.Today(ctx, c), c.Now()); err != nil { return fmt.Errorf("inventory: reverse valuation: %w", err) } return nil}
// OnTransition is a stock document's stock half of a transition, called// after the header lock and before workflow.Transition: entering the// posting state (accrual for a receipt or purchase return, effect// otherwise) moves the stock; entering rejected or cancelled after it// (posted: the instance's AccrualOpen before the move) reverses the moves.func OnTransition(ctx context.Context, tx pgx.Tx, c clock.Clock, docID uuid.UUID, to posting.Semantic, posted bool) error { switch { case to == posting.Accrual || to == posting.Effect: return apply(ctx, tx, c, docID) case (to == posting.Rejected || to == posting.Cancelled) && posted: return reverse(ctx, tx, c, docID) } return nil}
// TransitionInput moves one stock document along its workflow.type TransitionInput struct { DocumentID uuid.UUID From, To string Actor uuid.UUID Reason string}
// Transition is the one way a stock document changes state (R-O2-7: the// commitment kinds use trade.Transition). ORDER (LOCK_ORDER.md, inventory):// trade_document FOR UPDATE → on a cancel, its live lines FOR UPDATE in id// order → company_profile// FOR SHARE → item_cost rows in item id order → cost_layer rows in seq// order → stock_balance (stock_move's trigger) → workflow.Transition (the// Poster reads the moves) → audit, last. O3's receipt, delivery and return// transitions run their own checks under the same header lock, then call// this (a second FOR UPDATE of a held row is a no-op).func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (workflow.Decision, error) { org, err := stockOrg(ctx) if err != nil { return workflow.Decision{}, err } var kind string var instanceID uuid.UUID err = tx.QueryRow(ctx, `SELECT kind, instance_id FROM trade_document WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, in.DocumentID).Scan(&kind, &instanceID) if errors.Is(err, pgx.ErrNoRows) { return workflow.Decision{}, ErrDocumentNotFound } if err != nil { return workflow.Decision{}, fmt.Errorf("inventory: lock document: %w", err) } if !stockKinds[kind] { return workflow.Decision{}, fmt.Errorf("%w: %s", ErrWrongKind, kind) } inst, err := workflow.Get(ctx, tx, instanceID) if err != nil { return workflow.Decision{}, err } def, err := workflow.Load(ctx, tx, inst.DefinitionID) if err != nil { return workflow.Decision{}, err } to, ok := def.State(in.To) if !ok { return workflow.Decision{}, workflow.ErrNoSuchEdge } if to.Semantic == posting.Cancelled { // trade.Transition's rule (Ruling O1-7), with its lock: a document // others draw from (a receipt with a live return) is not cancelled. 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, in.DocumentID); err != nil { return workflow.Decision{}, fmt.Errorf("inventory: lock lines: %w", err) } var live int if 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, in.DocumentID).Scan(&live); err != nil { return workflow.Decision{}, fmt.Errorf("inventory: live children: %w", err) } if live > 0 { return workflow.Decision{}, ErrHasChildren } } if err := OnTransition(ctx, tx, c, in.DocumentID, to.Semantic, inst.AccrualOpen); err != nil { return workflow.Decision{}, err } d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: instanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return workflow.Decision{}, err } actor := in.Actor return d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "trade_document", EntityID: in.DocumentID, Action: "transition", ActorID: &actor, Before: map[string]string{"state": inst.StateKey}, After: map[string]string{"state": in.To}})}Run: make generate && git status --short
- Step 7: Run the tests to verify they pass
Run: go test ./internal/platform/inventory/... ./internal/kernel/db/ ./internal/kernel/layering/ -count=1
Expected: PASS (TestEveryTenantQueryCarriesAnExplicitOrgPredicate included).
- Step 8: Sabotage
In costOut, delete the case qty.Cmp(held) == 0: branch; run -run TestWeightedAverageIssues; read the red (-1.6666 expected); restore. In uncostIn, delete the qty.Cmp(remaining) != 0 check; run -run TestReversingADocument; read the red; restore.
- Step 9: Commit
git add db/migrations/00724_cost_layers.sql db/migrations/00725_stock_documents.sql internal/git commit -m "Phase 4 O2: FIFO and weighted-average costing; stock documents move stock
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 7: Posting — receipts to GRNI, effects at cost, reversals
Section titled “Task 7: Posting — receipts to GRNI, effects at cost, reversals”Files:
- Create:
internal/platform/inventory/posting.go - Modify:
internal/platform/inventory/stocktest/stocktest.go(Poster inNew;Post,Cancel,MoveErr,Entries,LinesByAccount,AccountBalance) - Create:
internal/platform/inventory/posting_test.go
Interfaces:
- Consumes:
documentLines,stockLine,receivedValue,liveLineCost,Transition(Task 6);chart.PurchasePriceVariance,ControlKindGRNI(Task 2);posting.Effect(Task 1); the flows’ state keys (Task 5). - Produces:
inventory.Rules() []accounting.PostingRule—(goods_receipt, accrual),(purchase_return, accrual),(delivery_order, effect),(sales_return, effect),(stock_adjustment, effect); errorsErrNothingReceived,ErrPostingMismatch,ErrItemAccountMissing. stocktest: the real Poster inNew,EntryFor(t, instanceID, semantic) ledger.Entry,Entries(t, instanceID) []ledger.Entry,LinesByAccount(ledger.Entry) map[uuid.UUID]ledger.PostedLine,AccountBalance(t, account) money.Amount(base debit − credit).
Entries, all in base currency:
| Document | Semantic | Entry |
|---|---|---|
| Goods receipt | accrual (grni) |
Dr Inventory (stock) or the item’s purchase account (service, non-stock), each line at its receipt_valuation base / Cr GRNI, party the supplier |
| Purchase return | accrual (grni) |
Dr GRNI at the receipt’s value pro rata / Cr Inventory at the moves’ cost (or the purchase account at the GRNI value); the difference to PPV (R-O2-15) |
| Delivery | effect | per line: Dr the item’s COGS / Cr Inventory at the moves’ cost |
| Sales return | effect | per line: Dr Inventory / Cr COGS at the moves’ cost |
| Stock adjustment | effect | per line: Inventory against the reason’s account, direction by the cost’s sign |
| Cancel after posting | reversal | the engine’s mirror; Transition reverses the moves first |
- Step 1: Write the failing tests
internal/platform/inventory/stocktest/stocktest.go — in New, replace Task 6’s e.nopPoster(t) with:
// The stock documents post through the real rules (plan global // constraint: posting is process-global; never t.Parallel()). e.UsePoster(t, slices.Concat(inventory.Rules(), payables.Rules())...)delete nopPoster and its method, and append (imports: slices, github.com/mcom/erp/internal/platform/accounting/ledger, github.com/mcom/erp/internal/platform/payables):
// EntryFor is the instance's unreversed entry of semantic, with its lines.func (e *Env) EntryFor(t *testing.T, instanceID uuid.UUID, semantic string) ledger.Entry { t.Helper() var out ledger.Entry e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { open, err := ledger.Unreversed(ctx, tx, instanceID) if err != nil { return err } for _, en := range open { if en.Semantic == semantic { out, err = ledger.Get(ctx, tx, en.ID) return err } } return nil }) require.NotEqual(t, uuid.Nil, out.ID, "no unreversed %s entry for instance %s", semantic, instanceID) return out}
// Entries is the instance's entries with their lines, oldest first.func (e *Env) Entries(t *testing.T, instanceID uuid.UUID) []ledger.Entry { t.Helper() var out []ledger.Entry e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { heads, err := ledger.List(ctx, tx, ledger.Filter{InstanceID: &instanceID}) if err != nil { return err } for i := len(heads) - 1; i >= 0; i-- { full, err := ledger.Get(ctx, tx, heads[i].ID) if err != nil { return err } out = append(out, full) } return nil }) return out}
// LinesByAccount indexes an entry's lines by account; a test using it// posts at most one line per account.func LinesByAccount(en ledger.Entry) map[uuid.UUID]ledger.PostedLine { out := map[uuid.UUID]ledger.PostedLine{} for _, l := range en.Lines { out[l.AccountID] = l } return out}
// AccountBalance is the account's base debit − credit over every line.func (e *Env) AccountBalance(t *testing.T, account uuid.UUID) money.Amount { t.Helper() v := Amt("0") require.NoError(t, e.H.Admin.QueryRow(context.Background(), ` SELECT coalesce(sum(base_debit - base_credit), 0) FROM journal_line WHERE org_id = $1 AND account_id = $2`, e.Org, account).Scan(&v)) return v}internal/platform/inventory/posting_test.go:
package inventory_test
import ( "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestAReceiptPostsInventoryAndExpenseAgainstGRNI(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") labour := e.Item(t, "LABOUR", "service", "none") sup := e.SupplierParty(t) r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"), stocktest.Line(labour, "2", "100.00")) e.Post(t, r)
en := e.EntryFor(t, r.InstanceID, ledger.SemanticAccrual) require.Equal(t, "MYR", en.Currency) lines := stocktest.LinesByAccount(en) stocktest.RequireAmount(t, "15", lines[e.InventoryAccount].BaseDebit) stocktest.RequireAmount(t, "200", lines[e.PurchaseAccount].BaseDebit, "a service is expensed on receipt") stocktest.RequireAmount(t, "215", lines[e.GRNI].BaseCredit) require.Equal(t, &sup, lines[e.GRNI].PartyID) q, v := e.ItemCost(t, bolt) stocktest.RequireQty(t, "10", q) stocktest.RequireAmount(t, "15", v)}
func TestAForeignReceiptPostsInBaseAtItsDatesRate(t *testing.T) { e := stocktest.New(t) e.SeedRate(t, "USD", "2026-09-18", "4.2000") bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "USD", stocktest.Line(bolt, "10", "2.00")) e.Post(t, r) en := e.EntryFor(t, r.InstanceID, ledger.SemanticAccrual) require.Equal(t, "MYR", en.Currency, "stock entries post in base (R-O2-3)") stocktest.RequireAmount(t, "84", stocktest.LinesByAccount(en)[e.GRNI].BaseCredit)}
func TestADeliveryPostsCostOfSalesAtCost(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"))) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00")) e.Post(t, d) lines := stocktest.LinesByAccount(e.EntryFor(t, d.InstanceID, ledger.SemanticEffect)) stocktest.RequireAmount(t, "6", lines[e.COGSAccount].BaseDebit) stocktest.RequireAmount(t, "6", lines[e.InventoryAccount].BaseCredit) stocktest.RequireAmount(t, "9", e.AccountBalance(t, e.InventoryAccount))}
func TestADeliveryOfServicesPostsNothingAndCancelsCleanly(t *testing.T) { e := stocktest.New(t) labour := e.Item(t, "LABOUR", "service", "none") cust := e.CustomerParty(t) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(labour, "1", "300.00")) e.Post(t, d) require.Empty(t, e.Entries(t, d.InstanceID), "no stock, no cost: nothing posts (R-O2-1)") e.Cancel(t, d) require.Empty(t, e.Entries(t, d.InstanceID))}
func TestCancellingAReceiptReversesItsEntryAndItsStock(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50")) e.Post(t, r) stocktest.RequireAmount(t, "-15", e.AccountBalance(t, e.GRNI), "PRESENCE: GRNI holds the receipt")
e.Cancel(t, r) en := e.Entries(t, r.InstanceID) require.Len(t, en, 2) require.Equal(t, ledger.SemanticReversal, en[1].Semantic) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.GRNI)) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.InventoryAccount)) q, v := e.ItemCost(t, bolt) stocktest.RequireQty(t, "0", q) stocktest.RequireAmount(t, "0", v) stocktest.RequireQty(t, "0", e.Received(t, r.Lines[0]).Quantity) e.RequireBalancesAddUp(t)}
func TestAPurchaseReturnClearsGRNIAtReceiptValueWithTheDifferenceToPPV(t *testing.T) { e := stocktest.New(t) // weighted average bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "1.00")) e.Post(t, a) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "2", "3.00"))) e.Post(t, e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "2", "9.00")))
// Two units left, worth 4.00; returning a's two empties the item, so // they leave carrying the 4.00 (the residue rule) against a GRNI of 2.00. ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR", stocktest.DocLine{Item: bolt, Qty: "2", Price: "1.00", Source: &a.Lines[0]}) e.Post(t, ret) lines := stocktest.LinesByAccount(e.EntryFor(t, ret.InstanceID, ledger.SemanticAccrual)) stocktest.RequireAmount(t, "2", lines[e.GRNI].BaseDebit) stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit) stocktest.RequireAmount(t, "2", lines[e.PPV].BaseDebit) stocktest.RequireAmount(t, "0", e.AccountBalance(t, e.InventoryAccount))}
func TestAFIFOPurchaseReturnHasNoVariance(t *testing.T) { e := stocktest.New(t) e.UseCosting(t, inventory.CostingFIFO) bolt := e.Item(t, "BOLT", "stock", "none") sup := e.SupplierParty(t) a := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00")) e.Post(t, a) ret := e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR", stocktest.DocLine{Item: bolt, Qty: "4", Price: "1.00", Source: &a.Lines[0]}) e.Post(t, ret) lines := stocktest.LinesByAccount(e.EntryFor(t, ret.InstanceID, ledger.SemanticAccrual)) stocktest.RequireAmount(t, "4", lines[e.GRNI].BaseDebit) stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit) _, ppv := lines[e.PPV] require.False(t, ppv, "the receipt's own layer leaves at the receipt's value") require.ErrorIs(t, e.MoveErr(a, e.Owner, "received", "cancelled", "Entered in error"), inventory.ErrHasChildren, "a receipt with a live return is not cancelled (O1-7)") e.Cancel(t, ret) e.Cancel(t, a)}
func TestASalesReturnReversesCostOfSalesAtTheDeliveredCost(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "3.00"))) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00")) e.Post(t, d) r := e.Doc(t, inventory.KindSalesReturn, &cust, "MYR", stocktest.DocLine{Item: bolt, Qty: "2", Price: "9.00", Source: &d.Lines[0]}) e.Post(t, r) lines := stocktest.LinesByAccount(e.EntryFor(t, r.InstanceID, ledger.SemanticEffect)) stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseDebit) stocktest.RequireAmount(t, "4", lines[e.COGSAccount].BaseCredit)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/ -run 'Receipt|Delivery|Return|Posts' -count=1
Expected: build failure undefined: inventory.Rules.
- Step 3:
posting.go
internal/platform/inventory/posting.go:
// The stock posting rules (spec O2.3). Each reads its document's lines,// valuation and moves on the transition's own tx and returns a Draft in// base currency; it never writes a journal line. Reversal is the engine's// mirror (Ruling R7); Transition reverses the moves before it.package inventory
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/posting")
var ( ErrNothingReceived = errors.New("inventory: the receipt has no value to post") ErrPostingMismatch = errors.New("inventory: the event's instance or kind is not this document's") ErrItemAccountMissing = errors.New("inventory: the item names no account for this posting"))
// Rules is every posting rule this package owns. internal/modules/inventory// registers them all in one loop.func Rules() []accounting.PostingRule { return []accounting.PostingRule{ grniRule{kind: KindGoodsReceipt}, grniRule{kind: KindPurchaseReturn}, effectRule{kind: KindDeliveryOrder}, effectRule{kind: KindSalesReturn}, effectRule{kind: KindStockAdjustment}, }}
var docLabel = map[string]string{KindGoodsReceipt: "Goods receipt", KindPurchaseReturn: "Purchase return", KindDeliveryOrder: "Delivery order", KindSalesReturn: "Sales return", KindStockAdjustment: "Stock adjustment"}
// docHead is what a rule needs of the document itself.type docHead struct { ID, InstanceID uuid.UUID Kind, Number string PartyID *uuid.UUID ReasonAccountID *uuid.UUID}
func readHead(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (docHead, error) { var h docHead err := tx.QueryRow(ctx, ` SELECT d.id, d.instance_id, d.kind, d.number, coalesce(d.supplier_party_id, d.customer_party_id), r.account_id FROM trade_document d LEFT JOIN stock_adjustment_detail a ON a.org_id = d.org_id AND a.document_id = d.id LEFT JOIN stock_adjustment_reason r ON r.org_id = a.org_id AND r.id = a.reason_id WHERE d.org_id = $1 AND d.id = $2`, org, id). Scan(&h.ID, &h.InstanceID, &h.Kind, &h.Number, &h.PartyID, &h.ReasonAccountID) if errors.Is(err, pgx.ErrNoRows) { return docHead{}, ErrDocumentNotFound } return h, err}
// ruleStart is every rule's first half: the organization, the document// (matching the event), its lines and the base currency.func ruleStart(ctx context.Context, tx pgx.Tx, kind string, src accounting.Source) (uuid.UUID, docHead, []stockLine, string, error) { org, err := stockOrg(ctx) if err != nil { return uuid.Nil, docHead{}, nil, "", err } h, err := readHead(ctx, tx, org, src.Event.SourceID) if err != nil { return uuid.Nil, docHead{}, nil, "", err } if h.InstanceID != src.Event.InstanceID || h.Kind != kind { return uuid.Nil, docHead{}, nil, "", ErrPostingMismatch } lines, err := documentLines(ctx, tx, org, h.ID) if err != nil { return uuid.Nil, docHead{}, nil, "", err } p, err := company.Get(ctx, tx) if err != nil { return uuid.Nil, docHead{}, nil, "", err } return org, h, lines, p.BaseCurrency, nil}
func memo(h docHead, l stockLine) string { return fmt.Sprintf("%s L%d", h.Number, l.LineNo) }
// stockSide is the account a line's goods sit in: Inventory for a stock// item, the item's purchase account for a service or non-stock one.func (l stockLine) stockSide() (uuid.UUID, error) { a := l.PurchaseAccountID if l.stock() { a = l.InventoryAccountID } if a == nil { return uuid.Nil, fmt.Errorf("%w: line %d", ErrItemAccountMissing, l.LineNo) } return *a, nil}
func debit(account uuid.UUID, amt money.Amount, m string) ledger.Line { return ledger.Line{AccountID: account, Debit: amt, Credit: money.Zero(), Memo: m}}
func credit(account uuid.UUID, amt money.Amount, m string) ledger.Line { return ledger.Line{AccountID: account, Debit: money.Zero(), Credit: amt, Memo: m}}
// grniRule posts a goods receipt (Dr stock side / Cr GRNI) and a purchase// return (Dr GRNI at the receipt's value / Cr stock side at cost, the// difference to purchase price variance, R-O2-15).type grniRule struct{ kind string }
var _ accounting.PostingRule = grniRule{}
func (r grniRule) Kind() string { return r.kind }func (grniRule) Semantic() posting.Semantic { return posting.Accrual }
func (r grniRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { if src.Control == nil { return accounting.Draft{}, fmt.Errorf("inventory: %s: %w", r.kind, chart.ErrNotFound) } org, h, lines, base, err := ruleStart(ctx, tx, r.kind, src) if err != nil { return accounting.Draft{}, err } var out []ledger.Line grni := money.Zero() for _, l := range lines { if l.ItemID == nil { continue } side, err := l.stockSide() if err != nil { return accounting.Draft{}, err } m := memo(h, l) if r.kind == KindGoodsReceipt { v, err := receivedValue(ctx, tx, org, l.ID) if err != nil { return accounting.Draft{}, err } if v.Base.IsZero() { continue } line := debit(side, v.Base, m) if !l.stock() { line.Distribution = l.Shares } out = append(out, line) grni = grni.Add(v.Base) continue } if l.SourceLineID == nil { return accounting.Draft{}, ErrNoSourceLine } rv, err := receivedValue(ctx, tx, org, *l.SourceLineID) if err != nil { return accounting.Draft{}, err } if !rv.Quantity.IsPositive() { return accounting.Draft{}, ErrNoSourceLine } gr, err := rv.Base.MulDiv(l.BaseQuantity, rv.Quantity) if err != nil { return accounting.Draft{}, err } stock := gr if l.stock() { moved, err := liveLineCost(ctx, tx, org, l.ID) if err != nil { return accounting.Draft{}, err } stock = moved.Neg() } if !stock.IsZero() { line := credit(side, stock, m) if !l.stock() { line.Distribution = l.Shares } out = append(out, line) } if diff := gr.Sub(stock); !diff.IsZero() { ppv, err := chart.PurchasePriceVariance(ctx, tx) if err != nil { return accounting.Draft{}, err } if diff.IsPositive() { out = append(out, credit(ppv.ID, diff, m+" price variance")) } else { out = append(out, debit(ppv.ID, diff.Neg(), m+" price variance")) } } grni = grni.Add(gr) } if grni.IsZero() { return accounting.Draft{}, ErrNothingReceived } control := ledger.Line{AccountID: src.Control.ID, PartyID: h.PartyID, Memo: h.Number, Debit: money.Zero(), Credit: money.Zero()} if r.kind == KindGoodsReceipt { control.Credit = grni } else { control.Debit = grni } return accounting.Draft{JournalType: chart.JournalPurchase, Currency: base, Description: docLabel[r.kind] + " " + h.Number, Lines: append(out, control)}, nil}
// effectRule posts a delivery, a sales return or an adjustment: per stock// line, Inventory against the counter account (the item's COGS, or the// adjustment reason's account) at the line's moves' cost, direction by// its sign. No stock lines, no lines: nothing posts (R-O2-1).type effectRule struct{ kind string }
var _ accounting.PostingRule = effectRule{}
func (r effectRule) Kind() string { return r.kind }func (effectRule) Semantic() posting.Semantic { return posting.Effect }
func (r effectRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { org, h, lines, base, err := ruleStart(ctx, tx, r.kind, src) if err != nil { return accounting.Draft{}, err } var out []ledger.Line for _, l := range lines { if !l.stock() { continue } cost, err := liveLineCost(ctx, tx, org, l.ID) if err != nil { return accounting.Draft{}, err } if cost.IsZero() { continue } counter := l.COGSAccountID if r.kind == KindStockAdjustment { counter = h.ReasonAccountID } if counter == nil || l.InventoryAccountID == nil { return accounting.Draft{}, fmt.Errorf("%w: line %d", ErrItemAccountMissing, l.LineNo) } m := memo(h, l) inv, other := debit(*l.InventoryAccountID, cost, m), credit(*counter, cost, m) if cost.IsNegative() { inv, other = credit(*l.InventoryAccountID, cost.Neg(), m), debit(*counter, cost.Neg(), m) } other.Distribution = l.Shares out = append(out, inv, other) } return accounting.Draft{JournalType: chart.JournalMisc, Currency: base, Description: docLabel[r.kind] + " " + h.Number, Lines: out}, nil}- Step 4: Run the tests to verify they pass
Run: go test ./internal/platform/inventory/... -count=1
Expected: PASS.
- Step 5: Sabotage
In grniRule.Build, drop the PPV block; run -run TestAPurchaseReturnClears; read the red (unbalanced from ledger.Post); restore. In Transition, skip OnTransition when to.Semantic == posting.Cancelled; run -run TestCancellingAReceipt; read the red (inventory still 15 / item cost 10); restore.
- Step 6: Commit
git add internal/platform/inventory/git commit -m "Phase 4 O2: stock posting rules (GRNI accrual, effect at cost, PPV on returns)
Sabotage: <quote the two red messages>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 8: Transfers
Section titled “Task 8: Transfers”Files:
- Create:
internal/platform/inventory/transfer.go,internal/platform/inventory/transfer_test.go
Interfaces:
-
Consumes:
beginStock,insertMove,checkLots(Tasks 3, 6). -
Produces:
inventory.TransferInput{ItemID, From, To uuid.UUID; Quantity money.Rate; Lots []Lot; Note string; Actor uuid.UUID},inventory.StockTransfer{ID, ItemID, From, To uuid.UUID; Quantity money.Rate; TransferredOn time.Time; Note string}(json snake_case),inventory.TransferStock(ctx, tx, c, in) (StockTransfer, error). -
Step 1: Write the failing test
internal/platform/inventory/transfer_test.go:
package inventory_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestATransferMovesQuantityAndNoValue(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") phone := e.Item(t, "PHONE", "stock", "serial") sup := e.SupplierParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"), stocktest.DocLine{Item: phone, Qty: "1", Price: "900.00", Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}}})) move := func(in inventory.TransferInput) error { in.Actor = e.Staff return e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.TransferStock(ctx, tx, e.Clock, in) return err }) }
require.NoError(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("4")})) stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, "")) stocktest.RequireQty(t, "4", e.OnHand(t, bolt, e.WH2, "")) _, v := e.ItemCost(t, bolt) stocktest.RequireAmount(t, "15", v, "cost is per item across the organization") stocktest.RequireAmount(t, "915", e.AccountBalance(t, e.InventoryAccount), "no journal entry")
require.NoError(t, move(inventory.TransferInput{ItemID: phone, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("1"), Lots: []inventory.Lot{{SerialNo: "SN-1", Quantity: stocktest.Qty("1")}}})) stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH2, "SN-1"))
require.ErrorIs(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("7")}), inventory.ErrInsufficientStock) require.ErrorIs(t, move(inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH1, Quantity: stocktest.Qty("1")}), inventory.ErrSameLocation) require.ErrorIs(t, move(inventory.TransferInput{ItemID: phone, From: e.WH2, To: e.WH1, Quantity: stocktest.Qty("1")}), inventory.ErrLotsMismatch) stocktest.RequireQty(t, "6", e.OnHand(t, bolt, e.WH1, ""), "a refused transfer moved nothing") e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t)}- Step 2: Run the test to verify it fails
Run: go test ./internal/platform/inventory/ -run TestATransfer -count=1
Expected: build failure undefined: inventory.TransferStock.
- Step 3:
transfer.go
package inventory
import ( "context" "errors" "fmt" "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")
// TransferInput moves one item between two locations (R-O2-5).type TransferInput struct { ItemID, From, To uuid.UUID Quantity money.Rate Lots []Lot Note string Actor uuid.UUID}
type StockTransfer struct { ID uuid.UUID `json:"id"` ItemID uuid.UUID `json:"item_id"` From uuid.UUID `json:"from_location_id"` To uuid.UUID `json:"to_location_id"` Quantity money.Rate `json:"quantity"` TransferredOn time.Time `json:"-"` Note string `json:"note"`}
// TransferStock is two moves per lot, out of From and into To, at no cost:// no workflow and no journal entry (spec O2.1). ORDER: company_profile FOR// SHARE → the item's item_cost FOR UPDATE → stock_balance (the moves'// trigger) → audit, last.func TransferStock(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransferInput) (StockTransfer, error) { org, err := stockOrg(ctx) if err != nil { return StockTransfer{}, err } if in.From == in.To { return StockTransfer{}, ErrSameLocation } if !in.Quantity.IsPositive() { return StockTransfer{}, ErrQuantityNotPositive } if err := recordtext.Check("note", in.Note); err != nil { return StockTransfer{}, err } var typ, tracking string err = tx.QueryRow(ctx, `SELECT type, tracking FROM item WHERE org_id = $1 AND id = $2`, org, in.ItemID).Scan(&typ, &tracking) if errors.Is(err, pgx.ErrNoRows) { return StockTransfer{}, ErrItemNotFound } if err != nil { return StockTransfer{}, fmt.Errorf("inventory: transfer item: %w", err) } if typ != itemTypeStock { return StockTransfer{}, ErrNotStockItem } lots, err := checkLots(tracking, in.Quantity, in.Lots) if err != nil { return StockTransfer{}, err } if _, err := beginStock(ctx, tx, org, []uuid.UUID{in.ItemID}); err != nil { return StockTransfer{}, err } t := StockTransfer{ItemID: in.ItemID, From: in.From, To: in.To, Quantity: in.Quantity, TransferredOn: clock.Today(ctx, c), Note: in.Note} if err := tx.QueryRow(ctx, ` INSERT INTO stock_transfer (org_id, item_id, from_location_id, to_location_id, quantity, transferred_on, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, org, t.ItemID, t.From, t.To, t.Quantity, t.TransferredOn, t.Note, in.Actor, c.Now()).Scan(&t.ID); err != nil { return StockTransfer{}, fmt.Errorf("inventory: transfer: %w", err) } for _, lot := range lots { for _, leg := range []struct { loc uuid.UUID qty money.Rate }{{t.From, lot.Quantity.Neg()}, {t.To, lot.Quantity}} { id := t.ID if _, err := insertMove(ctx, tx, c, moveInput{ItemID: t.ItemID, LocationID: leg.loc, Quantity: leg.qty, Cost: money.Zero(), SerialNo: lot.SerialNo, BatchNo: lot.BatchNo, TransferID: &id}); err != nil { return StockTransfer{}, err } } } actor := in.Actor return t, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "stock_transfer", EntityID: t.ID, Action: "create", ActorID: &actor, After: t})}(recordtext.Check(field, s string) error is the kernel check ledger uses for record text.)
- Step 4: Run the test to verify it passes
Run: go test ./internal/platform/inventory/ -run TestATransfer -count=1
Expected: PASS.
- Step 5: Commit
git add internal/platform/inventory/transfer.go internal/platform/inventory/transfer_test.gogit commit -m "Phase 4 O2: transfers between locations, no value moved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 9: Adjustment reasons, adjustments and stock-take
Section titled “Task 9: Adjustment reasons, adjustments and stock-take”Files:
- Create:
db/migrations/00726_stock_count.sql - Create:
internal/platform/inventory/reason.go - Create:
internal/platform/inventory/adjustment/adjustment.go,internal/platform/inventory/adjustment/adjustment_test.go - Modify:
internal/platform/inventory/stocktest/stocktest.go(Reason) - Modify:
internal/kernel/db/retention_test.go
Interfaces:
-
Consumes:
Transition,SetLineLots,CurrentUnitCost, the errors (Tasks 3, 6); the adjustment flow (Task 5);effectRule(Task 7);trade.Create(O1). -
Produces (package inventory):
DirectionIn = "in",DirectionOut = "out";type AdjustmentReason{ID uuid.UUID; Code, Name string; AccountID uuid.UUID; Active bool}(json snake_case);type ReasonInput{Code string; AccountID uuid.UUID; Names map[string]string};CreateAdjustmentReason(ctx, tx, c, ReasonInput) (AdjustmentReason, error);ListAdjustmentReasons(ctx, tx) ([]AdjustmentReason, error);ErrReasonCode,ErrReasonCodeTaken,ErrReasonAccount,ErrReasonName,ErrReasonNotFound,ErrReasonInactive; stepinventory_adjustment_reasons(order 83). -
Produces (package adjustment):
type Input{LocationID, ReasonID, Reporter uuid.UUID; CountID *uuid.UUID; Lines []Line},type Line{ItemID uuid.UUID; Direction string; Quantity money.Rate; UnitCost money.Amount; Lots []inventory.Lot},Create(ctx, tx, c, Input) (uuid.UUID, error),type Adjustment,type LineView,Get(ctx, tx, id uuid.UUID) (Adjustment, error),type CountInput{LocationID, ReasonID, Reporter uuid.UUID; Lines []CountLine},type CountLine{ItemID uuid.UUID; SerialNo, BatchNo string; Counted money.Rate},type Count,type CountedLine,RecordCount(ctx, tx, c, CountInput) (Count, error);ErrNoLines,ErrUnitCostOnOut,ErrNegativeUnitCost,ErrDuplicateCountLine,ErrCountedNegative. stocktest:Reason(t, code) uuid.UUID. -
Step 1: Write the failing tests
internal/platform/inventory/stocktest/stocktest.go — append:
// Reason is the id of the organization's adjustment reason with this code.func (e *Env) Reason(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT id FROM stock_adjustment_reason WHERE org_id = $1 AND code = $2`, e.Org, code).Scan(&id)) return id}internal/platform/inventory/adjustment/adjustment_test.go:
package adjustment_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/adjustment" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestProvisioningSeedsReasonsAndAReasonPostsToAnOrdinaryAccount(t *testing.T) { e := stocktest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { got, err := inventory.ListAdjustmentReasons(ctx, tx) require.NoError(t, err) codes := map[string]uuid.UUID{} for _, r := range got { codes[r.Code] = r.AccountID } require.Equal(t, e.AdjustAccount, codes["count"]) require.Equal(t, e.AdjustAccount, codes["damage"]) return nil }) create := func(code string, account uuid.UUID) error { return e.DB.InTx(e.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.CreateAdjustmentReason(ctx, tx, e.Clock, inventory.ReasonInput{Code: code, AccountID: account, Names: map[string]string{"en": "Theft", "ms": "Kecurian", "zh": "盗窃"}}) return err }) } require.NoError(t, create("theft", e.Account(t, "6190")), "PRESENCE: an ordinary expense account") require.ErrorIs(t, create("theft", e.Account(t, "6190")), inventory.ErrReasonCodeTaken) require.ErrorIs(t, create("shrink", e.InventoryAccount), inventory.ErrReasonAccount, "inventory's own account") require.ErrorIs(t, create("owed", e.GRNI), inventory.ErrReasonAccount, "a control account") require.ErrorIs(t, create("Bad Code", e.Account(t, "6190")), inventory.ErrReasonCode)}
// adjust creates a draft adjustment at WH1 as Staff.func adjust(t *testing.T, e *stocktest.Env, reason uuid.UUID, lines ...adjustment.Line) stocktest.DocRef { t.Helper() ref := stocktest.DocRef{Kind: inventory.KindStockAdjustment} require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error if ref.ID, err = adjustment.Create(ctx, tx, e.Clock, adjustment.Input{LocationID: e.WH1, ReasonID: reason, Reporter: e.Staff, Lines: lines}); err != nil { return err } return tx.QueryRow(ctx, `SELECT instance_id FROM trade_document WHERE org_id = $1 AND id = $2`, e.Org, ref.ID).Scan(&ref.InstanceID) })) return ref}
func approve(t *testing.T, e *stocktest.Env, d stocktest.DocRef) { t.Helper() require.NoError(t, e.MoveErr(d, e.Staff, "draft", "submitted", "")) require.NoError(t, e.MoveErr(d, e.Owner, "submitted", "approved", ""))}
func TestAnAdjustmentIsTheOwnersAndPostsAgainstItsReason(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") count := e.Reason(t, "count")
in := adjust(t, e, count, adjustment.Line{ItemID: bolt, Direction: inventory.DirectionIn, Quantity: stocktest.Qty("5"), UnitCost: stocktest.Amt("2.00")}) require.NoError(t, e.MoveErr(in, e.Staff, "draft", "submitted", "")) require.Error(t, e.MoveErr(in, e.Staff, "submitted", "approved", ""), "the preparer does not approve it") require.NoError(t, e.MoveErr(in, e.Owner, "submitted", "approved", "")) lines := stocktest.LinesByAccount(e.EntryFor(t, in.InstanceID, ledger.SemanticEffect)) stocktest.RequireAmount(t, "10", lines[e.InventoryAccount].BaseDebit) stocktest.RequireAmount(t, "10", lines[e.AdjustAccount].BaseCredit) stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""))
out := adjust(t, e, count, adjustment.Line{ItemID: bolt, Direction: inventory.DirectionOut, Quantity: stocktest.Qty("2")}) approve(t, e, out) lines = stocktest.LinesByAccount(e.EntryFor(t, out.InstanceID, ledger.SemanticEffect)) stocktest.RequireAmount(t, "4", lines[e.AdjustAccount].BaseDebit) stocktest.RequireAmount(t, "4", lines[e.InventoryAccount].BaseCredit)
require.NoError(t, e.MoveErr(out, e.Owner, "approved", "cancelled", "Found the two bolts")) stocktest.RequireQty(t, "5", e.OnHand(t, bolt, e.WH1, ""), "cancelling reverses the moves") e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t)
err := e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := adjustment.Create(ctx, tx, e.Clock, adjustment.Input{LocationID: e.WH1, ReasonID: count, Reporter: e.Staff, Lines: []adjustment.Line{{ItemID: bolt, Direction: inventory.DirectionOut, Quantity: stocktest.Qty("1"), UnitCost: stocktest.Amt("1")}}}) return err }) require.ErrorIs(t, err, adjustment.ErrUnitCostOnOut)}
func TestAStockCountAdjustsToWhatWasCounted(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") phone := e.Item(t, "PHONE", "stock", "serial") sup := e.SupplierParty(t) sn := func(s string) inventory.Lot { return inventory.Lot{SerialNo: s, Quantity: stocktest.Qty("1")} } e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "5", "1.00"), stocktest.DocLine{Item: phone, Qty: "2", Price: "100.00", Lots: []inventory.Lot{sn("SN-1"), sn("SN-2")}}))
record := func(lines ...adjustment.CountLine) adjustment.Count { var got adjustment.Count require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { var err error got, err = adjustment.RecordCount(ctx, tx, e.Clock, adjustment.CountInput{LocationID: e.WH1, ReasonID: e.Reason(t, "count"), Reporter: e.Staff, Lines: lines}) return err })) return got } // PRESENCE of the absence: a count that matches the books adjusts nothing. same := record(adjustment.CountLine{ItemID: bolt, Counted: stocktest.Qty("5")}) require.Nil(t, same.AdjustmentID)
c := record( adjustment.CountLine{ItemID: bolt, Counted: stocktest.Qty("3")}, adjustment.CountLine{ItemID: phone, SerialNo: "SN-1", Counted: stocktest.Qty("1")}, adjustment.CountLine{ItemID: phone, SerialNo: "SN-2", Counted: stocktest.Qty("0")}, adjustment.CountLine{ItemID: phone, SerialNo: "SN-3", Counted: stocktest.Qty("1")}, ) require.NotNil(t, c.AdjustmentID) stocktest.RequireQty(t, "5", c.Lines[0].System) var adj adjustment.Adjustment e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error adj, err = adjustment.Get(ctx, tx, *c.AdjustmentID) return err }) require.Len(t, adj.Lines, 3, "bolt out 2, SN-2 out, SN-3 in") require.Equal(t, &c.ID, adj.CountID) require.Equal(t, "draft", adj.State) for _, l := range adj.Lines { if l.Direction == inventory.DirectionIn { stocktest.RequireAmount(t, "100", l.UnitCost, "a found unit at the current average (R-O2-16)") } }
approve(t, e, stocktest.DocRef{ID: adj.ID, InstanceID: adj.InstanceID, Kind: inventory.KindStockAdjustment}) stocktest.RequireQty(t, "3", e.OnHand(t, bolt, e.WH1, "")) stocktest.RequireQty(t, "0", e.OnHand(t, phone, e.WH1, "SN-2")) stocktest.RequireQty(t, "1", e.OnHand(t, phone, e.WH1, "SN-3")) e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t)}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/adjustment/ -count=1
Expected: build failure (package adjustment does not exist).
- Step 3: Migration
db/migrations/00726_stock_count.sql:
-- +goose Up-- Phase 4 O2.4: stock-take. A count records, per item and lot at one-- location, what the books held and what was counted, once; the-- differences become a draft adjustment that names the count (R-O2-16).CREATE TABLE stock_count ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), location_id UUID NOT NULL, counted_on DATE NOT NULL, counted_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT stock_count_tenant_id UNIQUE (org_id, id), CONSTRAINT stock_count_location_fk FOREIGN KEY (org_id, location_id) REFERENCES location (org_id, id), CONSTRAINT stock_count_counted_by_fk FOREIGN KEY (org_id, counted_by) REFERENCES organization_member (org_id, id));CREATE TABLE stock_count_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), count_id UUID NOT NULL, item_id UUID NOT NULL, lot TEXT NOT NULL, system_quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_count_line_system_nonnegative CHECK (system_quantity >= 0), counted_quantity NUMERIC(19,6) NOT NULL CONSTRAINT stock_count_line_counted_nonnegative CHECK (counted_quantity >= 0), CONSTRAINT stock_count_line_once UNIQUE (org_id, count_id, item_id, lot), CONSTRAINT stock_count_line_count_fk FOREIGN KEY (org_id, count_id) REFERENCES stock_count (org_id, id), CONSTRAINT stock_count_line_item_fk FOREIGN KEY (org_id, item_id) REFERENCES item (org_id, id));ALTER TABLE stock_adjustment_detail ADD COLUMN count_id UUID, ADD CONSTRAINT stock_adjustment_detail_count_fk FOREIGN KEY (org_id, count_id) REFERENCES stock_count (org_id, id);
ALTER TABLE stock_count ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_count FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_count USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE stock_count_line ENABLE ROW LEVEL SECURITY;ALTER TABLE stock_count_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON stock_count_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON stock_count, stock_count_line FROM erp_app;
-- +goose DownALTER TABLE stock_adjustment_detail DROP COLUMN count_id;DROP TABLE stock_count_line;DROP TABLE stock_count;internal/kernel/db/retention_test.go: appendOnly gains "stock_count", "stock_count_line" (// Phase 4 O2.4: a stock count, what the books held and what was counted; written once.).
- Step 4:
reason.go
internal/platform/inventory/reason.go:
package inventory
import ( "context" "errors" "fmt" "regexp" "strings"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/i18n" "github.com/mcom/erp/internal/platform/provisioning")
// Adjustment line directions: the API's words for a line's sign (R-O2-12).const ( DirectionIn = "in" DirectionOut = "out")
var ( ErrReasonCode = errors.New("inventory: a reason code is lower-case letters, digits and underscores, starting with a letter") ErrReasonCodeTaken = errors.New("inventory: that reason code is already used") ErrReasonAccount = errors.New("inventory: a reason posts to an active, postable account that is neither a control account nor a module's own") ErrReasonName = errors.New("inventory: a reason is named in English at least") ErrReasonNotFound = errors.New("inventory: no such adjustment reason in this organization") ErrReasonInactive = errors.New("inventory: that adjustment reason is retired"))
const reasonEntity = "stock_adjustment_reason"
var reasonCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
type AdjustmentReason struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Name string `json:"name"` AccountID uuid.UUID `json:"account_id"` Active bool `json:"active"`}
type ReasonInput struct { Code string AccountID uuid.UUID Names map[string]string // locale → name; "en" required}
// CreateAdjustmentReason adds a reason and the account its adjustments post// against (R-O2-12). The account is an ordinary one: never a control// account nor a module's own (Inventory itself), so a reason cannot move// a subledger.func CreateAdjustmentReason(ctx context.Context, tx pgx.Tx, c clock.Clock, in ReasonInput) (AdjustmentReason, error) { org, err := stockOrg(ctx) if err != nil { return AdjustmentReason{}, err } if !reasonCode.MatchString(in.Code) { return AdjustmentReason{}, ErrReasonCode } if strings.TrimSpace(in.Names["en"]) == "" { return AdjustmentReason{}, ErrReasonName } a, err := chart.GetAccount(ctx, tx, in.AccountID) if errors.Is(err, chart.ErrNotFound) { return AdjustmentReason{}, ErrReasonAccount } if err != nil { return AdjustmentReason{}, err } if !a.IsPostable || !a.IsActive || a.OwnerModule != nil || a.ControlKind != nil { return AdjustmentReason{}, ErrReasonAccount } r := AdjustmentReason{Code: in.Code, Name: in.Names["en"], AccountID: a.ID, Active: true} err = tx.QueryRow(ctx, ` INSERT INTO stock_adjustment_reason (org_id, code, account_id, created_at) VALUES ($1, $2, $3, $4) RETURNING id`, org, r.Code, r.AccountID, c.Now()).Scan(&r.ID) var pg *pgconn.PgError if errors.As(err, &pg) && pg.ConstraintName == "stock_adjustment_reason_code_once" { return AdjustmentReason{}, ErrReasonCodeTaken } if err != nil { return AdjustmentReason{}, fmt.Errorf("inventory: create reason: %w", err) } for locale, name := range in.Names { if strings.TrimSpace(name) == "" { continue } if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, $2, $3, 'name', $4, $5)`, org, reasonEntity, r.ID, locale, name); err != nil { return AdjustmentReason{}, fmt.Errorf("inventory: reason name: %w", err) } } return r, audit.Write(ctx, tx, c, audit.Entry{EntityKind: reasonEntity, EntityID: r.ID, Action: "create", After: map[string]any{"code": r.Code, "account_id": r.AccountID, "names": in.Names}})}
// ListAdjustmentReasons is every reason, named in the request's locale.func ListAdjustmentReasons(ctx context.Context, tx pgx.Tx) ([]AdjustmentReason, error) { org, err := stockOrg(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT id, code, account_id, is_active FROM stock_adjustment_reason WHERE org_id = $1 ORDER BY code`, org) if err != nil { return nil, fmt.Errorf("inventory: reasons: %w", err) } var out []AdjustmentReason var ids []uuid.UUID for rows.Next() { var r AdjustmentReason if err := rows.Scan(&r.ID, &r.Code, &r.AccountID, &r.Active); err != nil { rows.Close() return nil, err } out = append(out, r) ids = append(ids, r.ID) } rows.Close() if err := rows.Err(); err != nil { return nil, err } names, err := i18n.Load(ctx, tx, reasonEntity, ids, "name") if err != nil { return nil, err } for i := range out { out[i].Name = out[i].Code if n, ok := names[out[i].ID]; ok { out[i].Name = n } } return out, nil}
// The adjustment reasons step, order 83 (R-O2-18): after the stock// accounts (81), whose Stock adjustment account (5400) the two seeded// reasons post to.const ( StepKeyAdjustmentReasons = "inventory_adjustment_reasons" StepOrderAdjustmentReasons = 83 adjustmentAccountCode = "5400")
func init() { provisioning.Register(reasonsStep{}) }
type reasonsStep struct{}
func (reasonsStep) Key() string { return StepKeyAdjustmentReasons }func (reasonsStep) Order() int { return StepOrderAdjustmentReasons }
// Check is done once any reason exists; with no usable 5400 it is done with// a warning, since re-running could not seed against it.func (reasonsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) { org, err := stockOrg(ctx) if err != nil { return false, nil, err } var exists bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM stock_adjustment_reason WHERE org_id = $1)`, org).Scan(&exists); err != nil { return false, nil, fmt.Errorf("inventory: reasons check: %w", err) } if exists { return true, nil, nil } a, err := chart.GetAccountByCode(ctx, tx, adjustmentAccountCode) if errors.Is(err, chart.ErrNotFound) || (err == nil && (a.Type != chart.TypeExpense || !a.IsPostable)) { return true, []provisioning.Warning{{Code: "adjustment_reason_account_missing", Detail: "Account 5400 is not a postable expense account; add adjustment reasons by hand."}}, nil } return false, nil, err}
func (reasonsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error { a, err := chart.GetAccountByCode(ctx, tx, adjustmentAccountCode) if err != nil { return fmt.Errorf("inventory: reasons: %w", err) } for _, in := range []ReasonInput{ {Code: "count", AccountID: a.ID, Names: map[string]string{"en": "Stock count difference", "ms": "Perbezaan kiraan stok", "zh": "盘点差异"}}, {Code: "damage", AccountID: a.ID, Names: map[string]string{"en": "Damaged or written off", "ms": "Rosak atau dihapus kira", "zh": "损坏或报废"}}, } { if _, err := CreateAdjustmentReason(ctx, tx, c, in); err != nil { return err } } return nil}(The step writes the second reason’s rows after the first’s audit: INSERTs of new rows only, as Task 2’s step.)
- Step 5:
adjustment.go
internal/platform/inventory/adjustment/adjustment.go:
// Package adjustment creates stock adjustments and records stock counts// (spec O2.3, O2.4). It is apart from package inventory because it creates// trade documents through O1's trade.Create, and inventory does not import// trade. Posting and moving stock are inventory's (Transition, the rules).package adjustment
import ( "context" "errors" "fmt" "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/tenancy" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/trade")
var ( ErrNoLines = errors.New("adjustment: an adjustment or a count has at least one line") ErrUnitCostOnOut = errors.New("adjustment: stock going out is costed by FIFO or the average; give no unit cost") ErrNegativeUnitCost = errors.New("adjustment: a unit cost is not negative") ErrDuplicateCountLine = errors.New("adjustment: an item and lot is counted once per count") ErrCountedNegative = errors.New("adjustment: a counted quantity is not negative"))
func orgOf(ctx context.Context) (uuid.UUID, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("adjustment: %w", tenancy.ErrNoOrg) } return org, nil}
type Input struct { LocationID uuid.UUID ReasonID uuid.UUID Reporter uuid.UUID CountID *uuid.UUID Lines []Line}
// Line is in the item's base UOM; UnitCost (base currency) only on "in".type Line struct { ItemID uuid.UUID Direction string Quantity money.Rate UnitCost money.Amount Lots []inventory.Lot}
// Create makes a draft stock adjustment (R-O2-12): a trade document in// base currency at the location (O1's trade.Create, which numbers it and// starts its flow), its reason, each line signed by its direction, and// lots. Its flow (submit → the Owner seat approves, effect) moves and// posts it through inventory.Transition.//// LOCK ORDER: trade.Create writes its audit row; the rows written after it// (the detail, the lots) are INSERTs of new rows whose only locks are FOR// KEY SHARE on rows this transaction wrote (the document, its lines) or// that nothing locks FOR UPDATE (the reason) — so the audit-last rule's// purpose holds.func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, in Input) (uuid.UUID, error) { org, err := orgOf(ctx) if err != nil { return uuid.Nil, err } if len(in.Lines) == 0 { return uuid.Nil, ErrNoLines } var active bool err = tx.QueryRow(ctx, `SELECT is_active FROM stock_adjustment_reason WHERE org_id = $1 AND id = $2`, org, in.ReasonID).Scan(&active) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, inventory.ErrReasonNotFound } if err != nil { return uuid.Nil, fmt.Errorf("adjustment: reason: %w", err) } if !active { return uuid.Nil, inventory.ErrReasonInactive } lines := make([]trade.LineInput, 0, len(in.Lines)) directions := make([]string, 0, len(in.Lines)) for _, l := range in.Lines { switch l.Direction { case inventory.DirectionIn: if l.UnitCost.IsNegative() { return uuid.Nil, ErrNegativeUnitCost } case inventory.DirectionOut: if !l.UnitCost.IsZero() { return uuid.Nil, ErrUnitCostOnOut } default: return uuid.Nil, inventory.ErrNoDirection } if !l.Quantity.IsPositive() { return uuid.Nil, inventory.ErrQuantityNotPositive } var typ string var uom uuid.UUID err := tx.QueryRow(ctx, `SELECT type, base_uom_id FROM item WHERE org_id = $1 AND id = $2`, org, l.ItemID).Scan(&typ, &uom) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, inventory.ErrItemNotFound } if err != nil { return uuid.Nil, fmt.Errorf("adjustment: item: %w", err) } if typ != "stock" { return uuid.Nil, inventory.ErrNotStockItem } item, qty := l.ItemID, l.Quantity if l.Direction == inventory.DirectionOut { qty = qty.Neg() // O1's trade_line_quantity_sign: an adjustment's sign is its direction } lines = append(lines, trade.LineInput{ItemID: &item, Quantity: qty, UOMID: uom, UnitPrice: l.UnitCost}) directions = append(directions, l.Direction) } p, err := company.Get(ctx, tx) if err != nil { return uuid.Nil, err } loc := in.LocationID doc, err := trade.Create(ctx, tx, c, in.Reporter, trade.DocumentInput{Kind: trade.KindAdjustment, Currency: p.BaseCurrency, LocationID: &loc, Lines: lines}) if err != nil { return uuid.Nil, err } if _, err := tx.Exec(ctx, ` INSERT INTO stock_adjustment_detail (org_id, document_id, reason_id, count_id) VALUES ($1, $2, $3, $4)`, org, doc.ID, in.ReasonID, in.CountID); err != nil { return uuid.Nil, fmt.Errorf("adjustment: detail: %w", err) } for i, dl := range doc.Lines { if len(in.Lines[i].Lots) > 0 { if err := inventory.SetLineLots(ctx, tx, dl.ID, in.Lines[i].Lots); err != nil { return uuid.Nil, err } } } return doc.ID, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "trade_document", EntityID: doc.ID, Action: "adjustment_details", After: map[string]any{"reason_id": in.ReasonID, "count_id": in.CountID, "directions": directions}})}
type Adjustment struct { ID uuid.UUID `json:"id"` Number string `json:"number"` State string `json:"state"` LocationID uuid.UUID `json:"location_id"` ReasonID uuid.UUID `json:"reason_id"` CountID *uuid.UUID `json:"count_id"` InstanceID uuid.UUID `json:"instance_id"` Lines []LineView `json:"lines"`}
type LineView struct { ID uuid.UUID `json:"id"` ItemID uuid.UUID `json:"item_id"` Direction string `json:"direction"` Quantity money.Rate `json:"quantity"` UnitCost money.Amount `json:"unit_cost"` Lots []inventory.Lot `json:"lots"`}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Adjustment, error) { org, err := orgOf(ctx) if err != nil { return Adjustment{}, err } var a Adjustment err = tx.QueryRow(ctx, ` SELECT d.id, d.number, w.state_key, d.location_id, s.reason_id, s.count_id, d.instance_id FROM trade_document d JOIN stock_adjustment_detail s ON s.org_id = d.org_id AND s.document_id = d.id JOIN workflow_instance w ON w.org_id = d.org_id AND w.id = d.instance_id WHERE d.org_id = $1 AND d.id = $2 AND d.kind = 'stock_adjustment'`, org, id). Scan(&a.ID, &a.Number, &a.State, &a.LocationID, &a.ReasonID, &a.CountID, &a.InstanceID) if errors.Is(err, pgx.ErrNoRows) { return Adjustment{}, inventory.ErrDocumentNotFound } if err != nil { return Adjustment{}, fmt.Errorf("adjustment: get: %w", err) } rows, err := tx.Query(ctx, ` SELECT l.id, l.item_id, CASE WHEN l.quantity_base < 0 THEN 'out' ELSE 'in' END, abs(l.quantity_base), l.unit_price FROM trade_line l WHERE l.org_id = $1 AND l.document_id = $2 AND l.withdrawn_at IS NULL ORDER BY l.line_no`, org, id) if err != nil { return Adjustment{}, fmt.Errorf("adjustment: lines: %w", err) } for rows.Next() { var l LineView if err := rows.Scan(&l.ID, &l.ItemID, &l.Direction, &l.Quantity, &l.UnitCost); err != nil { rows.Close() return Adjustment{}, err } a.Lines = append(a.Lines, l) } rows.Close() if err := rows.Err(); err != nil { return Adjustment{}, err } for i := range a.Lines { lots, err := tx.Query(ctx, ` SELECT coalesce(serial_no, ''), coalesce(batch_no, ''), expires_on, quantity FROM trade_line_lot WHERE org_id = $1 AND trade_line_id = $2 ORDER BY serial_no NULLS LAST, batch_no NULLS LAST`, org, a.Lines[i].ID) if err != nil { return Adjustment{}, err } a.Lines[i].Lots = []inventory.Lot{} for lots.Next() { var lot inventory.Lot if err := lots.Scan(&lot.SerialNo, &lot.BatchNo, &lot.ExpiresOn, &lot.Quantity); err != nil { lots.Close() return Adjustment{}, err } a.Lines[i].Lots = append(a.Lines[i].Lots, lot) } lots.Close() if err := lots.Err(); err != nil { return Adjustment{}, err } } return a, nil}
type CountInput struct { LocationID uuid.UUID ReasonID uuid.UUID Reporter uuid.UUID Lines []CountLine}
// CountLine counts one item (and serial or batch) at the location; a// serial is counted 0 or 1.type CountLine struct { ItemID uuid.UUID SerialNo, BatchNo string Counted money.Rate}
type Count struct { ID uuid.UUID `json:"id"` LocationID uuid.UUID `json:"location_id"` CountedOn time.Time `json:"-"` AdjustmentID *uuid.UUID `json:"adjustment_id"` Lines []CountedLine `json:"lines"`}
type CountedLine struct { ItemID uuid.UUID `json:"item_id"` Lot string `json:"lot"` System money.Rate `json:"system"` Counted money.Rate `json:"counted"`}
// RecordCount records a stock count and, when the books differ, a draft// adjustment for the differences: out at FIFO or average cost, in at the// current unit cost (R-O2-16).func RecordCount(ctx context.Context, tx pgx.Tx, c clock.Clock, in CountInput) (Count, error) { org, err := orgOf(ctx) if err != nil { return Count{}, err } if len(in.Lines) == 0 { return Count{}, ErrNoLines } count := Count{ID: uuid.New(), LocationID: in.LocationID, CountedOn: clock.Today(ctx, c)} if _, err := tx.Exec(ctx, ` INSERT INTO stock_count (id, org_id, location_id, counted_on, counted_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)`, count.ID, org, count.LocationID, count.CountedOn, in.Reporter, c.Now()); err != nil { return Count{}, fmt.Errorf("adjustment: count: %w", err) } seen := map[[2]string]bool{} var diffs []Line for _, l := range in.Lines { if l.Counted.IsNegative() { return Count{}, ErrCountedNegative } var typ, tracking string err := tx.QueryRow(ctx, `SELECT type, tracking FROM item WHERE org_id = $1 AND id = $2`, org, l.ItemID).Scan(&typ, &tracking) if errors.Is(err, pgx.ErrNoRows) { return Count{}, inventory.ErrItemNotFound } if err != nil { return Count{}, fmt.Errorf("adjustment: count item: %w", err) } if typ != "stock" { return Count{}, inventory.ErrNotStockItem } lot := l.SerialNo + l.BatchNo switch { case tracking == "serial" && (l.SerialNo == "" || l.BatchNo != "" || l.Counted.Cmp(money.OneRate()) > 0): return Count{}, inventory.ErrSerialRequired case tracking == "batch" && (l.BatchNo == "" || l.SerialNo != ""): return Count{}, inventory.ErrBatchRequired case tracking == "none" && lot != "": return Count{}, inventory.ErrUntrackedLot } key := [2]string{l.ItemID.String(), lot} if seen[key] { return Count{}, ErrDuplicateCountLine } seen[key] = true system := money.MustParseRate("0") err = tx.QueryRow(ctx, ` SELECT on_hand FROM stock_balance WHERE org_id = $1 AND item_id = $2 AND location_id = $3 AND lot = $4`, org, l.ItemID, in.LocationID, lot).Scan(&system) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Count{}, fmt.Errorf("adjustment: on hand: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO stock_count_line (org_id, count_id, item_id, lot, system_quantity, counted_quantity) VALUES ($1, $2, $3, $4, $5, $6)`, org, count.ID, l.ItemID, lot, system, l.Counted); err != nil { return Count{}, fmt.Errorf("adjustment: count line: %w", err) } count.Lines = append(count.Lines, CountedLine{ItemID: l.ItemID, Lot: lot, System: system, Counted: l.Counted}) diff := l.Counted.Sub(system) if diff.IsZero() { continue } d := Line{ItemID: l.ItemID, Direction: inventory.DirectionIn, Quantity: diff} if diff.IsNegative() { d.Direction, d.Quantity = inventory.DirectionOut, diff.Neg() } else if d.UnitCost, err = inventory.CurrentUnitCost(ctx, tx, l.ItemID); err != nil { return Count{}, err } switch tracking { case "serial": d.Lots = []inventory.Lot{{SerialNo: l.SerialNo, Quantity: d.Quantity}} case "batch": d.Lots = []inventory.Lot{{BatchNo: l.BatchNo, Quantity: d.Quantity}} } diffs = append(diffs, d) } if len(diffs) > 0 { id, err := Create(ctx, tx, c, Input{LocationID: in.LocationID, ReasonID: in.ReasonID, Reporter: in.Reporter, CountID: &count.ID, Lines: diffs}) if err != nil { return Count{}, err } count.AdjustmentID = &id } actor := in.Reporter return count, audit.Write(ctx, tx, c, audit.Entry{EntityKind: "stock_count", EntityID: count.ID, Action: "create", ActorID: &actor, After: count})}Run: make generate && git status --short
- Step 6: Run the tests to verify they pass
Run: go test ./internal/platform/inventory/... ./internal/kernel/db/ ./internal/kernel/layering/ ./internal/app/ -count=1
Expected: PASS.
- Step 7: Sabotage
In CreateAdjustmentReason, drop a.OwnerModule != nil ||; run go test ./internal/platform/inventory/adjustment/ -run TestProvisioningSeedsReasons -count=1; read the red; restore.
- Step 8: Commit
git add db/migrations/00726_stock_count.sql internal/git commit -m "Phase 4 O2: adjustment reasons, adjustments approved by the Owner, stock-take
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 10: Valuation equals the ledger — the integrity check
Section titled “Task 10: Valuation equals the ledger — the integrity check”Files:
- Create:
internal/platform/inventory/integrity.go,internal/platform/inventory/integrity_test.go - Modify:
internal/operator/orgviews.go(runbook),docs/RUNBOOK.md(§7.3)
Interfaces:
-
Consumes:
item_cost(Task 6), the stock postings (Task 7),stocktest.ItemOn(Task 3). -
Produces:
inventory.KeyStockValuation = "stock_valuation"registered inreconcile.Default();inventory.AccountValuation{AccountID uuid.UUID; Code string; Stock, Ledger money.Amount};inventory.ValuationByAccount(ctx, tx) ([]AccountValuation, error). -
Step 1: Write the failing tests
internal/platform/inventory/integrity_test.go:
package inventory_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/accounting/reconcile" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/stocktest")
// valuationResult runs the whole reconciliation set and returns// stock_valuation's result.func valuationResult(t *testing.T, e *stocktest.Env) reconcile.CheckResult { t.Helper() var out reconcile.CheckResult e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { res, err := reconcile.Default().Run(ctx, tx) if err != nil { return err } for _, c := range res.Checks { if c.Key == inventory.KeyStockValuation { out = c } } return nil }) require.Equal(t, inventory.KeyStockValuation, out.Key, "the check is registered") return out}
func TestStockValuationEqualsTheInventoryLedger(t *testing.T) { for _, method := range []string{inventory.CostingWeightedAverage, inventory.CostingFIFO} { t.Run(method, func(t *testing.T) { e := stocktest.New(t) e.UseCosting(t, method) bolt := e.Item(t, "BOLT", "stock", "none") phone := e.Item(t, "PHONE", "stock", "serial") sup, cust := e.SupplierParty(t), e.CustomerParty(t) sn := func(s string) inventory.Lot { return inventory.Lot{SerialNo: s, Quantity: stocktest.Qty("1")} }
r := e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.35"), stocktest.DocLine{Item: phone, Qty: "2", Price: "333.33", Lots: []inventory.Lot{sn("SN-1"), sn("SN-2")}}) e.Post(t, r) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "7", "2.10"))) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "6", "9.00"), stocktest.DocLine{Item: phone, Qty: "1", Price: "999.00", Lots: []inventory.Lot{sn("SN-1")}}) e.Post(t, d) e.Post(t, e.Doc(t, inventory.KindSalesReturn, &cust, "MYR", stocktest.DocLine{Item: bolt, Qty: "1", Price: "9.00", Source: &d.Lines[0]})) e.Post(t, e.Doc(t, inventory.KindPurchaseReturn, &sup, "MYR", stocktest.DocLine{Item: bolt, Qty: "2", Price: "1.35", Source: &r.Lines[0]})) cancelled := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "3", "9.00")) e.Post(t, cancelled) e.Cancel(t, cancelled)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { rows, err := inventory.ValuationByAccount(ctx, tx) require.NoError(t, err) require.NotEmpty(t, rows, "PRESENCE: the check compares at least the Inventory account") for _, a := range rows { if a.AccountID == e.InventoryAccount { require.True(t, a.Stock.IsPositive(), "PRESENCE: stock is held") } require.Zero(t, a.Stock.Cmp(a.Ledger), "account %s: stock %s, ledger %s", a.Code, a.Stock, a.Ledger) } return nil }) require.True(t, valuationResult(t, e).Passed) e.RequireBalancesAddUp(t) e.RequireCostsAddUp(t) }) }}
func TestAManualJournalOnAnInventoryAccountFailsTheCheck(t *testing.T) { e := stocktest.New(t) prepaid := e.Account(t, "1570") // an ordinary asset: manual lines may move it bolt := e.ItemOn(t, "BOLT", "stock", "none", prepaid) sup := e.SupplierParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.00"))) require.True(t, valuationResult(t, e).Passed, "PRESENCE: the books agree before the journal")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { capital, err := chart.GetAccountByCode(ctx, tx, "3100") if err != nil { return err } owner := e.Owner _, err = ledger.Post(ctx, tx, e.Clock, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: expensestest.Today, Description: "Stock found", Semantic: ledger.SemanticManual, PostedBy: &owner, Lines: []ledger.Line{{AccountID: prepaid, Debit: stocktest.Amt("5")}, {AccountID: capital.ID, Credit: stocktest.Amt("5")}}}) return err }) got := valuationResult(t, e) require.False(t, got.Passed) require.Len(t, got.Findings, 1) require.Equal(t, prepaid, got.Findings[0].Ref) require.Contains(t, got.Findings[0].Detail, "10.0000") require.Contains(t, got.Findings[0].Detail, "15.0000")}- Step 2: Run the tests to verify they fail
Run: go test ./internal/platform/inventory/ -run 'Valuation|ManualJournal' -count=1
Expected: build failure undefined: inventory.KeyStockValuation.
- Step 3:
integrity.go
package inventory
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/reconcile")
// KeyStockValuation is spec O2.5: stock valuation equals the GL balance of// the inventory accounts. Nightly, and on the operator console's "Run// checks now" (opsjobs records its pass/fail and finding count).const KeyStockValuation = "stock_valuation"
func init() { reconcile.Register(valuationCheck{}) }
// AccountValuation is one inventory account: the value of the items that// name it (item_cost) beside its ledger balance in base.type AccountValuation struct { AccountID uuid.UUID Code string Stock, Ledger money.Amount}
// ValuationByAccount compares every account an item names as its inventory// account, and every account module inventory owns.// ponytail: sums journal_line per account on every run; add a per-account// running balance if the nightly check gets slow.func ValuationByAccount(ctx context.Context, tx pgx.Tx) ([]AccountValuation, error) { org, err := stockOrg(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` WITH valued AS ( SELECT i.inventory_account_id AS account_id, sum(c.value) AS value FROM item_cost c JOIN item i ON i.org_id = c.org_id AND i.id = c.item_id WHERE c.org_id = $1 AND i.inventory_account_id IS NOT NULL GROUP BY 1), booked AS ( SELECT l.account_id, sum(l.base_debit - l.base_credit) AS balance FROM journal_line l WHERE l.org_id = $1 GROUP BY 1) SELECT a.id, a.code, coalesce(v.value, 0), coalesce(b.balance, 0) FROM account a LEFT JOIN valued v ON v.account_id = a.id LEFT JOIN booked b ON b.account_id = a.id WHERE a.org_id = $1 AND (a.owner_module = 'inventory' OR EXISTS (SELECT 1 FROM item i2 WHERE i2.org_id = a.org_id AND i2.inventory_account_id = a.id)) ORDER BY a.code`, org) if err != nil { return nil, fmt.Errorf("inventory: valuation: %w", err) } defer rows.Close() var out []AccountValuation for rows.Next() { var a AccountValuation if err := rows.Scan(&a.AccountID, &a.Code, &a.Stock, &a.Ledger); err != nil { return nil, err } out = append(out, a) } return out, rows.Err()}
type valuationCheck struct{}
func (valuationCheck) Key() string { return KeyStockValuation }
func (valuationCheck) Run(ctx context.Context, tx pgx.Tx) ([]reconcile.Finding, error) { rows, err := ValuationByAccount(ctx, tx) if err != nil { return nil, err } var findings []reconcile.Finding for _, a := range rows { if a.Stock.Cmp(a.Ledger) != 0 { findings = append(findings, reconcile.Finding{Check: KeyStockValuation, Ref: a.AccountID, Detail: fmt.Sprintf( "account %s: stock is valued at %s, the ledger holds %s", a.Code, a.Stock, a.Ledger)}) } } return findings, nil}internal/operator/orgviews.go, in runbook, after "run_failed": "7.3", "not_checked": "7.3",:
"stock_valuation": "7.3", // internal/platform/inventory.KeyStockValuation (a literal: the console imports no module)docs/RUNBOOK.md §7.3, after the audit_chain bullet:
- `stock_valuation`: an inventory account's ledger balance differs from the value of the stock that names it. Usually a manual journal on an inventory account the organization chose itself (the provisioned Inventory account refuses manual lines). The organization's accountant reverses that journal, or posts a stock adjustment for the difference.- Step 4: Run the tests to verify they pass
Run: go test ./internal/platform/inventory/ ./internal/operator/ -count=1
Expected: PASS (TestEveryDetailCodeHasARunbookEntry included).
- Step 5: Sabotage
In effectRule.Build, post cost with money.MustParse("0.0001") added to the Inventory side’s debit and the counter’s credit on every positive line (a drift both sides still balance); run -run TestStockValuationEquals; read the red; restore.
- Step 6: Commit
git add internal/platform/inventory/integrity.go internal/platform/inventory/integrity_test.go internal/operator/orgviews.go docs/RUNBOOK.mdgit commit -m "Phase 4 O2: stock valuation equals the inventory ledger (spec O2.5)
Sabotage: <quote the red message>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 11: Stock reports — on hand, movements, valuation
Section titled “Task 11: Stock reports — on hand, movements, valuation”Files:
- Create:
internal/platform/inventory/report.go,internal/platform/inventory/report_test.go
Interfaces:
-
Consumes:
stock_balance,stock_move(Task 3),TransferStock(Task 8). -
Produces:
inventory.OnHandRow,inventory.OnHand(ctx, tx, locationID, itemID *uuid.UUID) ([]OnHandRow, error);inventory.MovementFilter{From, To time.Time; ItemID, LocationID *uuid.UUID},inventory.MovementRow,inventory.Movements(ctx, tx, MovementFilter) ([]MovementRow, error),inventory.MaxMovementRows = 5000,inventory.ErrTooManyRows;inventory.ValuationRow,inventory.Valuation{AsOf time.Time; Currency string; Rows []ValuationRow; Total money.Amount},inventory.StockValuation(ctx, tx, asOf time.Time) (Valuation, error). -
Step 1: Write the failing test
internal/platform/inventory/report_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" "github.com/mcom/erp/internal/platform/inventory/stocktest")
func TestStockReportsReadTheLedger(t *testing.T) { e := stocktest.New(t) bolt := e.Item(t, "BOLT", "stock", "none") sup, cust := e.SupplierParty(t), e.CustomerParty(t) e.Post(t, e.Doc(t, inventory.KindGoodsReceipt, &sup, "MYR", stocktest.Line(bolt, "10", "1.50"))) require.NoError(t, e.DB.InTx(e.As(e.Staff), func(ctx context.Context, tx pgx.Tx) error { _, err := inventory.TransferStock(ctx, tx, e.Clock, inventory.TransferInput{ItemID: bolt, From: e.WH1, To: e.WH2, Quantity: stocktest.Qty("3"), Actor: e.Staff}) return err })) d := e.Doc(t, inventory.KindDeliveryOrder, &cust, "MYR", stocktest.Line(bolt, "4", "9.00")) e.Post(t, d)
today, yesterday := expensestest.Today, expensestest.Today.AddDate(0, 0, -1) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { all, err := inventory.OnHand(ctx, tx, nil, nil) require.NoError(t, err) require.Len(t, all, 2) stocktest.RequireQty(t, "3", all[0].OnHand, "WH1: 10 − 3 − 4") stocktest.RequireQty(t, "3", all[1].OnHand, "WH2") wh2, err := inventory.OnHand(ctx, tx, &e.WH2, nil) require.NoError(t, err) require.Len(t, wh2, 1)
moves, err := inventory.Movements(ctx, tx, inventory.MovementFilter{From: today, To: today, ItemID: &bolt}) require.NoError(t, err) require.Len(t, moves, 4, "receipt in, transfer out and in, delivery out") last := moves[3] require.Equal(t, inventory.KindDeliveryOrder, last.DocumentKind) require.NotEmpty(t, last.DocumentNumber) stocktest.RequireAmount(t, "-6", last.TotalCost) require.NotNil(t, moves[1].TransferID)
v, err := inventory.StockValuation(ctx, tx, today) require.NoError(t, err) require.Len(t, v.Rows, 1) stocktest.RequireQty(t, "6", v.Rows[0].Quantity) stocktest.RequireAmount(t, "9", v.Rows[0].Value) stocktest.RequireAmount(t, "1.5", v.Rows[0].UnitCost) stocktest.RequireAmount(t, "9", v.Total) require.Equal(t, "MYR", v.Currency)
before, err := inventory.StockValuation(ctx, tx, yesterday) require.NoError(t, err) require.Empty(t, before.Rows, "nothing was held the day before") return nil }) _, v := e.ItemCost(t, bolt) stocktest.RequireAmount(t, "9", v, "the report agrees with item_cost")}- Step 2: Run the test to verify it fails
Run: go test ./internal/platform/inventory/ -run TestStockReports -count=1
Expected: build failure undefined: inventory.OnHand.
- Step 3:
report.go
package inventory
import ( "context" "errors" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/company" "github.com/mcom/erp/internal/platform/i18n")
// MaxMovementRows caps one movements request (R-O2-14).const MaxMovementRows = 5000
var ErrTooManyRows = errors.New("inventory: more than 5,000 movements; narrow the dates, the item or the location")
// itemNames is each item's name in the request's locale, else its code.func itemNames(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]string, error) { return i18n.Load(ctx, tx, "item", ids, "name")}
func nameOr(names map[uuid.UUID]string, id uuid.UUID, code string) string { if n, ok := names[id]; ok { return n } return code}
type OnHandRow struct { ItemID uuid.UUID `json:"item_id"` ItemCode string `json:"item_code"` ItemName string `json:"item_name"` LocationID uuid.UUID `json:"location_id"` LocationCode string `json:"location_code"` Lot string `json:"lot"` OnHand money.Rate `json:"on_hand"`}
// OnHand is every positive balance, by item code, location code and lot,// optionally narrowed to one location or item.func OnHand(ctx context.Context, tx pgx.Tx, locationID, itemID *uuid.UUID) ([]OnHandRow, error) { org, err := stockOrg(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT b.item_id, i.code, b.location_id, l.code, b.lot, b.on_hand FROM stock_balance b JOIN item i ON i.org_id = b.org_id AND i.id = b.item_id JOIN location l ON l.org_id = b.org_id AND l.id = b.location_id WHERE b.org_id = $1 AND b.on_hand > 0 AND ($2::uuid IS NULL OR b.location_id = $2) AND ($3::uuid IS NULL OR b.item_id = $3) ORDER BY i.code, l.code, b.lot`, org, locationID, itemID) if err != nil { return nil, fmt.Errorf("inventory: on hand: %w", err) } out := []OnHandRow{} var ids []uuid.UUID for rows.Next() { var r OnHandRow if err := rows.Scan(&r.ItemID, &r.ItemCode, &r.LocationID, &r.LocationCode, &r.Lot, &r.OnHand); err != nil { rows.Close() return nil, err } out = append(out, r) ids = append(ids, r.ItemID) } rows.Close() if err := rows.Err(); err != nil { return nil, err } names, err := itemNames(ctx, tx, ids) if err != nil { return nil, err } for i := range out { out[i].ItemName = nameOr(names, out[i].ItemID, out[i].ItemCode) } return out, nil}
type MovementFilter struct { From, To time.Time ItemID, LocationID *uuid.UUID}
type MovementRow struct { MoveID uuid.UUID MoveDate time.Time ItemID uuid.UUID ItemCode string LocationID uuid.UUID LocationCode string Quantity money.Rate UnitCost money.Amount TotalCost money.Amount SerialNo string BatchNo string DocumentID *uuid.UUID DocumentKind string DocumentNumber string TransferID *uuid.UUID Reversal bool}
// Movements is every move dated From..To, in posting order, with the// document or transfer behind it.func Movements(ctx context.Context, tx pgx.Tx, f MovementFilter) ([]MovementRow, error) { org, err := stockOrg(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT m.id, m.move_date, m.item_id, i.code, m.location_id, l.code, m.quantity, m.unit_cost, m.total_cost, coalesce(m.serial_no, ''), coalesce(m.batch_no, ''), d.id, coalesce(d.kind, ''), coalesce(d.number, ''), m.transfer_id, m.reverses_move_id IS NOT NULL FROM stock_move m JOIN item i ON i.org_id = m.org_id AND i.id = m.item_id JOIN location l ON l.org_id = m.org_id AND l.id = m.location_id LEFT JOIN trade_line t ON t.org_id = m.org_id AND t.id = m.trade_line_id LEFT JOIN trade_document d ON d.org_id = t.org_id AND d.id = t.document_id WHERE m.org_id = $1 AND m.move_date BETWEEN $2 AND $3 AND ($4::uuid IS NULL OR m.item_id = $4) AND ($5::uuid IS NULL OR m.location_id = $5) ORDER BY m.move_date, m.seq LIMIT $6`, org, f.From, f.To, f.ItemID, f.LocationID, MaxMovementRows+1) if err != nil { return nil, fmt.Errorf("inventory: movements: %w", err) } defer rows.Close() out := []MovementRow{} for rows.Next() { var r MovementRow if err := rows.Scan(&r.MoveID, &r.MoveDate, &r.ItemID, &r.ItemCode, &r.LocationID, &r.LocationCode, &r.Quantity, &r.UnitCost, &r.TotalCost, &r.SerialNo, &r.BatchNo, &r.DocumentID, &r.DocumentKind, &r.DocumentNumber, &r.TransferID, &r.Reversal); err != nil { return nil, err } out = append(out, r) } if err := rows.Err(); err != nil { return nil, err } if len(out) > MaxMovementRows { return nil, ErrTooManyRows } return out, nil}
type ValuationRow struct { ItemID uuid.UUID ItemCode string ItemName string AccountID *uuid.UUID Quantity money.Rate UnitCost money.Amount Value money.Amount}
type Valuation struct { AsOf time.Time Currency string Rows []ValuationRow Total money.Amount}
// StockValuation is each item's quantity and value at the end of asOf, the// sum of its moves to then (moves are never back-dated, so a past date's// figure never changes).func StockValuation(ctx context.Context, tx pgx.Tx, asOf time.Time) (Valuation, error) { org, err := stockOrg(ctx) if err != nil { return Valuation{}, err } p, err := company.Get(ctx, tx) if err != nil { return Valuation{}, err } v := Valuation{AsOf: asOf, Currency: p.BaseCurrency, Rows: []ValuationRow{}, Total: money.Zero()} rows, err := tx.Query(ctx, ` SELECT m.item_id, i.code, i.inventory_account_id, sum(m.quantity), sum(m.total_cost) FROM stock_move m JOIN item i ON i.org_id = m.org_id AND i.id = m.item_id WHERE m.org_id = $1 AND m.move_date <= $2 GROUP BY m.item_id, i.code, i.inventory_account_id HAVING sum(m.quantity) <> 0 OR sum(m.total_cost) <> 0 ORDER BY i.code`, org, asOf) if err != nil { return Valuation{}, fmt.Errorf("inventory: valuation: %w", err) } var ids []uuid.UUID for rows.Next() { var r ValuationRow if err := rows.Scan(&r.ItemID, &r.ItemCode, &r.AccountID, &r.Quantity, &r.Value); err != nil { rows.Close() return Valuation{}, err } r.UnitCost = money.Zero() if r.Quantity.IsPositive() { if r.UnitCost, err = r.Value.MulDiv(money.OneRate(), r.Quantity); err != nil { rows.Close() return Valuation{}, err } } v.Rows = append(v.Rows, r) v.Total = v.Total.Add(r.Value) ids = append(ids, r.ItemID) } rows.Close() if err := rows.Err(); err != nil { return Valuation{}, err } names, err := itemNames(ctx, tx, ids) if err != nil { return Valuation{}, err } for i := range v.Rows { v.Rows[i].ItemName = nameOr(names, v.Rows[i].ItemID, v.Rows[i].ItemCode) } return v, nil}- Step 4: Run the test to verify it passes
Run: go test ./internal/platform/inventory/ -run TestStockReports -count=1
Expected: PASS.
- Step 5: Commit
git add internal/platform/inventory/report.go internal/platform/inventory/report_test.gogit commit -m "Phase 4 O2: stock on hand, movements and valuation reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 12: The HTTP surface — permissions, routes, contract, grants
Section titled “Task 12: The HTTP surface — permissions, routes, contract, grants”Files:
- Create:
db/migrations/00727_inventory_stock_grants.sql - Create:
internal/modules/inventory/stock_permissions.go,internal/modules/inventory/stock_boot.go,internal/modules/inventory/stock_handlers.go,internal/modules/inventory/stock_routes_test.go - Modify:
internal/modules/inventory/routes.go(O1’sRegistercallsregisterStock),internal/modules/inventory/errors.go(O1’smapErrcallsstockProblemfirst) - Modify:
api/openapi.yaml(twelve operations, schemas, responses)
Interfaces:
- Consumes: every exported function of Tasks 4, 6, 8, 9, 11; O1’s module (
handlers,Deps,permissionLoader,decodeStrict,mapErr,PermissionItemRead);*trade.FieldError(withField),trade.ErrNotFound. - Produces: permissions
inventory.adjustment.read,inventory.adjustment.create,inventory.adjustment.transition,inventory.transfer.create; routes:
| Route | Permission |
|---|---|
GET /inventory/stock/on-hand |
inventory.item.read |
GET /inventory/stock/movements |
inventory.item.read |
GET /inventory/stock/valuation |
inventory.item.read |
POST /inventory/transfers |
inventory.transfer.create |
GET /inventory/costing-method |
inventory.item.read |
PUT /inventory/costing-method |
org.profile.manage |
GET /inventory/adjustment-reasons |
inventory.adjustment.read |
POST /inventory/adjustment-reasons |
accounting.account.manage |
POST /inventory/adjustments |
inventory.adjustment.create |
GET /inventory/adjustments/{id} |
inventory.adjustment.read |
POST /inventory/adjustments/{id}/transitions |
inventory.adjustment.transition |
POST /inventory/stock-counts |
inventory.adjustment.create |
- Step 1: Write the failing test
internal/modules/inventory/stock_routes_test.go:
package inventory_test
import ( "testing"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/inventory" "github.com/mcom/erp/internal/platform/accounting" platforminventory "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/posting")
// TestTheStockRoutesRequireTheirExactPermission pins O2's twelve routes// (O1's module has no route-pin test of its own; internal/app's// TestEveryRouteIsAuthorized covers the whole router).func TestTheStockRoutesRequireTheirExactPermission(t *testing.T) { gin.SetMode(gin.TestMode) httpx.ResetAuthorizedRoutes() _, api := httpx.NewRouter(httpx.Deps{}) inventory.Register(api, inventory.Deps{})
read := inventory.PermissionItemRead want := map[string]string{ "GET /v1/inventory/stock/on-hand": read, "GET /v1/inventory/stock/movements": read, "GET /v1/inventory/stock/valuation": read, "POST /v1/inventory/transfers": inventory.PermissionTransferCreate, "GET /v1/inventory/costing-method": read, "PUT /v1/inventory/costing-method": "org.profile.manage", "GET /v1/inventory/adjustment-reasons": inventory.PermissionAdjustmentRead, "POST /v1/inventory/adjustment-reasons": "accounting.account.manage", "POST /v1/inventory/adjustments": inventory.PermissionAdjustmentCreate, "GET /v1/inventory/adjustments/:id": inventory.PermissionAdjustmentRead, "POST /v1/inventory/adjustments/:id/transitions": inventory.PermissionAdjustmentTransition, "POST /v1/inventory/stock-counts": inventory.PermissionAdjustmentCreate, } got := httpx.AuthorizedRoutes() for route, perm := range want { require.Equal(t, perm, got[route], route) } require.Equal(t, "inventory.adjustment.read", inventory.PermissionAdjustmentRead) require.Equal(t, "inventory.adjustment.create", inventory.PermissionAdjustmentCreate) require.Equal(t, "inventory.adjustment.transition", inventory.PermissionAdjustmentTransition) require.Equal(t, "inventory.transfer.create", inventory.PermissionTransferCreate)}
// TestTheStockRulesAreRegisteredAtBoot: importing the module registers// every rule of platform inventory into the process's rule set.func TestTheStockRulesAreRegisteredAtBoot(t *testing.T) { for _, r := range platforminventory.Rules() { _, ok := accounting.DefaultRules().Lookup(r.Kind(), r.Semantic()) require.True(t, ok, "%s on %s", r.Kind(), r.Semantic()) } _, ok := accounting.DefaultRules().Lookup(platforminventory.KindDeliveryOrder, posting.Effect) require.True(t, ok)}- Step 2: Run the test to verify it fails
Run: go test ./internal/modules/inventory/ -count=1
Expected: build failure undefined: inventory.PermissionTransferCreate.
- Step 3: Permissions, boot, grants
internal/modules/inventory/stock_permissions.go:
package inventory
import "github.com/mcom/erp/internal/platform/identity/member"
// O2's keys (spec O3.3's inventory.adjustment.* and inventory.transfer.*).// Stock reads are inventory.item.read (O1's key); the costing method is// org.profile.manage and a reason's account accounting.account.manage,// both registered by their own modules.const ( PermissionAdjustmentRead = "inventory.adjustment.read" PermissionAdjustmentCreate = "inventory.adjustment.create" PermissionAdjustmentTransition = "inventory.adjustment.transition" PermissionTransferCreate = "inventory.transfer.create"
permissionProfileManage = "org.profile.manage" permissionAccountManage = "accounting.account.manage")
func init() { member.RegisterPermission(PermissionAdjustmentRead, "Read stock adjustments and adjustment reasons") member.RegisterPermission(PermissionAdjustmentCreate, "Create stock adjustments and record stock counts") member.RegisterPermission(PermissionAdjustmentTransition, "Submit, approve, reject and cancel stock adjustments, as their workflow grants") member.RegisterPermission(PermissionTransferCreate, "Transfer stock between locations")}(A new Owner is seeded with every registered key by provisioning; no role gets them by RegisterRolePermissions — who approves an adjustment is the workflow’s grant, not the key.)
internal/modules/inventory/stock_boot.go:
package inventory
import ( "github.com/mcom/erp/internal/platform/accounting" platforminventory "github.com/mcom/erp/internal/platform/inventory")
// One loop over Rules(): registering only some of them is a transition// that commits a state change it cannot post.func init() { for _, r := range platforminventory.Rules() { accounting.RegisterPostingRule(r) }}db/migrations/00727_inventory_stock_grants.sql:
-- +goose Up-- Phase 4 O2: an existing organization's Owner gets the four stock keys a-- new Owner is seeded with (the pattern of 00676). 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.adjustment.read', 'inventory.adjustment.create', 'inventory.adjustment.transition', 'inventory.transfer.create']) AS k WHERE r.system AND r.name = 'Owner'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 = 'Owner');
-- +goose DownDELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name = 'Owner' AND rp.permission_key IN ('inventory.adjustment.read', 'inventory.adjustment.create', 'inventory.adjustment.transition', 'inventory.transfer.create');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 = 'Owner');- Step 4: Handlers and routes
internal/modules/inventory/stock_handlers.go:
package inventory
import ( "context" "errors" "fmt" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/company" p "github.com/mcom/erp/internal/platform/inventory" "github.com/mcom/erp/internal/platform/inventory/adjustment" "github.com/mcom/erp/internal/platform/trade" "github.com/mcom/erp/internal/platform/workflow")
// registerStock hangs O2's routes off the group; Register calls it.func registerStock(g *gin.RouterGroup, pl httpx.PermissionLoader, h handlers) { read := PermissionItemRead httpx.Protected(g, pl, "GET", "/inventory/stock/on-hand", read, h.getStockOnHand) httpx.Protected(g, pl, "GET", "/inventory/stock/movements", read, h.getStockMovements) httpx.Protected(g, pl, "GET", "/inventory/stock/valuation", read, h.getStockValuation) httpx.Protected(g, pl, "POST", "/inventory/transfers", PermissionTransferCreate, h.createTransfer) httpx.Protected(g, pl, "GET", "/inventory/costing-method", read, h.getCostingMethod) httpx.Protected(g, pl, "PUT", "/inventory/costing-method", permissionProfileManage, h.putCostingMethod) httpx.Protected(g, pl, "GET", "/inventory/adjustment-reasons", PermissionAdjustmentRead, h.listAdjustmentReasons) httpx.Protected(g, pl, "POST", "/inventory/adjustment-reasons", permissionAccountManage, h.createAdjustmentReason) httpx.Protected(g, pl, "POST", "/inventory/adjustments", PermissionAdjustmentCreate, h.createAdjustment) httpx.Protected(g, pl, "GET", "/inventory/adjustments/:id", PermissionAdjustmentRead, h.getAdjustment) httpx.Protected(g, pl, "POST", "/inventory/adjustments/:id/transitions", PermissionAdjustmentTransition, h.transitionAdjustment) httpx.Protected(g, pl, "POST", "/inventory/stock-counts", PermissionAdjustmentCreate, h.createStockCount)}
const stockDate = "2006-01-02"
func stockActor(c *gin.Context) (uuid.UUID, error) { a, ok := audit.ActorFromContext(c.Request.Context()) if !ok { return uuid.Nil, apperr.New(apperr.KindUnauthorized, "no authenticated member") } return a.MemberID, nil}
func stockUUIDQuery(c *gin.Context, name string) (*uuid.UUID, error) { s := c.Query(name) if s == "" { return nil, nil } id, err := uuid.Parse(s) if err != nil { return nil, apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=uuid: not an id", name)) } return &id, nil}
func stockDateQuery(c *gin.Context, name string, fallback time.Time) (time.Time, error) { s := c.Query(name) if s == "" { return fallback, nil } d, err := time.Parse(stockDate, s) if err != nil { return time.Time{}, apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=date: YYYY-MM-DD", name)) } return d, nil}
func stockPathID(c *gin.Context) (uuid.UUID, error) { id, err := uuid.Parse(c.Param("id")) if err != nil { return uuid.Nil, apperr.New(apperr.KindNotFound, "no such record in this organization") } return id, nil}
// lotBody is a Lot on the wire: the expiry is a date.type lotBody struct { SerialNo string `json:"serial_no"` BatchNo string `json:"batch_no"` ExpiresOn *string `json:"expires_on"` Quantity money.Rate `json:"quantity"`}
func lotsFrom(in []lotBody) ([]p.Lot, error) { out := make([]p.Lot, 0, len(in)) for _, b := range in { lot := p.Lot{SerialNo: b.SerialNo, BatchNo: b.BatchNo, Quantity: b.Quantity} if b.ExpiresOn != nil { d, err := time.Parse(stockDate, *b.ExpiresOn) if err != nil { return nil, apperr.New(apperr.KindValidation, "field=expires_on rule=date: YYYY-MM-DD") } lot.ExpiresOn = &d } out = append(out, lot) } return out, nil}
func lotBodies(in []p.Lot) []lotBody { out := make([]lotBody, 0, len(in)) for _, l := range in { b := lotBody{SerialNo: l.SerialNo, BatchNo: l.BatchNo, Quantity: l.Quantity} if l.ExpiresOn != nil { s := l.ExpiresOn.Format(stockDate) b.ExpiresOn = &s } out = append(out, b) } return out}
func (h handlers) getStockOnHand(c *gin.Context) { loc, err := stockUUIDQuery(c, "location_id") if err != nil { _ = c.Error(err) return } item, err := stockUUIDQuery(c, "item_id") if err != nil { _ = c.Error(err) return } var rows []p.OnHandRow err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = p.OnHand(ctx, tx, loc, item) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"rows": rows})}
type movementDTO struct { MoveID uuid.UUID `json:"move_id"` MoveDate string `json:"move_date"` ItemID uuid.UUID `json:"item_id"` ItemCode string `json:"item_code"` LocationID uuid.UUID `json:"location_id"` LocationCode string `json:"location_code"` Quantity money.Rate `json:"quantity"` UnitCost money.Amount `json:"unit_cost"` TotalCost money.Amount `json:"total_cost"` SerialNo string `json:"serial_no"` BatchNo string `json:"batch_no"` DocumentID *uuid.UUID `json:"document_id"` DocumentKind string `json:"document_kind"` DocumentNumber string `json:"document_number"` TransferID *uuid.UUID `json:"transfer_id"` Reversal bool `json:"reversal"`}
func (h handlers) getStockMovements(c *gin.Context) { var f p.MovementFilter var err error if f.ItemID, err = stockUUIDQuery(c, "item_id"); err != nil { _ = c.Error(err) return } if f.LocationID, err = stockUUIDQuery(c, "location_id"); err != nil { _ = c.Error(err) return } var rows []p.MovementRow err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { today := clock.Today(ctx, h.deps.Clock) var err error if f.From, err = stockDateQuery(c, "from", today.AddDate(0, -1, 0)); err != nil { return err } if f.To, err = stockDateQuery(c, "to", today); err != nil { return err } rows, err = p.Movements(ctx, tx, f) return err }) if err != nil { _ = c.Error(mapErr(err)) return } out := make([]movementDTO, 0, len(rows)) for _, r := range rows { out = append(out, movementDTO{r.MoveID, r.MoveDate.Format(stockDate), r.ItemID, r.ItemCode, r.LocationID, r.LocationCode, r.Quantity, r.UnitCost, r.TotalCost, r.SerialNo, r.BatchNo, r.DocumentID, r.DocumentKind, r.DocumentNumber, r.TransferID, r.Reversal}) } c.JSON(http.StatusOK, gin.H{"from": f.From.Format(stockDate), "to": f.To.Format(stockDate), "rows": out})}
type valuationRowDTO struct { ItemID uuid.UUID `json:"item_id"` ItemCode string `json:"item_code"` ItemName string `json:"item_name"` AccountID *uuid.UUID `json:"account_id"` Quantity money.Rate `json:"quantity"` UnitCost money.Amount `json:"unit_cost"` Value money.Amount `json:"value"`}
func (h handlers) getStockValuation(c *gin.Context) { var v p.Valuation err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { asOf, err := stockDateQuery(c, "as_of", clock.Today(ctx, h.deps.Clock)) if err != nil { return err } v, err = p.StockValuation(ctx, tx, asOf) return err }) if err != nil { _ = c.Error(mapErr(err)) return } rows := make([]valuationRowDTO, 0, len(v.Rows)) for _, r := range v.Rows { rows = append(rows, valuationRowDTO{r.ItemID, r.ItemCode, r.ItemName, r.AccountID, r.Quantity, r.UnitCost, r.Value}) } c.JSON(http.StatusOK, gin.H{"as_of": v.AsOf.Format(stockDate), "currency": v.Currency, "total": v.Total, "rows": rows})}
type transferBody struct { ItemID uuid.UUID `json:"item_id"` From uuid.UUID `json:"from_location_id"` To uuid.UUID `json:"to_location_id"` Quantity money.Rate `json:"quantity"` Lots []lotBody `json:"lots"` Note string `json:"note"`}
func (h handlers) createTransfer(c *gin.Context) { var b transferBody if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } actor, err := stockActor(c) if err != nil { _ = c.Error(err) return } lots, err := lotsFrom(b.Lots) if err != nil { _ = c.Error(err) return } var t p.StockTransfer err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error t, err = p.TransferStock(ctx, tx, h.deps.Clock, p.TransferInput{ItemID: b.ItemID, From: b.From, To: b.To, Quantity: b.Quantity, Lots: lots, Note: b.Note, Actor: actor}) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusCreated, gin.H{"id": t.ID, "item_id": t.ItemID, "from_location_id": t.From, "to_location_id": t.To, "quantity": t.Quantity, "transferred_on": t.TransferredOn.Format(stockDate), "note": t.Note})}
func (h handlers) getCostingMethod(c *gin.Context) { var method string var locked bool err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { prof, err := company.Get(ctx, tx) if err != nil { return err } method = prof.CostingMethod locked, err = p.CostingLocked(ctx, tx) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"costing_method": method, "locked": locked})}
func (h handlers) putCostingMethod(c *gin.Context) { var b struct { CostingMethod string `json:"costing_method"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } var method string err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { prof, err := company.SetCostingMethod(ctx, tx, h.deps.Clock, b.CostingMethod) method = prof.CostingMethod return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"costing_method": method, "locked": false})}
func (h handlers) listAdjustmentReasons(c *gin.Context) { var rows []p.AdjustmentReason err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = p.ListAdjustmentReasons(ctx, tx) return err }) if err != nil { _ = c.Error(mapErr(err)) return } if rows == nil { rows = []p.AdjustmentReason{} } c.JSON(http.StatusOK, gin.H{"reasons": rows})}
func (h handlers) createAdjustmentReason(c *gin.Context) { var b struct { Code string `json:"code"` AccountID uuid.UUID `json:"account_id"` Names map[string]string `json:"names"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } var r p.AdjustmentReason err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error r, err = p.CreateAdjustmentReason(ctx, tx, h.deps.Clock, p.ReasonInput{Code: b.Code, AccountID: b.AccountID, Names: b.Names}) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusCreated, r)}
type adjustmentLineBody struct { ItemID uuid.UUID `json:"item_id"` Direction string `json:"direction"` Quantity money.Rate `json:"quantity"` UnitCost *money.Amount `json:"unit_cost"` Lots []lotBody `json:"lots"`}
func (h handlers) createAdjustment(c *gin.Context) { var b struct { LocationID uuid.UUID `json:"location_id"` ReasonID uuid.UUID `json:"reason_id"` Lines []adjustmentLineBody `json:"lines"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } actor, err := stockActor(c) if err != nil { _ = c.Error(err) return } in := adjustment.Input{LocationID: b.LocationID, ReasonID: b.ReasonID, Reporter: actor} for _, l := range b.Lines { lots, err := lotsFrom(l.Lots) if err != nil { _ = c.Error(err) return } cost := money.Zero() if l.UnitCost != nil { cost = *l.UnitCost } in.Lines = append(in.Lines, adjustment.Line{ItemID: l.ItemID, Direction: l.Direction, Quantity: l.Quantity, UnitCost: cost, Lots: lots}) } h.respondAdjustment(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error) { return adjustment.Create(ctx, tx, h.deps.Clock, in) })}
func (h handlers) getAdjustment(c *gin.Context) { id, err := stockPathID(c) if err != nil { _ = c.Error(err) return } var a adjustment.Adjustment err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error a, err = adjustment.Get(ctx, tx, id) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, adjustmentDTO(a))}
func (h handlers) transitionAdjustment(c *gin.Context) { id, err := stockPathID(c) if err != nil { _ = c.Error(err) return } var b struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } actor, err := stockActor(c) if err != nil { _ = c.Error(err) return } h.respondAdjustment(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error) { if _, err := adjustment.Get(ctx, tx, id); err != nil { // a stock adjustment, not another stock document return uuid.Nil, err } _, err := p.Transition(ctx, tx, h.deps.Clock, p.TransitionInput{DocumentID: id, From: b.From, To: b.To, Actor: actor, Reason: b.Reason}) return id, err })}
// respondAdjustment runs write in one transaction, reads the adjustment it// names back in the same transaction, and answers it. A transition posts,// so the error goes through ledger.MapError before mapErr.func (h handlers) respondAdjustment(c *gin.Context, status int, write func(ctx context.Context, tx pgx.Tx) (uuid.UUID, error)) { var a adjustment.Adjustment err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { id, err := write(ctx, tx) if err != nil { return err } a, err = adjustment.Get(ctx, tx, id) return err }) if err != nil { _ = c.Error(mapErr(ledger.MapError(err))) return } c.JSON(status, adjustmentDTO(a))}
func adjustmentDTO(a adjustment.Adjustment) gin.H { lines := make([]gin.H, 0, len(a.Lines)) for _, l := range a.Lines { lines = append(lines, gin.H{"id": l.ID, "item_id": l.ItemID, "direction": l.Direction, "quantity": l.Quantity, "unit_cost": l.UnitCost, "lots": lotBodies(l.Lots)}) } return gin.H{"id": a.ID, "number": a.Number, "state": a.State, "location_id": a.LocationID, "reason_id": a.ReasonID, "count_id": a.CountID, "instance_id": a.InstanceID, "lines": lines}}
func (h handlers) createStockCount(c *gin.Context) { var b struct { LocationID uuid.UUID `json:"location_id"` ReasonID uuid.UUID `json:"reason_id"` Lines []struct { ItemID uuid.UUID `json:"item_id"` SerialNo string `json:"serial_no"` BatchNo string `json:"batch_no"` Counted money.Rate `json:"counted"` } `json:"lines"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } actor, err := stockActor(c) if err != nil { _ = c.Error(err) return } in := adjustment.CountInput{LocationID: b.LocationID, ReasonID: b.ReasonID, Reporter: actor} for _, l := range b.Lines { in.Lines = append(in.Lines, adjustment.CountLine{ItemID: l.ItemID, SerialNo: l.SerialNo, BatchNo: l.BatchNo, Counted: l.Counted}) } var count adjustment.Count err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error count, err = adjustment.RecordCount(ctx, tx, h.deps.Clock, in) return err }) if err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusCreated, gin.H{"id": count.ID, "location_id": count.LocationID, "counted_on": count.CountedOn.Format(stockDate), "adjustment_id": count.AdjustmentID, "lines": count.Lines})}
// stockProblem maps O2's refusals, and the workflow, ledger and trade// refusals O2's routes can meet (O1's mapErr maps item-master errors only);// mapErr calls it first. nil: not one of these.func stockProblem(err error) error { var ( refused *workflow.RefusedError unroutable *workflow.SubmissionRefusedError period *fiscal.PeriodNotOpenError control *chart.NoControlAccountError noRule *accounting.NoPostingRuleError tradeField *trade.FieldError ) switch { case errors.Is(err, workflow.ErrStaleState): return apperr.New(apperr.KindConflict, "the document is no longer in the state you sent as from; reload it and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.As(err, new(*workflow.ReasonError)): return apperr.New(apperr.KindValidation, err.Error()).With("field", "reason") case errors.Is(err, workflow.ErrSoleOwnerReasonRequired): return apperr.New(apperr.KindValidation, "field=reason rule=required: as the organization's only member you may do this yourself, with a reason"). With("code", "sole_owner_reason_required") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "reason is required for this transition") case errors.As(err, &refused): return apperr.New(apperr.KindForbidden, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail). With("code", refused.Refusal.Code) case errors.As(err, &unroutable): return apperr.New(apperr.KindValidation, err.Error()) case errors.As(err, &period): return apperr.New(apperr.KindConflict, fmt.Sprintf("the fiscal period this would post into (%s) is %s, not open", period.PeriodID, period.Status)) case errors.As(err, &control): return apperr.New(apperr.KindConflict, "accounting is not set up: no active account for control kind "+control.Kind) case errors.As(err, &noRule): return apperr.New(apperr.KindInternal, "this document cannot be posted; contact the operator") case errors.As(err, &tradeField): return apperr.New(apperr.KindValidation, tradeField.Error()).With("field", tradeField.Field) case errors.Is(err, trade.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such record in this organization") } conflict := func(code string) error { return apperr.New(apperr.KindConflict, err.Error()).With("code", code) } notFound := func(code string) error { return apperr.New(apperr.KindNotFound, err.Error()).With("code", code) } invalid := func(field, rule string) error { return apperr.New(apperr.KindValidation, fmt.Sprintf("field=%s rule=%s: %s", field, rule, err.Error())) } for _, m := range []struct { target error out func() error }{ {p.ErrInsufficientStock, func() error { return conflict("insufficient_stock") }}, {p.ErrSerialHeld, func() error { return conflict("serial_held") }}, {p.ErrLayerConsumed, func() error { return conflict("layer_consumed") }}, {p.ErrValueExhausted, func() error { return conflict("value_exhausted") }}, {p.ErrAlreadyApplied, func() error { return conflict("already_applied") }}, {p.ErrItemFixed, func() error { return conflict("item_fixed") }}, {p.ErrHasChildren, func() error { return conflict("has_children") }}, {p.ErrNothingReceived, func() error { return conflict("nothing_received") }}, {p.ErrItemAccountMissing, func() error { return conflict("item_account_missing") }}, {p.ErrReasonCodeTaken, func() error { return conflict("reason_code_taken") }}, {p.ErrReasonInactive, func() error { return conflict("reason_inactive") }}, {company.ErrCostingMethodLocked, func() error { return conflict("costing_method_locked") }}, {p.ErrDocumentNotFound, func() error { return notFound("document_not_found") }}, {p.ErrWrongKind, func() error { return notFound("document_not_found") }}, {p.ErrItemNotFound, func() error { return notFound("item_not_found") }}, {p.ErrReasonNotFound, func() error { return notFound("reason_not_found") }}, {p.ErrSerialRequired, func() error { return invalid("lots", "serial_required") }}, {p.ErrBatchRequired, func() error { return invalid("lots", "batch_required") }}, {p.ErrUntrackedLot, func() error { return invalid("lots", "untracked_lot") }}, {p.ErrLotsMismatch, func() error { return invalid("lots", "lots_mismatch") }}, {p.ErrBatchExpiryMismatch, func() error { return invalid("lots", "batch_expiry_mismatch") }}, {p.ErrNotStockItem, func() error { return invalid("item_id", "stock_item") }}, {p.ErrSameLocation, func() error { return invalid("to_location_id", "other_location") }}, {p.ErrQuantityNotPositive, func() error { return invalid("quantity", "positive") }}, {p.ErrNoDirection, func() error { return invalid("direction", "in_or_out") }}, {p.ErrNoLocation, func() error { return invalid("location_id", "required") }}, {p.ErrNoSourceLine, func() error { return invalid("source_line_id", "required") }}, {p.ErrLineNeedsItem, func() error { return invalid("item_id", "required") }}, {p.ErrReasonCode, func() error { return invalid("code", "shape") }}, {p.ErrReasonName, func() error { return invalid("names", "en_required") }}, {p.ErrReasonAccount, func() error { return invalid("account_id", "ordinary_account") }}, {p.ErrTooManyRows, func() error { return invalid("to", "too_many_rows") }}, {company.ErrCostingMethodUnknown, func() error { return invalid("costing_method", "known") }}, {adjustment.ErrNoLines, func() error { return invalid("lines", "required") }}, {adjustment.ErrUnitCostOnOut, func() error { return invalid("unit_cost", "none_on_out") }}, {adjustment.ErrNegativeUnitCost, func() error { return invalid("unit_cost", "nonnegative") }}, {adjustment.ErrDuplicateCountLine, func() error { return invalid("lines", "counted_once") }}, {adjustment.ErrCountedNegative, func() error { return invalid("counted", "nonnegative") }}, } { if errors.Is(err, m.target) { return m.out() } } return nil}internal/modules/inventory/routes.go — last line of O1’s Register (its h and pl):
registerStock(g, pl, h)internal/modules/inventory/errors.go — first statement of O1’s mapErr:
if problem := stockProblem(err); problem != nil { return problem }- Step 5: The contract
api/openapi.yaml — under paths (tag inventory, like O1’s routes; every operation inherits bearerAuth):
/inventory/stock/on-hand: get: operationId: getStockOnHand summary: Stock on hand by item, location and lot description: Every positive balance, optionally narrowed to one location or item. Requires inventory.item.read. parameters: - { name: location_id, in: query, schema: { type: string, format: uuid } } - { name: item_id, in: query, schema: { type: string, format: uuid } } responses: '200': description: Stock on hand content: application/json: schema: type: object required: [rows] properties: rows: { type: array, items: { $ref: '#/components/schemas/StockOnHandRow' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/stock/movements: get: operationId: getStockMovements summary: Stock movements between two dates description: > Every move dated from..to (default: a month back to today), in posting order, with the document or transfer behind it. At most 5,000 rows (422 too_many_rows). Requires inventory.item.read. parameters: - { name: from, in: query, schema: { type: string, format: date } } - { name: to, in: query, schema: { type: string, format: date } } - { name: item_id, in: query, schema: { type: string, format: uuid } } - { name: location_id, in: query, schema: { type: string, format: uuid } } responses: '200': description: The movements content: application/json: schema: type: object required: [from, to, rows] properties: from: { $ref: '#/components/schemas/Date' } to: { $ref: '#/components/schemas/Date' } rows: { type: array, items: { $ref: '#/components/schemas/StockMovement' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/stock/valuation: get: operationId: getStockValuation summary: Stock valuation as of a date description: Each item's quantity and value at the end of as_of (default today), in base currency. Requires inventory.item.read. parameters: - { name: as_of, in: query, schema: { type: string, format: date } } responses: '200': description: The valuation content: application/json: schema: type: object required: [as_of, currency, total, rows] properties: as_of: { $ref: '#/components/schemas/Date' } currency: { type: string } total: { $ref: '#/components/schemas/Money' } rows: { type: array, items: { $ref: '#/components/schemas/StockValuationRow' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/transfers: post: operationId: createStockTransfer summary: Transfer one item between two locations description: Two moves, no workflow, no journal entry, no value moved (spec O2.1). Requires inventory.transfer.create. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/StockTransferInput' } } } responses: '201': { description: The transfer, content: { application/json: { schema: { $ref: '#/components/schemas/StockTransfer' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '404': { $ref: '#/components/responses/StockNotFound' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/costing-method: get: operationId: getCostingMethod summary: The organization's costing method description: fifo or weighted_average; locked once stock has moved (OD-1). Requires inventory.item.read. responses: '200': { description: The method, content: { application/json: { schema: { $ref: '#/components/schemas/CostingMethod' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } put: operationId: setCostingMethod summary: Choose the costing method before stock first moves description: Refused (409 costing_method_locked) once any stock has moved. Requires org.profile.manage. requestBody: required: true content: application/json: schema: type: object required: [costing_method] additionalProperties: false properties: costing_method: { type: string, enum: [fifo, weighted_average] } responses: '200': { description: The method, content: { application/json: { schema: { $ref: '#/components/schemas/CostingMethod' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/adjustment-reasons: get: operationId: listAdjustmentReasons summary: Stock adjustment reasons description: Requires inventory.adjustment.read. responses: '200': description: The reasons content: application/json: schema: type: object required: [reasons] properties: reasons: { type: array, items: { $ref: '#/components/schemas/AdjustmentReason' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } post: operationId: createAdjustmentReason summary: Add a stock adjustment reason and the account it posts to description: > The account is postable, active, no control account and no module's own. Requires accounting.account.manage (it chooses a GL account). requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/AdjustmentReasonInput' } } } responses: '201': { description: The reason, content: { application/json: { schema: { $ref: '#/components/schemas/AdjustmentReason' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/adjustments: post: operationId: createStockAdjustment summary: Draft a stock adjustment description: > Base UOM and base currency; "in" lines carry a unit cost, "out" lines none (FIFO or the average decides). Submitted by its preparer and approved by the Owner seat, when it moves and posts (effect). Requires inventory.adjustment.create. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustmentInput' } } } responses: '201': { description: The draft, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '404': { $ref: '#/components/responses/StockNotFound' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/adjustments/{id}: get: operationId: getStockAdjustment summary: One stock adjustment description: Requires inventory.adjustment.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The adjustment, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '404': { $ref: '#/components/responses/StockNotFound' } /inventory/adjustments/{id}/transitions: post: operationId: transitionStockAdjustment summary: Move a stock adjustment along its workflow description: > Approving moves the stock and posts; cancelling after approval reverses both. Authority is the workflow's grants. Requires inventory.adjustment.transition. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/StockTransitionInput' } } } responses: '200': { description: The adjustment after the move, content: { application/json: { schema: { $ref: '#/components/schemas/StockAdjustment' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '404': { $ref: '#/components/responses/StockNotFound' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' } /inventory/stock-counts: post: operationId: recordStockCount summary: Record a stock count description: > Records counted and book quantities at one location and drafts an adjustment for the differences (adjustment_id; null when the count matches). Requires inventory.adjustment.create. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/StockCountInput' } } } responses: '201': { description: The count, content: { application/json: { schema: { $ref: '#/components/schemas/StockCount' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/StockForbidden' } '404': { $ref: '#/components/responses/StockNotFound' } '409': { $ref: '#/components/responses/StockConflict' } '422': { $ref: '#/components/responses/StockInvalid' }Under components.responses:
StockForbidden: description: The caller does not hold the permission this operation requires. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } StockNotFound: description: code document_not_found, item_not_found or reason_not_found. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } StockConflict: description: > code insufficient_stock, serial_held, layer_consumed, value_exhausted, already_applied, item_fixed, has_children, nothing_received, item_account_missing, reason_code_taken, reason_inactive, costing_method_locked; or a workflow or ledger refusal. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } StockInvalid: description: A field refusal, "field=<name> rule=<code>". content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }Under components.schemas:
StockQuantity: type: string description: A quantity in the item's base unit as a decimal string, up to 6 places. Never a JSON number. example: "12.500000" x-go-type: money.Rate x-go-type-import: path: github.com/mcom/erp/internal/kernel/money StockLot: type: object required: [quantity] additionalProperties: false properties: serial_no: { type: string, description: A serial item's serial; quantity is then 1. } batch_no: { type: string } expires_on: { type: string, format: date, nullable: true, description: A batch's expiry. } quantity: { $ref: '#/components/schemas/StockQuantity' } StockOnHandRow: type: object required: [item_id, item_code, item_name, location_id, location_code, lot, on_hand] properties: item_id: { type: string, format: uuid } item_code: { type: string } item_name: { type: string } location_id: { type: string, format: uuid } location_code: { type: string } lot: { type: string, description: The serial or batch; empty for an untracked item. } on_hand: { $ref: '#/components/schemas/StockQuantity' } StockMovement: type: object required: [move_id, move_date, item_id, item_code, location_id, location_code, quantity, unit_cost, total_cost, serial_no, batch_no, document_id, document_kind, document_number, transfer_id, reversal] properties: move_id: { type: string, format: uuid } move_date: { $ref: '#/components/schemas/Date' } item_id: { type: string, format: uuid } item_code: { type: string } location_id: { type: string, format: uuid } location_code: { type: string } quantity: { $ref: '#/components/schemas/StockQuantity' } unit_cost: { $ref: '#/components/schemas/Money' } total_cost: { $ref: '#/components/schemas/Money' } serial_no: { type: string } batch_no: { type: string } document_id: { type: string, format: uuid, nullable: true } document_kind: { type: string } document_number: { type: string } transfer_id: { type: string, format: uuid, nullable: true } reversal: { type: boolean } StockValuationRow: type: object required: [item_id, item_code, item_name, account_id, quantity, unit_cost, value] properties: item_id: { type: string, format: uuid } item_code: { type: string } item_name: { type: string } account_id: { type: string, format: uuid, nullable: true } quantity: { $ref: '#/components/schemas/StockQuantity' } unit_cost: { $ref: '#/components/schemas/Money' } value: { $ref: '#/components/schemas/Money' } StockTransferInput: type: object required: [item_id, from_location_id, to_location_id, quantity] additionalProperties: false properties: item_id: { type: string, format: uuid } from_location_id: { type: string, format: uuid } to_location_id: { type: string, format: uuid } quantity: { $ref: '#/components/schemas/StockQuantity' } lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } } note: { type: string, maxLength: 500 } StockTransfer: type: object required: [id, item_id, from_location_id, to_location_id, quantity, transferred_on, note] properties: id: { type: string, format: uuid } item_id: { type: string, format: uuid } from_location_id: { type: string, format: uuid } to_location_id: { type: string, format: uuid } quantity: { $ref: '#/components/schemas/StockQuantity' } transferred_on: { $ref: '#/components/schemas/Date' } note: { type: string } CostingMethod: type: object required: [costing_method, locked] properties: costing_method: { type: string, enum: [fifo, weighted_average] } locked: { type: boolean, description: True once any stock has moved. } AdjustmentReason: type: object required: [id, code, name, account_id, active] properties: id: { type: string, format: uuid } code: { type: string } name: { type: string, description: In the request's locale. } account_id: { type: string, format: uuid } active: { type: boolean } AdjustmentReasonInput: type: object required: [code, account_id, names] additionalProperties: false properties: code: { type: string, pattern: '^[a-z][a-z0-9_]{0,31}$' } account_id: { type: string, format: uuid } names: type: object required: [en] additionalProperties: { type: string, maxLength: 200 } StockAdjustmentInput: type: object required: [location_id, reason_id, lines] additionalProperties: false properties: location_id: { type: string, format: uuid } reason_id: { type: string, format: uuid } lines: type: array minItems: 1 items: type: object required: [item_id, direction, quantity] additionalProperties: false properties: item_id: { type: string, format: uuid } direction: { type: string, enum: [in, out] } quantity: { $ref: '#/components/schemas/StockQuantity' } unit_cost: { $ref: '#/components/schemas/Money' } lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } } StockAdjustment: type: object required: [id, number, state, location_id, reason_id, count_id, instance_id, lines] properties: id: { type: string, format: uuid } number: { type: string } state: { type: string, description: The workflow state key. } location_id: { type: string, format: uuid } reason_id: { type: string, format: uuid } count_id: { type: string, format: uuid, nullable: true } instance_id: { type: string, format: uuid } lines: type: array items: type: object required: [id, item_id, direction, quantity, unit_cost, lots] properties: id: { type: string, format: uuid } item_id: { type: string, format: uuid } direction: { type: string, enum: [in, out] } quantity: { $ref: '#/components/schemas/StockQuantity' } unit_cost: { $ref: '#/components/schemas/Money' } lots: { type: array, items: { $ref: '#/components/schemas/StockLot' } } StockTransitionInput: type: object required: [from, to] additionalProperties: false properties: from: { type: string } to: { type: string } reason: { type: string, maxLength: 1000 } StockCountInput: type: object required: [location_id, reason_id, lines] additionalProperties: false properties: location_id: { type: string, format: uuid } reason_id: { type: string, format: uuid } lines: type: array minItems: 1 items: type: object required: [item_id, counted] additionalProperties: false properties: item_id: { type: string, format: uuid } serial_no: { type: string } batch_no: { type: string } counted: { $ref: '#/components/schemas/StockQuantity' } StockCount: type: object required: [id, location_id, counted_on, adjustment_id, lines] properties: id: { type: string, format: uuid } location_id: { type: string, format: uuid } counted_on: { $ref: '#/components/schemas/Date' } adjustment_id: { type: string, format: uuid, nullable: true } lines: type: array items: type: object required: [item_id, lot, system, counted] properties: item_id: { type: string, format: uuid } lot: { type: string } system: { $ref: '#/components/schemas/StockQuantity' } counted: { $ref: '#/components/schemas/StockQuantity' }(If O1 already defines a quantity schema, use it instead of StockQuantity. BadRequestBody is the shared 400 response the e-Invoice routes use.)
Run: make generate && git status --short
- Step 6: Run the tests to verify they pass
Run: go test ./internal/modules/inventory/... ./internal/app/ ./internal/gen/ ./internal/kernel/db/ -count=1
Expected: PASS (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, the generated-code drift tests included).
- Step 7: Verify at runtime
Start the lane’s stack (make dev-stack or the E2E lane ports the phase uses), provision an organization, sign in as its Owner, and with curl -H "Authorization: Bearer $TOKEN":
GET /v1/inventory/costing-method→{"costing_method":"weighted_average","locked":false}.POST /v1/inventory/adjustmentswith an item O1’sPOST /v1/inventory/itemscreated,direction: "in",quantity: "5",unit_cost: "2.00", reasoncount(fromGET /v1/inventory/adjustment-reasons) → 201,state: "draft".- As a second member,
POST …/transitions{"from":"draft","to":"submitted"}; as the Owner{"from":"submitted","to":"approved"}→ 200,state: "approved". GET /v1/inventory/stock/on-hand→ the 5;GET /v1/inventory/stock/valuation→ value10.0000;GET /v1/inventory/costing-method→locked: true;PUTit tofifo→ 409costing_method_locked.
Record the responses in the commit body.
- Step 8: Commit
git add db/migrations/00727_inventory_stock_grants.sql internal/modules/inventory/ api/openapi.yaml internal/gen/ web/src/lib/api/git commit -m "Phase 4 O2: stock routes, permissions and contract
Runtime check: <the four responses>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 13: Lock order, roadmap, the full suite
Section titled “Task 13: Lock order, roadmap, the full suite”Files:
- Modify:
docs/LOCK_ORDER.md,docs/ROADMAP.md
Interfaces:
-
Consumes: everything above.
-
Step 1:
docs/LOCK_ORDER.md
Add to the Per module table:
| Inventory stock documents (receipts, deliveries, returns, adjustments) | `trade_document` `FOR UPDATE` → on a cancel, its live lines `FOR UPDATE` in `id` order → `company_profile` `FOR SHARE` (the costing method; `company.SetCostingMethod` takes it `FOR UPDATE`) → `item_cost` rows `FOR UPDATE` in item id order, created if missing → `cost_layer` rows `FOR UPDATE` in `seq` order (FIFO) → `stock_balance` (upserted by `stock_move`'s trigger) → `workflow.Transition` (the Poster reads the moves) → audit, last. `adjustment.Create` writes its detail, direction and lot rows after `trade.Create`'s audit: INSERTs whose only locks are `FOR KEY SHARE` on rows the transaction wrote or that nothing locks `FOR UPDATE` | `inventory/apply.go` `Transition`, `inventory/costing.go` `beginStock`, `inventory/adjustment/adjustment.go` `Create` || Inventory transfers | `company_profile` `FOR SHARE` → the item's `item_cost` `FOR UPDATE` → `stock_balance` (trigger) → audit | `inventory/transfer.go` `TransferStock` |- Step 2:
docs/ROADMAP.md
Under Phase 4 — Operations, tick the inventory item once O1 has landed (items, multi-UOM and locations are O1’s; transfers, FIFO and weighted average costing, serial/batch and posting through the engine are this plan’s):
- [x] Inventory: items, multi-UOM, multi-location, transfers, FIFO and weighted average costing, serial/batch. **Costing posts through the posting engine.** (O1 + O2: `effect` semantic, `grni`, `stock_valuation` integrity check; web UI for stock is a later lane, R-O2-19)- Step 3: The full suite
Run: make generate && git status --short && go vet ./... && go test -p 2 -count=1 ./... && pnpm -C web typecheck && pnpm -C web test
Expected: tree clean after generate; every package PASS.
- Step 4: Commit
git add docs/LOCK_ORDER.md docs/ROADMAP.mdgit commit -m "Phase 4 O2: lock order and roadmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Self-review against the spec
Section titled “Self-review against the spec”| Spec | Where |
|---|---|
O2.1 stock_move append-only (no UPDATE/DELETE for the app role) |
Task 3 (00722 REVOKE, test TestOnlyAMoveWritesTheBalanceAndNoMoveIsEdited) |
O2.1 item, location, signed base qty, unit and total cost NUMERIC(19,4), move date, serial/batch, source |
Task 3 schema; source is the trade line or transfer (R-O2-2: no journal_entry_id) |
O2.1 move date is clock.Today, no back-dating, correction by reversing move |
insertMove; reverse (Task 6) |
O2.1 stock_balance in the same transaction under a row lock, CHECK (on_hand >= 0) |
00722 trigger upsert; TestStockCannotGoBelowZero |
| O2.1 transfers: two moves, no workflow, no entry, cost unchanged | Task 8 |
O2.2 company.costing_method, trigger once a move exists |
Task 4 |
| O2.2 cost per item across the organization | item_cost (Task 6), zero-cost transfers |
| O2.2 FIFO layers, oldest first | consumeLayers; TestFIFOIssuesTheOldestLayersFirst |
| O2.2 weighted average, rounded to 4, residue to the emptying issue | costOut; TestWeightedAverageIssuesAtTheAverage… |
O2.3 receipt: accrual, grni, Dr Inventory / Expense, Cr GRNI |
Tasks 2, 5, 7 |
| O2.3 purchase invoice on receipt lines (GRNI/AP, PPV, FX) | O3 (R-O2-8); O2 provides grni, PPV role, ReceivedValue |
O2.3 delivery: effect, Dr COGS / Cr Inventory at cost |
Tasks 1, 7 |
O2.3 adjustment: effect, Inventory against the reason’s account |
Tasks 7, 9 |
| O2.3 returns and cancellations: reversal path, reversing moves | Task 6 reverse, Task 7 cancel test; returns post their own entries (R-O2-15) |
| O2.3 provisioning: GRNI, Inventory, COGS, PPV, Stock adjustment | Task 2 step |
| O2.4 serial unique per item, batch with expiry, refused by the database | 00722 guard and partial unique index; stock_batch; Task 3 tests |
O2.4 stock-take → stock_adjustment |
Task 9 RecordCount |
O1’s hand-offs: item_fixed_once_used() extended to stock_move; stock kinds’ graphs (O1-14); live-children rule on cancel |
Task 3 (00722), Task 5, Task 6 Transition |
| O2.5 valuation = GL: a test and an operator console check | Task 10 (reconcile check → opsjobs → console; RUNBOOK §7.3) |
| O3.3 stock on hand, movement, valuation reports | Task 11, Task 12 routes (R-O2-14) |
| Drift tests: balance = Σ moves; negative refused; costing lock; valuation = GL; new tables tenant-isolated | RequireBalancesAddUp, Task 3, Task 4, Task 10, the existing RLS/FK/constraint-name tests |
Gaps deliberately left, each a line: no web UI (R-O2-19); no upgrade step for O1 placeholder graphs on already-provisioned organizations (add one, like receivables’ sales_graphs_v2, if O1’s placeholders reached a real organization); FIFO residue is exercised through the weighted-average test and RequireCostsAddUp rather than a dedicated FIFO rounding case; one item per transfer; adjustments carry no analytic shares (an organization that requires a cost centre on expense accounts must add shares when O3 or a later lane exposes them).