Stock — the append-only inventory ledger

The whole stock model reduces to one idea: every inbound or outbound of inventory is one Stock row. type: IN adds quantity at branchId, type: OUT removes it. There is no mutable "current quantity" field — on-hand is always derived by aggregating the ledger (Σ netQuantity(IN) − Σ netQuantity(OUT), scoped by itemId + branchId). Deleting a row self-corrects the balance, which makes every movement auditable and reversible.

Source: BE inventory/stock (stock.schema.ts, stock.service.ts, stock.repository.ts, stock.resolver.ts, stock.dto.ts, stock.controller.ts, costing.service.ts, cost-layer.dto.ts, stock.module.ts) · Admin src/modules/inventory/stock, src/modules/store · page src/pages/report/stock-movement.tsx (+ report/{fifo,avco,lifo,standard}-valuation.tsx)

Related: ./_overview.md · costing/UOM/pricing in ./pricing.md and ./categories-uom.md · the canonical narrative reference zerp-be/docs/inventory-stock-flow.md.


1. Purpose & scope

The stock module owns the stocks collection and the read aggregations over it. It is the single source of truth for "how much of item X is at branch Y". It does not create movement on its own — it is the shared ledger writer that purchase, sales, transfer, adjustment, and manufacturing call.

What this module is responsible for:

  • The Stock schema and its enums (StockTypes, StockKindTypes, StockStatus).
  • StockService.create() — the one method every flow calls to append a movement row.
  • Read aggregations: on-hand balance(), totalQuantity(), totalCost(), paginated movement listings (page / itemPage), and the stock summary.
  • Four valuation aggregations (FIFO, AVCO/AVERAGE, LIFO, STANDARD) and the costing engine (CostingService: postReceipt / postIssue / adjustCost) that maintains FIFO cost layers and posts standard-cost variances.
  • The manufacturing backflush consumer (backflushConsume).
  • An XLSX movement-detail report (stock.controller.ts).

What it explicitly does not do:

  • It does not validate availability for sales — that guard exists but is commented out (§4.4).
  • It does not create the source documents. Order/transfer/adjustment headers + lines live in their own modules and call into StockService.create().
  • It does not store running quantities on the Item (the Item.stockIn/stockOut/netQuantity fields are legacy/denormalized — trust the ledger; see ./item.md).
  • Available-to-promise / reservations are a separate collection (stock_reservations) that never touches stocks (§4.5).

2. Data model

2.1 Stock (collection stocks)

One row per inventory movement. @ApSchema({ collection: "stocks", discriminatorKey: "kind", timestamps: { createdAt: false } }), soft-deletes via mongoose-delete (deletedAt). Note createdAt: false on the schema-level timestamps option — createdAt is still present and queried (it is set/maintained through BaseSchema/the base repo, used heavily by the resolve-fields).

Fields below BaseSchema (the latter supplies key, ref, companyId, branchId, documentDate, createdBy/At, updatedBy/At, deleted/deletedAt/deletedBy, and the canDelete/canUpdate/canView/canPost flags). branchId and companyId come from BaseSchema but branchId is re-declared as required on Stock.

