Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions app/actions/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@/lib/cost-layers'
import { sliceTransferSnapshotForReceipt } from '@/lib/domain/wms/asn-reconciliation'
import { toInventoryConstraintMessage } from '@/lib/domain/inventory/prisma-errors'
import { canDispatchTransferQty } from '@/lib/domain/inventory/transfer-availability'
import { addMoney, multiplyMoney, roundQuantity, toDecimal } from '@/lib/domain/math/decimal'
import { serializeCostLayerSnapshot } from '@/lib/cost-layer-snapshots'
import {
Expand Down Expand Up @@ -388,9 +389,13 @@ export async function dispatchTransfer(id: string): Promise<TransferResult> {
where: { productId_warehouseId: { productId: line.productId, warehouseId: transfer.fromWarehouseId } },
select: { quantity: true, reservedQty: true },
})
const available = level ? Number(level.quantity) - Number(level.reservedQty) : 0
if (available < qty) {
throw new Error(`Insufficient stock for ${line.sku}: ${available} available, ${qty} requested`)
// audit-M-stock #1: net the source warehouse's reserved (allocated)
// quantity so a transfer can't drain stock an order is holding there.
if (!canDispatchTransferQty(level?.quantity, level?.reservedQty, qty)) {
// Report the raw (unclamped) delta so an over-reservation (negative)
// stays a visible diagnostic, not silently shown as 0.
const rawAvailable = Number(level?.quantity ?? 0) - Number(level?.reservedQty ?? 0)
throw new Error(`Insufficient stock for ${line.sku}: ${rawAvailable} available, ${qty} requested`)
}
}

Expand Down
26 changes: 26 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,29 @@ refund payments.

Stock transfer status tracks inter-warehouse movement. `IN_TRANSIT` means stock
has left the source warehouse and is not yet available at the destination.

**Stock / concurrency invariants (audit-M-stock).** A few cross-cutting guards
worth knowing:

- **Transfers don't strand allocations.** Dispatch availability is the source
warehouse's on-hand minus its `reservedQty` (`availableForTransfer`), and
`StockLevel.reservedQty` is per-(product, warehouse) and kept in sync with
order allocations — so a transfer can never drain stock an order is holding in
that warehouse.
- **Manual receipt + WMS booked-in don't double-count.** `reconcileBookedInQuantities`
nets the locally-received quantity (read under a `FOR UPDATE` lock on the PO or
the stock transfer) so a manual receipt already covering a line is not re-added
when the Mintsoft booked-in webhook approves the same ASN.
- **Opening stock can't duplicate.** `applyOpeningStock` takes a `FOR UPDATE`
lock on the stock level before checking for an existing opening cost layer, so
concurrent calls serialise and the second is rejected.
- **Non-negative stock in the DB.** `quantity >= 0` and `reservedQty >= 0` are
fully VALIDATEd CHECK constraints (every existing row checked). `reservedQty <=
quantity` is a `NOT VALID` constraint — enforced on new writes but not yet
validated against historical rows (that cleanup is deferred), so the transfer
guard above (not just the constraint) is what protects allocations at dispatch.
- **FIFO ordering at the destination** of a received transfer follows the
dispatch-time cost-layer snapshot order. If transfers are received out of
dispatch order the recreated layers can be marginally out of strict FIFO order
at the destination — this is cosmetic for reporting and accepted (the cost
basis per layer is preserved).
37 changes: 37 additions & 0 deletions lib/domain/inventory/transfer-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// decimal-boundary-ok: server-action-boundary (numeric stock availability check)
import { decimalToNumber, type DecimalLike } from '@/lib/decimal'

// ---------------------------------------------------------------------------
// Transfer-dispatch availability (audit-M-stock #1)
//
// A transfer must not drain stock that an order has already reserved in the
// SOURCE warehouse, or the order is stranded. StockLevel.reservedQty is
// per-(product, warehouse) and kept in sync with order allocations, so the
// available-to-transfer quantity is the warehouse's on-hand minus its reserved
// — netting out exactly the allocations pointing at that warehouse. Centralised
// here so the rule is explicit and regression-tested rather than an inline
// subtraction.
// ---------------------------------------------------------------------------

/**
* On-hand minus reserved for a single (product, warehouse) stock level, clamped
* to >= 0. Correct for dispatch gating; do NOT reuse for data-integrity checks —
* the clamp hides an over-reservation (reservedQty > quantity), which raw
* subtraction would surface as a negative.
*/
export function availableForTransfer(
quantity: DecimalLike | null | undefined,
reservedQty: DecimalLike | null | undefined,
): number {
const available = decimalToNumber(quantity ?? 0) - decimalToNumber(reservedQty ?? 0)
return available > 0 ? available : 0
}

/** Whether `requestedQty` can be dispatched without eating into reserved (allocated) stock. */
export function canDispatchTransferQty(
quantity: DecimalLike | null | undefined,
reservedQty: DecimalLike | null | undefined,
requestedQty: number,
): boolean {
return requestedQty <= availableForTransfer(quantity, reservedQty)
}
32 changes: 32 additions & 0 deletions tests/domain/inventory/transfer-availability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import { Prisma } from '@/app/generated/prisma/client'
import { availableForTransfer, canDispatchTransferQty } from '@/lib/domain/inventory/transfer-availability'

test('availableForTransfer nets reserved (allocated) qty out of on-hand', () => {
assert.equal(availableForTransfer(new Prisma.Decimal('100'), new Prisma.Decimal('30')), 70)
})

test('availableForTransfer never goes negative', () => {
assert.equal(availableForTransfer(new Prisma.Decimal('10'), new Prisma.Decimal('15')), 0)
})

test('availableForTransfer treats missing level as zero', () => {
assert.equal(availableForTransfer(null, null), 0)
assert.equal(availableForTransfer(undefined, undefined), 0)
})

test('canDispatchTransferQty: cannot dispatch into reserved stock', () => {
// 100 on hand, 30 reserved for an order → only 70 transferable.
assert.equal(canDispatchTransferQty(new Prisma.Decimal('100'), new Prisma.Decimal('30'), 70), true)
assert.equal(canDispatchTransferQty(new Prisma.Decimal('100'), new Prisma.Decimal('30'), 71), false)
})

test('canDispatchTransferQty: full unreserved stock is transferable', () => {
assert.equal(canDispatchTransferQty(new Prisma.Decimal('50'), new Prisma.Decimal('0'), 50), true)
})

test('canDispatchTransferQty: rejects when over-reserved (reservedQty > quantity)', () => {
assert.equal(canDispatchTransferQty(new Prisma.Decimal('10'), new Prisma.Decimal('15'), 1), false)
})
Loading