Stock — the append-only inventory ledger
The whole stock model reduces to one idea: every inbound or outbound of inventory is one
Stockrow.type: INadds quantity atbranchId,type: OUTremoves it. There is no mutable "current quantity" field — on-hand is always derived by aggregating the ledger (Σ netQuantity(IN) − Σ netQuantity(OUT), scoped byitemId+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
Stockschema 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(theItem.stockIn/stockOut/netQuantityfields are legacy/denormalized — trust the ledger; see./item.md). - Available-to-promise / reservations are a separate collection (
stock_reservations) that never touchesstocks(§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.tsStockKindTypesis out of sync with the BE: it containsSalesOrderandSalesInvoiceItemand omitsSalesInvoice/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 hardcodesPurchaseInvoice/SalesInvoicestrings.
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.itemandstoreare 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 bothbranchIdandcompanyId. - Soft delete:
mongoose-deletewithdeletedAt. 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 |
StockPageResult — one 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 fromfromBranchIdtotoBranchId.
Two subtleties for a faithful rebuild:
- The IN/OUT classification is
kind == PurchaseInvoice OR type == IN(andkind == SalesInvoice OR type == OUT). Because purchase rows are alwaysINand sales rows alwaysOUT, this is equivalent to a puretypetest for those flows — but thekindclause means a row's classification could be pulled into the wrong bucket only if itstypeever disagreed with itskind(it doesn't in current writers). Treattypeas authoritative; thekindOR-clause is belt-and-suspenders. - The internal field names are confusingly swapped (
totalGrossQuantity= inbound,totalNetQuantity= outbound). Both sumnetQuantity. The finalstockBalance = inbound − outbound.
Sibling read aggregations (stock.repository.ts):
totalQuantity(query, weightType="grossQuantity")→Σ grossQuantity(ornetQuantity) over the filtered set (no IN/OUT netting — it sums the filtered rows, so callers passkind/typeto scope).totalCost(query)→Σ (cost × netQuantity).count(query)→ row count.getAverage(itemId, kind)(service) →totalCost / totalQuantityfor 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)?.wasteQuantitylessThanDate 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; FIFO → CostingService.postReceipt(...) pushes a cost layer onto the new stock row.
Sales (addItemStock, OUT). Two guards, then write:
- Inventory-item check —
if (!item.type.isInventoryItem) return;→ service/non-stock lines write no stock row. - Writes
stockSvc.create({ ...model, branchId, avgCost: unitCost, cost: model.cost, kind: SalesInvoice, orderItemId: model._id, type: OUT }), then back-linksorderItem.stockId = newStock._id. - FIFO returns/voids re-push layers via
postReceipt(..., "void-sale-…"). - Availability —
salesSvc.validateStockAvailability([model])is called before the line is created, but its body is entirely commented out (it builds an emptyerrorsarray and never throws). In the current code sales can drive on-hand negative. (ThegetItemStockBalancequery 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 acostLayer {quantity, remainingQty: qty, unitCost}onto the target stock row (the explicitstockId, else the latest IN row for item+branch).AVERAGE→ no-op here (purchase service updatesitem.costto the rolling average).STANDARD→ ifunitCost ≠ item.cost, post a price variance journal (variance = (unitCost − standardCost) × qty, favourable when actual < standard).
postIssue(itemId, qty, branchId) → { unitCost, totalCost }— on every issue:FIFO→consumeFifoLayersconsumes open layers oldest-first; returns weighted-avg consumed cost.AVERAGE/STANDARD→item.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 toitem.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 thanStock.cost, becausecostis0for non-fixedlines. 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 line →
StockService.updateItemStock(orderItem)/ the per-flowupdateTransferStock(..., "update")/updateAdjustmentStock(..., "update")re-finds the row byorderItemId(transfer also bytype) and updates qty/cost/branch in place. - Delete line/document →
StockService.deleteByOrderItemId(id)(single row) orstockSvc.deleteMany({ orderItemId })(transfer removes both legs; adjustment its row). On-hand self-corrects because soft-deleted rows drop out ofbalance().
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
StockResolveris 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(seestore/model.tsnote: the admin "store" module is the BEBranchentity).
6. Flows
6.1 Purchase posting (Stock IN) — happy path
- Admin posts a Purchase Invoice (
Order kind=PurchaseInvoice). OrderService.createdispatches toPurchaseService.checkout→ persistsOrder+ lines in a retry transaction.- Per ITEM line:
PurchaseItemService.addInvoiceItem(UOM-convert to base, compute waste) →addItemStock. addItemStockcomputes the new purchase average →StockService.create({ kind: PurchaseInvoice, type: IN, branchId, netQuantity, avgCost })appends the IN row.- Costing side effect:
AVERAGEupdatesitem.cost;FIFOpushes a cost layer (postReceipt). - On-hand at
branchIdrises bynetQuantity(nextbalance()read reflects it).
6.2 Sales posting (Stock OUT) — happy path + skips
- Admin posts a Sales Invoice (
Order kind=SalesInvoice). - Per line:
SalesItemService.addInvoiceItem→validateStockAvailability([line])(no-op, §4.4) → resolve price/COGS → persist line →addItemStock. addItemStock: if!item.type.isInventoryItem→ return (no stock row); elseStockService.create({ kind: SalesInvoice, type: OUT, branchId, netQuantity, cost, avgCost }), then back-linkorderItem.stockId.- On-hand at
branchIdfalls bynetQuantity. Can go negative (availability not enforced on post). - Void/return: delete the sales line →
deleteByOrderItemIdremoves the OUT row (on-hand restores); FIFO re-pushes a layer to reinstate consumed cost.
6.3 Transfer (OUT@from + IN@to) — happy + unhappy
- Admin posts a Stock Transfer (
fromBranchId,toBranchId, lines). - Per line (retry txn):
assertBaseUomOnly→ availability guardtotalQuantity({fromBranch, item}) >= qtyelse throw "Insufficient stock". - Write OUT @from then IN @to (
kind=StockTransfer), plus paired inventory GL legs; assert balanced. - 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)
- Admin posts a Stock Adjustment (line:
quantity→newQuantity). - Per line (retry txn):
diff = newQuantity − quantity; write one rowtype = diff>0 ? IN : OUT,qty = |diff|,kind=StockAdjustment, plus a balanced inventory GL entry.
6.5 Read/report
- Admin opens Stock Movement (
report/stock-movement.tsx) →stockPage(filter)+stockSummary(filter). - Each row's
inStock/grossQuantityBeforeare resolved by re-aggregating the ledger up to that row (lessThanDate). - Valuation pages call
fifoValuation/avcoValuation/lifoValuation/standardValuation(branchId). - XLSX export hits
GET /api/stock/movement-detail-download.
7. Admin UI
Pages/routes:
src/pages/report/stock-movement.tsx→ renders the stockStockPage(modulesrc/modules/inventory/stock/page.tsx). Alsosrc/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 → setsfromDate/toDate) and anApDownloadButton2(PDF/XLSX →/stock/movement-detail-download). - A
StockSummarycard (components/stockSummary.tsx) bound tosummary(stockIn/stockOut count/weight/cost). - Filters: In/Out (
ALL/STOCK IN=PurchaseInvoice/STOCK OUT=SalesInvoice→ setskind), Item, plus Cost Center / Class / Analysis Code master filters. StockTable(components/table.tsx): columns Item / Category / Store / Sold By / Stock (IN|OUT) (typeoverkind) / Qty (grossQuantity) / Cost (PurchaseInvoice→cost × netQuantity, elseavgCost × grossQuantity) / Stock (inStock+grossQuantityBefore, suffixedgwhensoldBy=WEIGHT) / Created At. Server-side pagination viafilter.page/pageSize.
State (context.tsx → useStockState()): 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(CostingServiceposts standard-cost variance JEs),StockAdjustmentModule. Wired instock.module.tswithforwardRef(circular deps with item/order). - Called by (writers):
inventory/purchase/item,inventory/sales/item,inventory/transfer/item,inventory/adjustment/item,manufacturing/production(backflush).inventory/fulfillmentreadsbalance()for ATP. - Exports:
StockService,CostingService, the Mongoose feature module. - GL side effects: transfer & adjustment item services post balanced inventory transactions (
AccountTransactionKind.StockTransfer/.StockAdjustment);CostingService.postStandardCostVarianceposts paired DEBIT/CREDIT journal entries againstitem.inventoryAccountId/costOfSalesAccountId. - No cron/queue/external service in this module. The XLSX export is synchronous (
exceljsvia the base controller). StockModule.onModuleInitis an empty migration stub (commented out).
9. Gotchas & project-specific rules
- Sales availability is NOT enforced.
validateStockAvailabilityis entirely commented out — sales posting can drive on-hand negative. Only transfers (totalQuantityguard) and reservations (ATP) enforce availability. If a rebuild needs hard stock-out blocking on sales, re-enable/port that guard. grossQuantityresolve-field returnsnetQuantity.StockResolver.grossQuantity()returnsargs.netQuantity(notargs.grossQuantity). The stored column is correct; the GraphQL resolve-field aliases it to net. Be careful relying ongrossQuantityover GraphQL.balance()is "subtract ins from outs" with confusingly named buckets (totalGrossQuantity= inbound,totalNetQuantity= outbound) and ankind OR typeclassifier. Both buckets sumnetQuantity. Usetypeas the authoritative direction.- Always base UOM in the ledger. Callers convert before writing; transfer/adjustment hard-reject any
uomId/uomQuantity/conversionFactor(assertBaseUomOnly). avgCostauto-defaults toitem.coston non-transfer rows missing it (StockService.create). Transfers intentionally bypass this.- Costing-method default is inconsistent:
CostingServicedefaults toAVERAGE; the purchase/sales item services default toSTANDARDwhenitem.costingMethodis unset. Set it explicitly on items to avoid surprises. - AVCO/LIFO value from
order_items.amount, notStock.cost(cost is 0 on non-fixedlines). Valuation depends on theorder_itemscollection, not juststocks. - Per-branch balances are independent. No global "company on-hand" — always pass
branchId(or aggregate across branches yourself). Valuation queries are mandatory-branchId. - Admin
StockKindTypesenum is stale (SalesOrder/SalesInvoiceItem, missingSalesInvoice). Trust the BE enum /schema.gql. StockCountandOrderReturnkinds 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/netQuantitycounters — 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.