field type required description
orderId ObjectId no Parent document _id: the Order (purchase/sales), StockTransfer, or StockAdjustment. Setter coerces string→ObjectId.
orderItemId ObjectId no Parent line _id: OrderItem / StockTransferItem / StockAdjustmentItem. The key all update/delete operations filter on.
branchId ObjectId yes The warehouse/branch this movement hits. Drives per-branch on-hand. Indexed (via BaseSchema).
itemId ObjectId yes The product being moved. Indexed lookups via $lookUpItem.
type StockTypes yes IN (+qty) or OUT (−qty). The direction.
kind StockKindTypes yes (default Stock) Source-document discriminator. Stored as String; this is the collection's discriminatorKey.
status StockStatus no (default Available) Lifecycle of an individual row (mostly ITEM-sold-by semantics).
grossQuantity number yes Quantity including waste.
netQuantity number yes Usable quantity (gross − waste). This is the field balance() sums. Always in base UOM (callers convert before writing).
wasteQuantity number no gross − net.
soldBy ItemSoldByTypes no (default ITEM) Copied from item.soldBy (ITEM / QUANTITY / WEIGHT). Drives g vs unit display.
cost number no (default 0) Total/line cost of this movement (purchase = purchase rate; sales = COGS line).
avgCost number no (default 0) Unit cost of this movement. On non-transfer rows, if not supplied, defaulted to item.cost (§4.1).
lastStockId ObjectId no Pointer to the previous stock row (used by wasteQuantityBefore resolve-field).
soldAt number no (default 0) Sale timestamp/price (gold heritage; not central).
weightSold number no (default 0) Weight sold (gold heritage).
fixed boolean no (default true) Whether the row carries a fixed cost. AVCO valuation uses order_items.amount instead because cost is 0 on non-fixed lines.
costLayers CostLayer[] no (default []) Embedded FIFO cost layers { receiptDate, receiptRef, orderId?, quantity, remainingQty, unitCost }. Pushed on FIFO receipts, consumed oldest-first on FIFO issues (§4.6).
standardCost number no (default 0) Standard cost snapshot.
pendingStandardCost number no (default 0) Pending standard-cost revaluation.
item (virtual) Item Joined via $lookUpItem aggregation / resolve-field; not stored.

sortKey / sortValue exist as untyped class members (no @Prop) — not persisted columns.

// stock.schema.ts
@ApSchema({ collection: "stocks", discriminatorKey: "kind", timestamps: { createdAt: false } })
export class Stock extends BaseSchema {
  orderId: Types.ObjectId;           // parent document _id
  orderItemId: Types.ObjectId;       // parent line _id  ← update/delete key
  branchId: Types.ObjectId;          // REQUIRED — warehouse
  itemId: Types.ObjectId;            // REQUIRED — product
  type: StockTypes;                  // REQUIRED — IN | OUT
  kind: string;                      // REQUIRED — discriminatorKey, default "Stock"
  status: StockStatus;               // default Available
  grossQuantity: number;             // REQUIRED
  netQuantity: number;               // REQUIRED — what balance() sums (base UOM)
  wasteQuantity: number;
  soldBy: ItemSoldByTypes;           // default ITEM
  cost: number;  avgCost: number;    // default 0 / 0
  lastStockId: Types.ObjectId;
  costLayers: Array<{ receiptDate; receiptRef; orderId?; quantity; remainingQty; unitCost }>;
  standardCost: number;  pendingStandardCost: number;
  fixed: boolean;                    // default true
}
StockSchema.plugin(SoftDelete, { deletedAt: true });

2.2 Enums (verbatim from stock.schema.ts)

export enum StockTypes {
  IN  = "IN",    // quantity increases at branchId
  OUT = "OUT",   // quantity decreases at branchId
}

export enum StockKindTypes {
  Stock           = "Stock",            // generic / manual / manufacturing backflush
  PurchaseInvoice = "PurchaseInvoice",  // written by purchase (always IN)
  SalesInvoice    = "SalesInvoice",     // written by sales (always OUT)
  StockCount      = "StockCount",       // (enum value present; no writer in stock module)
  StockTransfer   = "StockTransfer",    // written by transfer (BOTH legs)
  StockAdjustment = "StockAdjustment",  // written by adjustment (IN or OUT by sign of diff)
  OrderReturn     = "OrderReturn",      // (enum value present; returns handled via order kinds)
}

export enum StockStatus {
  Available = "Available",  // default
  Sold      = "Sold",
  Offline   = "Offline",
  Closed    = "Closed",
}

Enum drift to note for a rebuild. The admin model.ts StockKindTypes is out of sync with the BE: it contains SalesOrder and SalesInvoiceItem and omits SalesInvoice/OrderReturn. The authoritative set is the BE enum + src/schema.gql. The admin enum is only used for a couple of display/cost branches; the In/Out filter hardcodes PurchaseInvoice/SalesInvoice strings.

StockReservationStatus (inventory.constant.ts) governs the separate reservation collection: ACTIVE | RELEASED | CONSUMED | CANCELLED (§4.5).

2.3 Relationships, scoping, soft-delete

  • References (not embedded): itemId → items, branchId → branches, orderId/orderItemId → the source document/line. item and store are resolved on demand (resolve-fields / $lookUpItem).
  • Tenant/branch scoping: every row carries companyId + branchId. On-hand is per branch — the same item at two branches has two independent balances. Valuation queries are filtered by both branchId and companyId.
  • Soft delete: mongoose-delete with deletedAt. Soft-deleted rows are excluded from the base query (deleted: { $ne: true } in the valuation pipelines), so deleting a movement makes on-hand self-correct without a recompute job.
  • Derived/computed fields (resolve-fields, never stored): inStock, netQuantityBefore, grossQuantityBefore, wasteQuantityBefore — see §4.3.

3. API surface

All queries are under one @ApGqlAuthorize() resolver (StockResolver extends ApBaseResolver<Stock>). There are no stock mutations exposed on this resolver — rows are written only through the source modules (purchase/sales/transfer/adjustment/manufacturing). Reads:

Operation Type Input Returns Auth
stockPage Query StockPageInput (filters + skip/take) StockPageResult ({ totalRecords, data: [Stock] }) @ApGqlAuthorize
stockItemPage Query StockPageInput StockPageResultone row per item (last movement per itemId) @ApGqlAuthorize
findStock Query QueryStockInput [Stock] (sorted createdAt: 1, with item join) @ApGqlAuthorize
findOneStock Query QueryStockInput Stock @ApGqlAuthorize
stockSummary Query QueryStockInput (nullable) StockSummary (totalRecords + stockIn{count,weight,cost} + stockOut{…}) @ApGqlAuthorize
getItemStockBalance Query itemId: String!, branchId: String Float — on-hand balance() (branch-scoped if given) @ApGqlAuthorize
fifoValuation Query branchId: String! (+ user.companyId) [FifoItemValuation] @ApGqlAuthorize
avcoValuation Query branchId: String! [AvcoItemValuation] @ApGqlAuthorize
lifoValuation Query branchId: String! [LifoItemValuation] @ApGqlAuthorize
standardValuation Query branchId: String! [StandardItemValuation] @ApGqlAuthorize

Resolve-fields on Stock: netQuantity, grossQuantity (note: returns args.netQuantity — see gotchas), inStock, netQuantityBefore, grossQuantityBefore, wasteQuantityBefore, item, store.

REST: StockController (@Controller("api/stock"), @ApiAuthorize()) — one endpoint: GET /api/stock/movement-detail-download?downloadType=XLSX → streams an XLSX of the movement detail report (item, category, store, soldBy, IN/OUT+kind, qty, cost, in-stock/stock-before, document date).

3.1 Query/page inputs (stock.dto.ts)

QueryStockInput extends PartialType(CommonStockInput) adds the filter surface:

@InputType() export class QueryStockInput extends PartialType(CommonStockInput) {
  fromDate?: number;  toDate?: number;       // documentDate range (inclusive)
  kind?: StockKindTypes;                     // PurchaseInvoice | SalesInvoice | ...
  itemId?: string;  itemKeyword?: string;    // item filter / keyword on item.keywords
  stores?: [string];                         // branchId $in
  categories?: [string];                     // item.categoryId $in
  costCenterId?; classId?; analysisCodeId?;  // master-data analysis filters (passthrough)
}
@InputType() export class StockPageInput extends QueryStockInput { skip: number; take: number; }

CommonStockInput (the create-side shape) carries branchId, status, name, grossQuantity, netQuantity, wasteQuantity, quantityBefore, quantityAfter. CreateStockInput = OmitType(..., ["quantityBefore","quantityAfter"]). (No createStock mutation is wired on the resolver; the input exists for the shared StockService.create shape used internally.)


4. Business rules & calculations

4.1 The ledger writer — StockService.create()

public async create(model: Partial<Stock>): Promise<Stock> {
  // default avgCost from item.cost for non-transfer movements when not supplied
  if ((model.avgCost == null) && model.kind !== StockKindTypes.StockTransfer) {
    const item = await this.itemSvc.findById(model.itemId?.toString());
    model.avgCost = item.cost;
  }
  return this.stockRepo.create(model);
}

This is the only place rows are appended. It does no direction logic itself — type, kind, branchId, netQuantity are all supplied by the caller. The lone built-in rule: for any non-transfer movement missing avgCost, stamp the current item.cost. (Transfers deliberately skip this — both legs carry the item cost passed by the transfer service.)

4.2 On-hand derivation — balance() (the only way quantity is read)

getItemStockBalance, inStock, ATP, and the transfer guard all funnel through StockRepository.balance(query). The aggregation groups the filtered ledger into "in" and "out" buckets and subtracts:

// stock.repository.ts → balance()
this.aggregate([
  this.buildQuery(query),                       // { itemId, branchId, lessThanDate?, ... }
  { $group: { _id: null,
      totalNetQuantity: {                        // ← OUTS
        $sum: { $cond: [ { $or: [ {$eq:["$kind","SalesInvoice"]}, {$eq:["$type","OUT"]} ] },
                         "$netQuantity", 0 ] } },
      totalGrossQuantity: {                      // ← INS (despite the name)
        $sum: { $cond: [ { $or: [ {$eq:["$kind","PurchaseInvoice"]}, {$eq:["$type","IN"]} ] },
                         "$netQuantity", 0 ] } },
  }},
  { $project: { stockBalance: { $subtract: ["$totalGrossQuantity", "$totalNetQuantity"] } } },
])

On-hand = Σ netQuantity(IN) − Σ netQuantity(OUT) for the filtered scope (typically itemId + branchId). A transfer nets to zero company-wide but shifts quantity from fromBranchId to toBranchId.

Two subtleties for a faithful rebuild:

  1. The IN/OUT classification is kind == PurchaseInvoice OR type == IN (and kind == SalesInvoice OR type == OUT). Because purchase rows are always IN and sales rows always OUT, this is equivalent to a pure type test for those flows — but the kind clause means a row's classification could be pulled into the wrong bucket only if its type ever disagreed with its kind (it doesn't in current writers). Treat type as authoritative; the kind OR-clause is belt-and-suspenders.
  2. The internal field names are confusingly swapped (totalGrossQuantity = inbound, totalNetQuantity = outbound). Both sum netQuantity. The final stockBalance = inbound − outbound.

Sibling read aggregations (stock.repository.ts):

  • totalQuantity(query, weightType="grossQuantity")Σ grossQuantity (or netQuantity) over the filtered set (no IN/OUT netting — it sums the filtered rows, so callers pass kind/type to scope).
  • totalCost(query)Σ (cost × netQuantity).
  • count(query) → row count.
  • getAverage(itemId, kind) (service) → totalCost / totalQuantity for that item+kind.

4.3 Per-row derived movement fields (resolve-fields)

For the movement table each row shows the running balance before and after itself, computed by re-aggregating the ledger up to that row's createdAt:

// stock.resolver.ts
inStock(row)            = row.type===IN ? balanceBefore + row.grossQuantity
                                        : balanceBefore - row.grossQuantity
netQuantityBefore(row)  = balance({ itemId, branchId, lessThanDate: row.createdAt })
grossQuantityBefore(row)= balance({ itemId, branchId, lessThanDate: row.createdAt })  // same
wasteQuantityBefore(row)= stock(row.lastStockId)?.wasteQuantity

lessThanDate is handled in buildQuery as createdAt < dayjs(lessThanDate).valueOf(). The same "balance before + this row" math is duplicated in the XLSX controller.

4.4 Who writes rows — the writers and their direction

StockService.create() is called by five flows. Each owns its source schema/lines; the table is the authoritative map of what kind/type each flow writes and what it scopes to:

Flow Source doc / line Writer method kind type branch rows/line
Purchase Order(kind=PurchaseInvoice) / OrderItem purchase/item/item.service.ts → addItemStock() PurchaseInvoice IN model.branchId ?? order.branchId 1
Sales Order(kind=SalesInvoice) / OrderItem sales/item/item.service.ts → addItemStock() SalesInvoice OUT model.branchId ?? order.branchId 1 (0 if non-inventory item)
Transfer StockTransfer / StockTransferItem transfer/item/item.service.ts → updateTransferStock() (×2) StockTransfer OUT @from + IN @to both branches 2
Adjustment StockAdjustment / StockAdjustmentItem adjustment/item/item.service.ts → updateAdjustmentStock() StockAdjustment IN if diff>0 else OUT model.branchId ?? user.branchId 1
Manufacturing production order (backflush) stock.service.ts → backflushConsume() Stock OUT passed branchId 1

Detail per flow:

Purchase (addItemStock, IN). Resolves item + order, computes a new weighted purchase average via orderSvc.getPurchaseAvg(...), then stockSvc.create({ ...model, branchId, avgCost: newAvg, orderItemId: model._id, soldBy: item.soldBy, kind: PurchaseInvoice, type: IN }). Costing side effect by item.costingMethod (default STANDARD when unset): AVERAGE → updates item.cost = newAvg; FIFOCostingService.postReceipt(...) pushes a cost layer onto the new stock row.

Sales (addItemStock, OUT). Two guards, then write:

  1. Inventory-item checkif (!item.type.isInventoryItem) return; → service/non-stock lines write no stock row.
  2. Writes stockSvc.create({ ...model, branchId, avgCost: unitCost, cost: model.cost, kind: SalesInvoice, orderItemId: model._id, type: OUT }), then back-links orderItem.stockId = newStock._id.
  3. FIFO returns/voids re-push layers via postReceipt(..., "void-sale-…").
  4. AvailabilitysalesSvc.validateStockAvailability([model]) is called before the line is created, but its body is entirely commented out (it builds an empty errors array and never throws). In the current code sales can drive on-hand negative. (The getItemStockBalance query and ATP exist for callers to check, but the posting path does not enforce it.)

Transfer (two legs). StockTransferItemService.create() runs in a retry transaction: assertBaseUomOnly(model) (rejects uomId/uomQuantity/conversionFactor) → availability guard totalQuantity({branchId, itemId}) >= model.quantity else throw "Insufficient stock" → create the transfer item → leg 1 updateTransferStock({...item, branchId: fromBranchId}, OUT, "add")leg 2 updateTransferStock({...item, branchId: toBranchId}, IN, "add"). Both legs use kind: StockTransfer, grossQuantity = netQuantity = Math.abs(quantity), and reuse orderId = transferId / orderItemId = transferItem._id. Also posts paired inventory GL legs (DECREASE @from, INCREASE @to) and asserts the books are balanced.

Adjustment. updateAdjustmentStock computes diff = newQuantity − quantity, writes one row with type = diff > 0 ? IN : OUT, gross = net = Math.abs(diff), kind: StockAdjustment, plus a balanced inventory GL entry (INCREASE/DECREASE). Base UOM only.

Manufacturing backflush. StockService.backflushConsume(itemId, qty, branchId)CostingService.postIssue(...) for unit cost → appends a Stock(type=OUT, kind=Stock, fixed=true, cost=unitCost*qty, avgCost=unitCost) and also mutates the denormalized item.stockOut/netQuantity counters (the only writer that touches those legacy fields). Called from manufacturing/production/production.service.ts on production material consumption.

4.5 Reservations & available-to-promise (separate collection)

ATP lives in inventory/fulfillment, in a dedicated stock_reservations collection that never touches stocks/reports/GL. StockReservation carries orderId/orderItemId/itemId/warehouseId, quantity, reservedQuantity, releasedQuantity, status (StockReservationStatus).

// fulfillment.service.ts — ATP is read-only; never mutates stock
availableToPromise(itemId, branchId?) = stockSvc.balance({ itemId, branchId })       // on-hand
                                        − Σ active(reservedQuantity − releasedQuantity)

reserveOrderStock(orderId) reserves the remaining ordered qty per line (idempotent), throwing Insufficient available-to-promise when toReserve > ATP unless company.fulfillmentAllowOversell. Reservation status flows ACTIVE → RELEASED/CONSUMED/CANCELLED. This is the only availability enforcement in the inventory domain (the sales-posting guard being disabled, §4.4).

4.6 Costing engine (CostingService) and stock valuation

CostingService maintains cost and is keyed by item.costingMethod (STANDARD | AVERAGE | FIFO | LIFO, default AVERAGE inside CostingService, STANDARD inside the purchase/sales item services — note the inconsistency):

  • postReceipt(itemId, qty, unitCost, receiptRef, branchId, stockId?, orderId?) — on every receipt:
    • FIFO → push a costLayer {quantity, remainingQty: qty, unitCost} onto the target stock row (the explicit stockId, else the latest IN row for item+branch).
    • AVERAGE → no-op here (purchase service updates item.cost to the rolling average).
    • STANDARD → if unitCost ≠ item.cost, post a price variance journal (variance = (unitCost − standardCost) × qty, favourable when actual < standard).
  • postIssue(itemId, qty, branchId) → { unitCost, totalCost } — on every issue:
    • FIFOconsumeFifoLayers consumes open layers oldest-first; returns weighted-avg consumed cost.
    • AVERAGE/STANDARDitem.cost (or standard cost) × qty.
  • adjustCost(itemId, totalAdditionalCost, branchId) — landed-cost allocation: FIFO spreads the total across all open layers per remaining unit; AVERAGE adds to item.cost; STANDARD posts the full amount as a PPV journal.

Four valuation queries (StockRepository, all filtered by branchId+companyId, deleted ≠ true, surfaced via the *Valuation GraphQL queries):

Query Method How it values on-hand
FIFO findFifoValuation Unwinds costLayers with remainingQty > 0; value = Σ remainingQty × unitCost. Returns per-item layers + totalRemainingQty/Value. Items with live layers only.
AVCO (AVERAGE) findAvcoValuation balance = Σ IN − Σ OUT (netQuantity); filters items with costingMethod=AVERAGE; weighted avg = Σ order_items.amount / Σ order_items.netQuantity over PurchaseInvoiceItem lots (falls back to item.cost); totalValue = balance × avgCost.
LIFO findLifoValuation balance = Σ IN − Σ OUT; pulls PurchaseInvoiceItem purchase layers oldest-first, then in JS consumes newest-first by (Σ layer qty − balance) and returns the surviving oldest layers + values.
STANDARD findStandardValuation balance = Σ IN − Σ OUT; items whose costingMethod ∉ {AVERAGE,FIFO,LIFO}; totalValue = balance × item.cost.

AVCO/LIFO compute weighted/lot cost from order_items.amount (total line spend) rather than Stock.cost, because cost is 0 for non-fixed lines. This is a deliberate workaround documented in the repository.

4.7 Update & delete (all flows)

Because quantity is derived, edits/deletes just re-write or remove rows keyed by orderItemId:

  • Update lineStockService.updateItemStock(orderItem) / the per-flow updateTransferStock(..., "update") / updateAdjustmentStock(..., "update") re-finds the row by orderItemId (transfer also by type) and updates qty/cost/branch in place.
  • Delete line/documentStockService.deleteByOrderItemId(id) (single row) or stockSvc.deleteMany({ orderItemId }) (transfer removes both legs; adjustment its row). On-hand self-corrects because soft-deleted rows drop out of balance().

4.8 Transactionality

Each source flow wraps its line write in withRetryTransaction(...) so the source line, its Stock row(s), and the paired GL entries commit atomically in one Mongo session (setSession fans the session into stockSvc/accountSvc/transactionSvc). StockService.create() itself participates in the caller's session — it has no transaction of its own.


5. Permissions

  • The whole StockResolver is gated by @ApGqlAuthorize() (JWT + access-group RBAC; see ../../platform/permissions-access.md). No per-query CASL action is declared on the read queries, and there are no stock mutations to gate here.
  • Stock rows are written transitively through the source modules (purchase/sales/transfer/adjustment), which carry their own @ApGqlAuthorize() + @AuditMeta(...) on their mutations.
  • The REST download is gated by @ApiAuthorize().
  • Admin "Stock Movement" surfaces under the Branch/Store maintenance area; the related store/branch permission key is STORE_MAINTENANCE (see store/model.ts note: the admin "store" module is the BE Branch entity).

6. Flows

6.1 Purchase posting (Stock IN) — happy path

  1. Admin posts a Purchase Invoice (Order kind=PurchaseInvoice).
  2. OrderService.create dispatches to PurchaseService.checkout → persists Order + lines in a retry transaction.
  3. Per ITEM line: PurchaseItemService.addInvoiceItem (UOM-convert to base, compute waste) → addItemStock.
  4. addItemStock computes the new purchase average → StockService.create({ kind: PurchaseInvoice, type: IN, branchId, netQuantity, avgCost }) appends the IN row.
  5. Costing side effect: AVERAGE updates item.cost; FIFO pushes a cost layer (postReceipt).
  6. On-hand at branchId rises by netQuantity (next balance() read reflects it).

6.2 Sales posting (Stock OUT) — happy path + skips

  1. Admin posts a Sales Invoice (Order kind=SalesInvoice).
  2. Per line: SalesItemService.addInvoiceItemvalidateStockAvailability([line]) (no-op, §4.4) → resolve price/COGS → persist line → addItemStock.
  3. addItemStock: if !item.type.isInventoryItemreturn (no stock row); else StockService.create({ kind: SalesInvoice, type: OUT, branchId, netQuantity, cost, avgCost }), then back-link orderItem.stockId.
  4. On-hand at branchId falls by netQuantity. Can go negative (availability not enforced on post).
  5. Void/return: delete the sales line → deleteByOrderItemId removes the OUT row (on-hand restores); FIFO re-pushes a layer to reinstate consumed cost.

6.3 Transfer (OUT@from + IN@to) — happy + unhappy

  1. Admin posts a Stock Transfer (fromBranchId, toBranchId, lines).
  2. Per line (retry txn): assertBaseUomOnly → availability guard totalQuantity({fromBranch, item}) >= qty else throw "Insufficient stock".
  3. Write OUT @from then IN @to (kind=StockTransfer), plus paired inventory GL legs; assert balanced.
  4. Net company on-hand unchanged; from-branch −qty, to-branch +qty. No in-transit state — both legs exist immediately.

6.4 Adjustment (IN or OUT by sign)

  1. Admin posts a Stock Adjustment (line: quantitynewQuantity).
  2. Per line (retry txn): diff = newQuantity − quantity; write one row type = diff>0 ? IN : OUT, qty = |diff|, kind=StockAdjustment, plus a balanced inventory GL entry.

6.5 Read/report

  1. Admin opens Stock Movement (report/stock-movement.tsx) → stockPage(filter) + stockSummary(filter).
  2. Each row's inStock / grossQuantityBefore are resolved by re-aggregating the ledger up to that row (lessThanDate).
  3. Valuation pages call fifoValuation/avcoValuation/lifoValuation/standardValuation(branchId).
  4. XLSX export hits GET /api/stock/movement-detail-download.

7. Admin UI

Pages/routes:

  • src/pages/report/stock-movement.tsx → renders the stock StockPage (module src/modules/inventory/stock/page.tsx). Also src/pages/item/[_id]/stock.tsx (per-item movement).
  • Valuation report pages: src/pages/report/{fifo,avco,lifo,standard}-valuation.tsx, inventory-valuation-detail.tsx, inventory-valuation-summary.tsx, stock-movement-summary.tsx, inventory-count.tsx.
  • Transfer/adjustment maintenance: src/pages/stock/transfer/{index,[_id]}.tsx, src/pages/stock/adjustments/{index,[_id]}.tsx (their own modules; they write the ledger).

Module screen (page.tsx — "Stock Movement"):

  • Header with ApDurationPicker (TODAY / ranges → sets fromDate/toDate) and an ApDownloadButton2 (PDF/XLSX → /stock/movement-detail-download).
  • A StockSummary card (components/stockSummary.tsx) bound to summary (stockIn/stockOut count/weight/cost).
  • Filters: In/Out (ALL / STOCK IN=PurchaseInvoice / STOCK OUT=SalesInvoice → sets kind), Item, plus Cost Center / Class / Analysis Code master filters.
  • StockTable (components/table.tsx): columns Item / Category / Store / Sold By / Stock (IN|OUT) (type over kind) / Qty (grossQuantity) / Cost (PurchaseInvoicecost × netQuantity, else avgCost × grossQuantity) / Stock (inStock + grossQuantityBefore, suffixed g when soldBy=WEIGHT) / Created At. Server-side pagination via filter.page/pageSize.

State (context.tsxuseStockState()): read-only — stocks, summary, totalRecords, filter, plus stockPage(filter) and stockSummary(filter). No create/update/delete here (the stock ledger is never edited directly from this screen). gql/query.ts exposes stockPage / stockSummary lazy queries (fetchPolicy: no-cache) and the StockFragment.

Store module (src/modules/store): the admin "Store" is the BE Branch entity (branchPage/createBranch/…, permission STORE_MAINTENANCE). It supplies the store/branch select used to scope stock, and a store report (IStoreReport) with totalStockValue, stockBalance, stockValue, profit margins — i.e. per-branch valuation surfaced through the branch report, not the stock module.


8. Dependencies & integrations

  • Imports/uses: ItemModule (item cost, soldBy, costingMethod, accounts), BranchModule (store resolve-field), ItemCategoryModule (report), AccountTransactionModule (CostingService posts standard-cost variance JEs), StockAdjustmentModule. Wired in stock.module.ts with forwardRef (circular deps with item/order).
  • Called by (writers): inventory/purchase/item, inventory/sales/item, inventory/transfer/item, inventory/adjustment/item, manufacturing/production (backflush). inventory/fulfillment reads balance() for ATP.
  • Exports: StockService, CostingService, the Mongoose feature module.
  • GL side effects: transfer & adjustment item services post balanced inventory transactions (AccountTransactionKind.StockTransfer / .StockAdjustment); CostingService.postStandardCostVariance posts paired DEBIT/CREDIT journal entries against item.inventoryAccountId / costOfSalesAccountId.
  • No cron/queue/external service in this module. The XLSX export is synchronous (exceljs via the base controller).
  • StockModule.onModuleInit is an empty migration stub (commented out).

9. Gotchas & project-specific rules

  • Sales availability is NOT enforced. validateStockAvailability is entirely commented out — sales posting can drive on-hand negative. Only transfers (totalQuantity guard) and reservations (ATP) enforce availability. If a rebuild needs hard stock-out blocking on sales, re-enable/port that guard.
  • grossQuantity resolve-field returns netQuantity. StockResolver.grossQuantity() returns args.netQuantity (not args.grossQuantity). The stored column is correct; the GraphQL resolve-field aliases it to net. Be careful relying on grossQuantity over GraphQL.
  • balance() is "subtract ins from outs" with confusingly named buckets (totalGrossQuantity = inbound, totalNetQuantity = outbound) and an kind OR type classifier. Both buckets sum netQuantity. Use type as the authoritative direction.
  • Always base UOM in the ledger. Callers convert before writing; transfer/adjustment hard-reject any uomId/uomQuantity/conversionFactor (assertBaseUomOnly).
  • avgCost auto-defaults to item.cost on non-transfer rows missing it (StockService.create). Transfers intentionally bypass this.
  • Costing-method default is inconsistent: CostingService defaults to AVERAGE; the purchase/sales item services default to STANDARD when item.costingMethod is unset. Set it explicitly on items to avoid surprises.
  • AVCO/LIFO value from order_items.amount, not Stock.cost (cost is 0 on non-fixed lines). Valuation depends on the order_items collection, not just stocks.
  • Per-branch balances are independent. No global "company on-hand" — always pass branchId (or aggregate across branches yourself). Valuation queries are mandatory-branchId.
  • Admin StockKindTypes enum is stale (SalesOrder/SalesInvoiceItem, missing SalesInvoice). Trust the BE enum / schema.gql.
  • StockCount and OrderReturn kinds have no writer in the stock module — they are enum values for flows handled elsewhere (returns via order kinds; counts not implemented as a stock writer here).
  • Manufacturing backflush is the one writer that mutates the legacy item.stockOut/netQuantity counters — every other flow leaves those denormalized fields untouched (trust the ledger, per ./_overview.md).
  • Soft delete drives reversibility. Deleting a line/document soft-deletes its row(s); on-hand self-corrects with no recompute. There is no separate "reverse" document type for ordinary edits.