diff --git a/app/__tests__/integration/fractionalStock.integration.test.ts b/app/__tests__/integration/fractionalStock.integration.test.ts new file mode 100644 index 00000000..f89ff48e --- /dev/null +++ b/app/__tests__/integration/fractionalStock.integration.test.ts @@ -0,0 +1,192 @@ +// /app/__tests__/integration/fractionalStock.integration.test.ts +// +// A roll of fabric is a length, not a count. +// +// The selling side has always been Decimal -- OrderLineItem.orderedQuantity and +// fulfilledQty both -- while InventoryPosition.quantity and +// InventoryTransfer.quantity were Int. So holt could take an order for 12.5 +// yards and could not hold 12.5 yards, and the mismatch was invisible until +// somebody received a part roll: every count, transfer and allocation silently +// rounded. +// +// These tests buy, hold, allocate, split and consume fractional stock, and +// assert the eighth-of-a-yard case explicitly, because 0.125 is the smallest +// unit fabric is actually sold in. + +import { prisma } from "@/lib/prisma"; +import { resetTestDb } from "@/lib/testing/withTestDb"; +import { allocate, consume, availableQuantity, planDraw } from "@/lib/inventory/allocation"; +import { toQty, roundQty, qtyEquals, qtyIsZero } from "@/lib/inventory/quantity"; + +let productId: number; +let storeId: number; + +beforeEach(async () => { + await resetTestDb(); + const store = await prisma.storeLocation.create({ + data: { name: "Mill Shop", code: "MILL", type: "STORE" }, + }); + const vendor = await prisma.vendor.create({ + data: { name: "Weaver", code: "WV", pricingModel: "FLAT" }, + }); + const department = await prisma.department.create({ data: { name: "Textiles" } }); + const category = await prisma.category.create({ + data: { name: "Drapery", departmentId: department.id }, + }); + const product = await prisma.product.create({ + data: { + productNumber: "LINEN-01", + name: "Belgian Linen", + vendorId: vendor.id, + departmentId: department.id, + categoryId: category.id, + }, + }); + productId = product.id; + storeId = store.id; +}); + +async function bolt(yards: number) { + return prisma.inventoryPosition.create({ + data: { productId, storeLocationId: storeId, quantity: yards }, + }); +} + +async function orderFor(yards: number, orderno: string) { + const customer = await prisma.customer.create({ + data: { firstName: "Yardage", lastName: "Buyer" }, + }); + return prisma.salesOrder.create({ + data: { + orderno, + status: "ORDER", + orderDate: new Date(), + customerId: customer.id, + storeLocation: "Mill Shop", + lineItems: { + create: [ + { + lineNumber: 1, + productId, + productName: "Belgian Linen", + orderedQuantity: yards, + netPrice: yards * 48, + cost: yards * 22, + vatRate: 0, + vatAmount: 0, + }, + ], + }, + }, + }); +} + +describe("fractional stock (real DB)", () => { + it("holds a part roll", async () => { + await bolt(37.5); + expect(await availableQuantity(productId, storeId, prisma)).toBe(37.5); + }); + + it("sells to the eighth of a yard", async () => { + // 0.125 is the smallest unit fabric is sold in. It is exactly representable + // at 3dp, which is why the column is Decimal(12,3) and not (12,2) -- two + // places would round an eighth to 0.13 and lose stock on every cut. + await bolt(10); + const order = await orderFor(2.125, "SO-EIGHTH"); + + const result = await prisma.$transaction((tx) => + allocate(order.id, [{ productId, storeLocationId: storeId, quantity: 2.125 }], tx), + ); + expect(result.shortfalls).toHaveLength(0); + + const held = await prisma.inventoryPosition.findFirst({ + where: { salesOrderId: order.id }, + }); + expect(toQty(held?.quantity)).toBe(2.125); + expect(await availableQuantity(productId, storeId, prisma)).toBe(7.875); + }); + + it("cuts one order across two bolts and leaves the remnant sellable", async () => { + await bolt(8.25); + await bolt(12.5); + const order = await orderFor(15, "SO-TWO-BOLTS"); + + await prisma.$transaction((tx) => + allocate(order.id, [{ productId, storeLocationId: storeId, quantity: 15 }], tx), + ); + + // 8.25 from the first bolt, 6.75 from the second. The 5.75 left on the + // second is still free stock -- a remnant somebody can still buy. + expect(await availableQuantity(productId, storeId, prisma)).toBe(5.75); + + const held = await prisma.inventoryPosition.findMany({ + where: { salesOrderId: order.id }, + }); + const committed = held.reduce((sum, p) => sum + toQty(p.quantity), 0); + expect(roundQty(committed)).toBe(15); + }); + + it("shorts honestly rather than rounding", async () => { + // The sale always wins: allocate what exists, record the rest as a + // shortfall. Before, a request for 4.5 against 4.2 on hand could round. + await bolt(4.2); + const order = await orderFor(4.5, "SO-SHORT"); + + const result = await prisma.$transaction((tx) => + allocate(order.id, [{ productId, storeLocationId: storeId, quantity: 4.5 }], tx), + ); + + expect(result.shortfalls).toHaveLength(1); + expect(result.shortfalls[0].allocated).toBe(4.2); + expect(result.shortfalls[0].shortfall).toBe(0.3); + }); + + it("consumes a fractional allocation completely, leaving no sliver", async () => { + // An exhausted position must be deleted, not left at 0.0000001 -- present + // in every count, sellable to nobody. + await bolt(6.375); + const order = await orderFor(6.375, "SO-EXACT"); + + await prisma.$transaction((tx) => + allocate(order.id, [{ productId, storeLocationId: storeId, quantity: 6.375 }], tx), + ); + await prisma.$transaction((tx) => consume(order.id, [{ productId, quantity: 6.375 }], tx)); + + const left = await prisma.inventoryPosition.findMany({ where: { productId } }); + expect(left).toHaveLength(0); + expect(await availableQuantity(productId, storeId, prisma)).toBe(0); + }); + + it("moves a part roll between locations", async () => { + const user = await prisma.user.create({ data: { email: "wh@example.test" } }); + await prisma.inventoryTransfer.create({ + data: { + productId, + quantity: 18.75, + fromLocation: "MILL", + toLocation: "SHOWROOM", + requestedByUserId: user.id, + status: "RECEIVED", + }, + }); + const t = await prisma.inventoryTransfer.findFirstOrThrow({ where: { productId } }); + expect(toQty(t.quantity)).toBe(18.75); + }); +}); + +describe("quantity arithmetic", () => { + it("treats rounding noise as nothing, not as stock", () => { + expect(qtyIsZero(0.0001)).toBe(true); + expect(qtyIsZero(0.001)).toBe(false); + expect(qtyEquals(2.125, 2.1250004)).toBe(true); + }); + + it("planDraw exhausts a position it has fully drawn, despite float error", () => { + // 0.1 + 0.2 is the canonical float trap. If `exhausts` used ===, the + // position survives at a sliver and is never cleaned up. + const plan = planDraw([{ id: 1, quantity: 0.3 }], 0.1 + 0.2); + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0].exhausts).toBe(true); + expect(plan.shortfall).toBe(0); + }); +}); diff --git a/app/__tests__/integration/inventoryAllocation.integration.test.ts b/app/__tests__/integration/inventoryAllocation.integration.test.ts index 73a5ff60..a35c23ef 100644 --- a/app/__tests__/integration/inventoryAllocation.integration.test.ts +++ b/app/__tests__/integration/inventoryAllocation.integration.test.ts @@ -15,6 +15,7 @@ // The merge cases below are therefore the point of this file, not decoration. import { prisma } from "@/lib/prisma"; +import { toQty } from "@/lib/inventory/quantity"; import { resetTestDb } from "@/lib/testing/withTestDb"; import { allocate, availableQuantity, consume, release } from "@/lib/inventory/allocation"; @@ -108,8 +109,8 @@ describe("inventory allocation (real DB)", () => { const free = await freeRows(product.id); const committed = await prisma.inventoryPosition.findMany({ where: { salesOrderId: order.id } }); - expect(free.map((r) => r.quantity)).toEqual([3]); - expect(committed.map((r) => r.quantity)).toEqual([2]); + expect(free.map((r) => toQty(r.quantity))).toEqual([3]); + expect(committed.map((r) => toQty(r.quantity))).toEqual([2]); }); it("cancelling MERGES stock back instead of fragmenting it", async () => { @@ -125,7 +126,7 @@ describe("inventory allocation (real DB)", () => { const free = await freeRows(product.id); expect(free).toHaveLength(1); - expect(free[0].quantity).toBe(5); + expect(toQty(free[0].quantity)).toBe(5); expect(await availableQuantity(product.id, store.id, prisma)).toBe(5); }); @@ -143,7 +144,7 @@ describe("inventory allocation (real DB)", () => { const free = await freeRows(product.id); expect(free).toHaveLength(1); - expect(free[0].quantity).toBe(4); + expect(toQty(free[0].quantity)).toBe(4); }); it("allocating twice for one order merges into a single committed row", async () => { @@ -158,7 +159,7 @@ describe("inventory allocation (real DB)", () => { const committed = await prisma.inventoryPosition.findMany({ where: { salesOrderId: order.id } }); expect(committed).toHaveLength(1); - expect(committed[0].quantity).toBe(5); + expect(toQty(committed[0].quantity)).toBe(5); expect(await availableQuantity(product.id, store.id, prisma)).toBe(4); }); @@ -280,7 +281,7 @@ describe("inventory allocation (real DB)", () => { const heldRow = await prisma.inventoryPosition.findFirst({ where: { productId: product.id, stockLocationId: held.id }, }); - expect(heldRow!.quantity).toBe(6); + expect(toQty(heldRow!.quantity)).toBe(6); expect(heldRow!.salesOrderId).toBeNull(); }); @@ -303,7 +304,7 @@ describe("inventory allocation (real DB)", () => { ]); // It took what existed rather than refusing or taking nothing. const committed = await prisma.inventoryPosition.findMany({ where: { salesOrderId: order.id } }); - expect(committed[0].quantity).toBe(1); + expect(toQty(committed[0].quantity)).toBe(1); }); it("a return line allocates nothing", async () => { diff --git a/app/__tests__/integration/inventoryOrderWiring.integration.test.ts b/app/__tests__/integration/inventoryOrderWiring.integration.test.ts index 1f57f675..87683a14 100644 --- a/app/__tests__/integration/inventoryOrderWiring.integration.test.ts +++ b/app/__tests__/integration/inventoryOrderWiring.integration.test.ts @@ -23,6 +23,7 @@ // apiRouteAuthorization tripwire. import type { NextApiRequest, NextApiResponse } from "next"; +import { toQty } from "@/lib/inventory/quantity"; import type { Session } from "next-auth"; import { prisma } from "@/lib/prisma"; import { resetTestDb } from "@/lib/testing/withTestDb"; @@ -134,7 +135,7 @@ describe("inventory order wiring (real DB)", () => { expect(await availableQuantity(product.id, store.id, prisma)).toBe(3); const committed = await prisma.inventoryPosition.findMany({ where: { salesOrderId: orderId } }); expect(committed).toHaveLength(1); - expect(committed[0].quantity).toBe(2); + expect(toQty(committed[0].quantity)).toBe(2); }); it("a return line in the cart never allocates", async () => { @@ -376,7 +377,7 @@ describe("inventory order wiring (real DB)", () => { // 2 (original) + 1 (new line) = 3 committed, 2 left free. const committed = await prisma.inventoryPosition.findMany({ where: { salesOrderId: orderId } }); expect(committed).toHaveLength(1); - expect(committed[0].quantity).toBe(3); + expect(toQty(committed[0].quantity)).toBe(3); expect(await availableQuantity(product.id, store.id, prisma)).toBe(2); }); diff --git a/app/__tests__/integration/inventorySnapshotGenerate.integration.test.ts b/app/__tests__/integration/inventorySnapshotGenerate.integration.test.ts index 6432c0d3..50df46f2 100644 --- a/app/__tests__/integration/inventorySnapshotGenerate.integration.test.ts +++ b/app/__tests__/integration/inventorySnapshotGenerate.integration.test.ts @@ -19,6 +19,7 @@ // itself is covered by __tests__/roleDecision.test.ts. import type { NextApiRequest, NextApiResponse } from "next"; +import { toQty } from "@/lib/inventory/quantity"; import type { Session } from "next-auth"; import { prisma } from "@/lib/prisma"; import { resetTestDb } from "@/lib/testing/withTestDb"; @@ -133,7 +134,7 @@ describe("POST /api/inventory/snapshot/generate (real DB)", () => { const nativeRow = rows.find((r) => r.productId === nativeProduct.id); expect(nativeRow).toBeDefined(); expect(nativeRow?.storeLocationId).toBe(storeA.id); - expect(nativeRow?.quantity).toBe(4); + expect(toQty(nativeRow?.quantity)).toBe(4); }); it("a same-day re-run replaces LOCAL rows instead of duplicating or crashing", async () => { @@ -183,9 +184,9 @@ describe("POST /api/inventory/snapshot/generate (real DB)", () => { const rows = await prisma.inventorySnapshot.findMany(); expect(rows).toHaveLength(2); const importRow = rows.find((r) => r.source === "IMPORT"); - expect(importRow?.quantity).toBe(999); + expect(toQty(importRow?.quantity)).toBe(999); expect(importRow?.externalId).toBe(4242); const localRow = rows.find((r) => r.source === "LOCAL"); - expect(localRow?.quantity).toBe(4); + expect(toQty(localRow?.quantity)).toBe(4); }); }); diff --git a/app/__tests__/integration/tradingDay.integration.test.ts b/app/__tests__/integration/tradingDay.integration.test.ts index 4214f257..273ac48d 100644 --- a/app/__tests__/integration/tradingDay.integration.test.ts +++ b/app/__tests__/integration/tradingDay.integration.test.ts @@ -362,7 +362,7 @@ describe("a complete trading day (real DB)", () => { where: { salesOrderId: world.order.id }, }); expect(held).toHaveLength(2); - expect(held.every((p) => p.quantity === 1)).toBe(true); + expect(held.every((p) => Number(p.quantity) === 1)).toBe(true); }); it("7. moves the allocated stock to the backroom without orphaning it", async () => { @@ -376,7 +376,7 @@ describe("a complete trading day (real DB)", () => { await tx.inventoryTransfer.create({ data: { productId: pos.productId, - quantity: pos.quantity, + quantity: Number(pos.quantity), fromLocation: world.warehouse.code, toLocation: world.backroom.code, fromStockLocationId: world.warehouse.id, diff --git a/app/__tests__/integration/transferForOrder.integration.test.ts b/app/__tests__/integration/transferForOrder.integration.test.ts index 36018a6c..1e5b09e7 100644 --- a/app/__tests__/integration/transferForOrder.integration.test.ts +++ b/app/__tests__/integration/transferForOrder.integration.test.ts @@ -13,6 +13,7 @@ // units, which is exactly why nobody noticed. import { prisma } from "@/lib/prisma"; +import { toQty } from "@/lib/inventory/quantity"; import { resetTestDb } from "@/lib/testing/withTestDb"; import { availableQuantity } from "@/lib/inventory/allocation"; @@ -117,7 +118,7 @@ describe("receiving a transfer merges stock instead of fragmenting it", () => { where: { productId: product.id, storeLocationId: storeA.id, salesOrderId: null }, }); expect(rows).toHaveLength(1); - expect(rows[0].quantity).toBe(3); + expect(toQty(rows[0].quantity)).toBe(3); expect(await availableQuantity(product.id, storeA.id, prisma)).toBe(3); expect(storeB.id).toBeDefined(); }); diff --git a/app/prisma/migrations/20260825140000_fractional_stock/migration.sql b/app/prisma/migrations/20260825140000_fractional_stock/migration.sql new file mode 100644 index 00000000..308df0ec --- /dev/null +++ b/app/prisma/migrations/20260825140000_fractional_stock/migration.sql @@ -0,0 +1,16 @@ +-- Stock quantities become fractional, because a roll of fabric or wallpaper is +-- a length rather than a count. +-- +-- The selling side has always been Decimal (OrderLineItem.orderedQuantity, +-- fulfilledQty); the stock side was Int. That split is invisible until you +-- receive a part roll, and then every count, transfer and allocation rounds. +-- +-- 3dp because fabric is sold to the eighth of a yard (0.125), which is exact at +-- three places. Postgres widens integer -> numeric in place, so no data moves +-- and every existing whole-unit row is unchanged. +ALTER TABLE "InventoryPosition" + ALTER COLUMN "quantity" TYPE DECIMAL(12, 3) USING "quantity"::numeric, + ALTER COLUMN "quantity" SET DEFAULT 1; + +ALTER TABLE "InventoryTransfer" + ALTER COLUMN "quantity" TYPE DECIMAL(12, 3) USING "quantity"::numeric; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 0c05dd37..17d90ab4 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -1606,7 +1606,17 @@ model InventoryPosition { storeLocation StoreLocation @relation(fields: [storeLocationId], references: [id]) stockLocationId Int? stockLocation StockLocation? @relation(fields: [stockLocationId], references: [id]) - quantity Int @default(1) + /** + * Fractional, because a roll of fabric or wallpaper is a length, not a count. + * + * This was Int, which meant the SELLING side could handle 12.5 yards + * (OrderLineItem.orderedQuantity has always been Decimal) and the STOCK side + * could not. The split is invisible until you receive a part roll, and then + * every count, transfer and allocation silently rounds. + * + * 3dp because fabric is sold to the eighth of a yard (0.125), which is exact. + */ + quantity Decimal @default(1) @db.Decimal(12, 3) salesOrderId Int? salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id]) notes String? @@ -1694,7 +1704,9 @@ model InventoryTransfer { id Int @id @default(autoincrement()) productId Int product Product @relation(fields: [productId], references: [id]) - quantity Int + /** Fractional -- see InventoryPosition.quantity. A transfer of a part roll + * is the normal case for roll goods, not an edge case. */ + quantity Decimal @db.Decimal(12, 3) fromLocation String toLocation String fromLocationId Int? diff --git a/app/prisma/seed/demo/org.ts b/app/prisma/seed/demo/org.ts index aa0bb60e..ece62850 100644 --- a/app/prisma/seed/demo/org.ts +++ b/app/prisma/seed/demo/org.ts @@ -4,6 +4,7 @@ // feature flags a running store would actually have turned on. import type { PrismaClient } from "@prisma/client"; +import { assertKnownModules } from "@/lib/modules/registry"; const SEED_ACTOR = "seed:demo"; diff --git a/app/src/lib/inventory/allocation.ts b/app/src/lib/inventory/allocation.ts index ad82d75c..ff5f0209 100644 --- a/app/src/lib/inventory/allocation.ts +++ b/app/src/lib/inventory/allocation.ts @@ -68,6 +68,7 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import { logger } from "@/lib/logger"; +import { toQty, roundQty, qtyEquals, qtyIsZero } from "./quantity"; export type PrismaTx = PrismaClient | Prisma.TransactionClient; @@ -143,19 +144,32 @@ export interface DrawPlan { * No I/O, no ordering decisions (the caller supplies `positions` already in * the order it wants them drawn), just the arithmetic. */ +/** Prisma rows -> the plain shape planDraw works on. */ +function asDrawable(rows: { id: number; quantity: Prisma.Decimal | number }[]): PositionForDraw[] { + return rows.map((r) => ({ id: r.id, quantity: toQty(r.quantity) })); +} + export function planDraw(positions: PositionForDraw[], requested: number): DrawPlan { const steps: DrawStep[] = []; - let remaining = requested > 0 ? requested : 0; + let remaining = requested > 0 ? roundQty(requested) : 0; for (const position of positions) { - if (remaining <= 0) break; - const take = Math.min(remaining, position.quantity); - if (take <= 0) continue; - steps.push({ id: position.id, take, exhausts: take === position.quantity }); - remaining -= take; + if (qtyIsZero(remaining) || remaining < 0) break; + const take = roundQty(Math.min(remaining, position.quantity)); + if (qtyIsZero(take) || take < 0) continue; + // `qtyEquals`, not `===`. Quantities are fractional now, and exact float + // equality is how a position ends up stranded at 0.0000001 -- present in + // every count, sellable to nobody, and never cleaned up because it never + // reads as exhausted. + steps.push({ id: position.id, take, exhausts: qtyEquals(take, position.quantity) }); + remaining = roundQty(remaining - take); } - return { steps, totalTaken: requested > 0 ? requested - remaining : 0, shortfall: remaining }; + return { + steps, + totalTaken: requested > 0 ? roundQty(requested - remaining) : 0, + shortfall: remaining, + }; } // --------------------------------------------------------------------------- @@ -206,7 +220,7 @@ export async function availableQuantity( where: { ...freePositionWhere(), productId, storeLocationId }, _sum: { quantity: true }, }); - return result._sum.quantity ?? 0; + return toQty(result._sum.quantity); } /** @@ -244,7 +258,7 @@ export async function allocate( orderBy: { id: "asc" }, }); - const plan = planDraw(freePositions, line.quantity); + const plan = planDraw(asDrawable(freePositions), line.quantity); const byId = new Map(freePositions.map((p) => [p.id, p])); for (const step of plan.steps) { @@ -408,7 +422,7 @@ export async function consume( orderBy: { id: "asc" }, }); - const plan = planDraw(allocated, line.quantity); + const plan = planDraw(asDrawable(allocated), line.quantity); const byId = new Map(allocated.map((p) => [p.id, p])); for (const step of plan.steps) { diff --git a/app/src/lib/inventory/quantity.ts b/app/src/lib/inventory/quantity.ts new file mode 100644 index 00000000..33cea4f2 --- /dev/null +++ b/app/src/lib/inventory/quantity.ts @@ -0,0 +1,53 @@ +// /app/src/lib/inventory/quantity.ts +// +// Stock quantities are fractional, because a roll of fabric or wallpaper is a +// length rather than a count. This file owns the arithmetic so the rounding +// rule lives in exactly one place. +// +// Deliberately NOT lib/journalEntry.ts's round2(). That one is money: two +// decimal places, because a cent is the smallest thing you can owe. A quantity +// is not money and does not round like it -- fabric is sold to the eighth of a +// yard, so 0.125 has to survive a round trip, and 2dp would turn it into 0.13 +// and lose an eighth on every line. + +import { Prisma } from "@prisma/client"; + +/** Places kept on a stock quantity. Matches `@db.Decimal(12, 3)` in the schema. */ +export const QTY_DP = 3; + +const FACTOR = 10 ** QTY_DP; + +/** + * Anything smaller than this is rounding noise, not stock. + * + * Used instead of `===` when asking "did this draw take the whole position?". + * Exact equality on floats is how a position ends up stranded at 0.0000001 and + * never gets cleaned up -- present in every count, sellable to nobody. + */ +export const QTY_EPSILON = 1 / (FACTOR * 2); + +/** Prisma Decimal (or a plain number, or null) -> number. */ +export function toQty(value: Prisma.Decimal | number | null | undefined): number { + if (value == null) return 0; + return typeof value === "number" ? value : value.toNumber(); +} + +/** Round to the stock precision. */ +export function roundQty(n: number): number { + return Math.round(n * FACTOR) / FACTOR; +} + +/** True when two quantities are the same to within rounding noise. */ +export function qtyEquals(a: number, b: number): boolean { + return Math.abs(a - b) < QTY_EPSILON; +} + +/** True when a quantity is effectively zero -- nothing left to sell or move. */ +export function qtyIsZero(n: number): boolean { + return Math.abs(n) < QTY_EPSILON; +} + +/** A number on its way back into a Decimal column. */ +export function toDecimal(n: number): Prisma.Decimal { + return new Prisma.Decimal(roundQty(n).toFixed(QTY_DP)); +} diff --git a/app/src/lib/inventory/snapshot.ts b/app/src/lib/inventory/snapshot.ts index 267da24e..f7eba45e 100644 --- a/app/src/lib/inventory/snapshot.ts +++ b/app/src/lib/inventory/snapshot.ts @@ -16,6 +16,7 @@ // and this matches how InventoryFreeze has aggregated since it shipped. import type { Prisma, PrismaClient } from "@prisma/client"; +import { toQty } from "./quantity"; type PrismaTx = PrismaClient | Prisma.TransactionClient; @@ -42,7 +43,7 @@ export async function aggregateCurrentInventory(tx: PrismaTx): Promise ({ productId: p.productId, storeLocationId: p.storeLocationId, - quantity: p._sum.quantity || 0, + quantity: toQty(p._sum.quantity), })); } diff --git a/app/src/lib/modules/registry.ts b/app/src/lib/modules/registry.ts index a9ef6e4a..576ac3b6 100644 --- a/app/src/lib/modules/registry.ts +++ b/app/src/lib/modules/registry.ts @@ -178,3 +178,31 @@ export const MODULES: ModuleDef[] = [ docs: "docs/domains/dmarc-tools.md", }, ]; + +/** + * Every valid module key, derived from MODULES rather than restated. + */ +export const MODULE_KEYS: readonly string[] = MODULES.map((m) => m.key); + +/** + * Assert that a features map names only real modules, and return it unchanged. + * + * `AppSettings.features` is loose JSON, so an unknown key is not a type error -- + * it is silently ignored by isFeatureEnabled and the module simply stays at its + * registry default. The demo seed shipped four such keys (commission, + * storefront, invoicing, deliveryScheduling) for long enough that a fresh clone + * 404'd on Invoices and hid half the nav, and nothing anywhere said why. + * + * Anything writing a features map should go through here so a typo fails at the + * point it is written instead of becoming a missing screen weeks later. + */ +export function assertKnownModules>(features: T): T { + const unknown = Object.keys(features).filter((k) => !MODULE_KEYS.includes(k)); + if (unknown.length > 0) { + throw new Error( + `Unknown module key(s): ${unknown.join(", ")}. ` + + `Valid keys are: ${MODULE_KEYS.join(", ")}.`, + ); + } + return features; +} diff --git a/app/src/pages/api/warehouse/dashboard/summary.ts b/app/src/pages/api/warehouse/dashboard/summary.ts index c4c41eed..ad7e65f3 100644 --- a/app/src/pages/api/warehouse/dashboard/summary.ts +++ b/app/src/pages/api/warehouse/dashboard/summary.ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "@/lib/prisma"; +import { toQty } from "@/lib/inventory/quantity"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/pages/api/auth/[...nextauth]"; import { logError } from "@/lib/logger"; @@ -57,7 +58,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) let totalItems = 0; for (const p of locPositions) { - const qty = p._sum.quantity || 0; + const qty = toQty(p._sum.quantity); totalItems += qty; const slId = String(p.stockLocationId || "unassigned"); diff --git a/app/src/pages/api/warehouse/outbound-dashboard.ts b/app/src/pages/api/warehouse/outbound-dashboard.ts index 14819a55..e12d7e9b 100644 --- a/app/src/pages/api/warehouse/outbound-dashboard.ts +++ b/app/src/pages/api/warehouse/outbound-dashboard.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { requirePermission } from "@/lib/auth/requireAuth"; import { prisma } from "@/lib/prisma"; +import { toQty } from "@/lib/inventory/quantity"; import { logError } from "@/lib/logger"; import type { Prisma } from "@prisma/client"; @@ -143,7 +144,7 @@ export default requirePermission( id: t.id, fromLocation: t.fromStoreLocation?.name || t.fromLocation, toLocation: t.toStoreLocation?.name || t.toLocation, - itemCount: t.quantity, + itemCount: toQty(t.quantity), status: t.status, shippedAt: t.shippedAt ? t.shippedAt.toISOString() : null, })); diff --git a/app/src/pages/api/warehouse/transfers/[id]/status.ts b/app/src/pages/api/warehouse/transfers/[id]/status.ts index f8e4e9cd..54e6f4f4 100644 --- a/app/src/pages/api/warehouse/transfers/[id]/status.ts +++ b/app/src/pages/api/warehouse/transfers/[id]/status.ts @@ -9,6 +9,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import { allocate } from "@/lib/inventory/allocation"; import type { Session } from "next-auth"; import { prisma } from "@/lib/prisma"; +import { toQty, roundQty, qtyIsZero } from "@/lib/inventory/quantity"; import { requirePermission } from "@/lib/auth/requireAuth"; import { logError } from "@/lib/logger"; @@ -67,8 +68,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse, session: Sessi }); if (sourcePosition) { - const newQty = sourcePosition.quantity - transfer.quantity; - if (newQty <= 0) { + const newQty = roundQty(toQty(sourcePosition.quantity) - toQty(transfer.quantity)); + if (qtyIsZero(newQty) || newQty < 0) { await tx.inventoryPosition.delete({ where: { id: sourcePosition.id } }); } else { await tx.inventoryPosition.update({ @@ -142,7 +143,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse, session: Sessi { productId: transfer.productId, storeLocationId: transfer.toLocationId!, - quantity: transfer.quantity, + quantity: toQty(transfer.quantity), }, ], tx, diff --git a/docs/domains/manufacturing.md b/docs/domains/manufacturing.md new file mode 100644 index 00000000..bfcbae44 --- /dev/null +++ b/docs/domains/manufacturing.md @@ -0,0 +1,264 @@ +# Manufacturing in holt — a feasibility plan + +**Status: proposal.** holt is a retail ERP. This document answers a narrower +question than "should we build this": *could* the existing model carry a +made-to-order business, or is the retail shape load-bearing in a way that would +fight it? + +The answer is yes, for one specific reason: holt already models *"a thing was +ordered, it is being made somewhere else, it has not arrived, and here is when it +should"*. That is the production-tracking problem. What changes is that +"somewhere else" becomes "here", and one hop becomes several. + +Scoped to **broad-market discrete manufacturing** — someone who buys materials, +makes things from them, and sells them — rather than to one vertical. A textile +and wallpaper maker prompted it and is used for examples, but nothing below is +specific to roll goods except where it says so. + +See `docs/domains/revenue-recognition.md` for the accounting half, which is +already done. + +--- + +## What already points the right way + +| Capability | Where it lives | Why it carries over | +| --- | --- | --- | +| A job raised because a customer ordered something | `PurchaseOrder.salesOrderId` | This IS a work order, addressed to a vendor instead of a work centre | +| "Where is it, is it late" | Delivery Planner, bucketed by `expectedDelivery` | The shop-floor question, already answered for external work | +| Configure-to-order | `SEComponent`, `VendorPriceDimension`, `StyleGradePrice` | The front half of make-to-order, and the part most ERPs do badly | +| Yardage with repeat | `comYardage` / `comYardagePattern` / `comYardageRepeat` | Plain, large-repeat and railroaded are already distinguished | +| Movement between places | `StockLocation` + `InventoryTransfer` | A routing step is a transfer with a sequence | +| Work that is blocked on someone | `ServiceTask.waitingOn` | The shape an approval gate needs | +| Deposits held until shipped | recognition keyed to `deliveredAt` | Exactly the made-to-order accounting problem | +| GL by department | `AccountGroup` — 6 account slots | WIP is a seventh | + +## What fights it + +Four things, in order of how much they hurt. + +1. **`InventoryPosition.quantity` is `Int`**, as is `InventoryTransfer.quantity`. + Roll goods cannot live in integer positions — you cannot hold 37.5 yards. The + selling side is already `Decimal` (`OrderLineItem.orderedQuantity`, + `fulfilledQty`), so the split is invisible until you receive a part roll and + every count, transfer and allocation rounds. + +2. **Nothing carries lot identity.** No lot, batch, dye-lot or serial field + exists in the 164-model schema. For textiles this is the load-bearing gap: + two runs of a colourway do not match, so which lot shipped to whom is the + most consequential fact the business records. + +3. **A `StockLocation` is a place, not a stage.** It has `locationType` (a plain + `String`, so a new value is free) but no sequence — nothing says cut comes + before print comes before trim. + +4. **`PurchaseOrder.vendorId` is non-null.** A work order has no vendor. + +--- + +## The build + +Each step is independently shippable and independently verifiable. Steps 1–3 are +small and touch little; 4–5 are the real work; 6–7 are where a manufacturer +starts getting value beyond a whiteboard; 8–9 are optional for a long time. + +### Step 1 — Fractional stock + +`InventoryPosition.quantity` and `InventoryTransfer.quantity` from `Int` to +`Decimal`. Postgres widens in place, so the migration is two `ALTER COLUMN`s and +no data movement. + +The work is not the migration, it is `lib/inventory/allocation.ts`: `allocate`, +`consume`, `release` and `availableQuantity` all do integer arithmetic and +integer comparison. Decimal comparison needs care about precision, and the +existing rounding helper (`round2`) is money-shaped — yardage wants its own. + +**Risk:** allocation is money-adjacent. `inventoryAllocation.integration.test.ts` +and `tradingDay.integration.test.ts` are the guard. + +### Step 2 — Lot identity, and how lots are picked + +A new `StockLot { id, lotNumber, productId, receivedAt, notes }`, and +`InventoryPosition.stockLotId`. Then three behaviours: + +- **Receipt** records the lot (`ReceivingRecord` gains it). +- **Allocation prefers one lot** and records a warning when it cannot satisfy a + line from a single one. This is the interesting change: `allocate()` currently + walks free positions in `id` order and takes what it finds. +- **Shipment records which lot went out**, so a reorder can be matched and a + claim traced to a run. + +A **removal strategy** comes with it, per location: FIFO by default, FEFO where +things expire. Once lots exist, "which one do we pick" needs an answer, and +newest-first silently ages the oldest stock into scrap. + +Purely additive: an order with no lots behaves exactly as today. + +### Step 3 — Unit of measure, with conversion + +Not just a label. `UomCategory` (Length, Weight, Count, Area) and `Uom` with a +factor against the category's reference unit, then `Product.uomId` and a +purchase UoM that may differ from the stock UoM. + +The label alone is the tempting version and it is not enough: buying in boxes of +twelve, holding in each and selling by the yard is ordinary, and without +conversion every purchase order needs mental arithmetic that somebody +eventually gets wrong. Odoo models this well and it is worth copying. + +### Step 4 — Work orders, with partial completion + +The decision point. Two options: + +**(a) Generalise `PurchaseOrder`.** Make `vendorId` nullable, add +`source: VENDOR | INTERNAL`. An internal one is a work order. This reuses the +special-order chain, receiving, and the Delivery Planner **wholesale** — all the +tracking that already works. + +**(b) A separate `WorkOrder` model.** Cleaner conceptually; rebuilds the planner, +the receiving path and the linkage to `SalesOrder`. + +**Recommend (a)**, with an honest caveat: it overloads a model that currently +means "we bought this". If vendor and internal orders later need to diverge +substantially, that is a split to pay for then. The reuse is worth it now. + +Either way the job must support **partial completion**: an order for 100 that +finishes 60 today leaves 40 open, not the whole thing. `ReceivingRecord` already +does exactly this shape for vendor orders, which is another argument for (a). + +Option (a) also gives **subcontracting** almost for free, and subcontracting is +often most of a small manufacturer's production. A subcontracted job is a supply +order that *has* a vendor and *also* has a routing — materials go out, a +finished component comes back. + +### Step 5 — Stages and routing + +`StockLocation.locationType` gains `WORK_CENTRE` (free — it is a `String`). Add +`RoutingStep { productId | vendorStyleId, sequence, workCentreId, description }` +and a current-step pointer on the job. + +Movement between steps is already `InventoryTransfer`. This step is mostly about +*sequence* — knowing that trim follows print — and about showing a board. + +### Step 6 — Bill of materials + +`Bom { productId, quantity, version }` and +`BomLine { bomId, componentProductId, quantity, uomId, scrapPercent }`. + +**Multi-level**, not flat: a component may itself have a BoM, and explosion +recurses. A flat list handles a cushion and not a sofa made of a frame made of +rails. + +**`scrapPercent` from the start.** Cutting loses material and printing loses a +metre to setup. If the BoM says 10 and the floor consumes 11, every job costs +wrong, and adding yield later means restating history. + +holt's configurator already produces a *specification* (depth, arm, cushion +fill). A BoM turns a specification into materials. Consumption on step +completion — backflushing — reuses `consume()` from Step 1. + +### Step 7 — WIP costing + +`AccountGroup` gains `wipAccountId`, a seventh slot next to the six it has. + +- Issuing material: **Dr WIP, Cr Raw Materials** +- Completing the job: **Dr Finished Goods, Cr WIP** + +This plugs directly into the journal engine that now exists — the same engine +that relieves deposits on delivery. Without it, a half-made run is valued either +as raw stock (too low) or as finished goods (too high), and neither balance sheet +is true. + +### Step 8 — Approval gates + +Strike-offs and cuttings-for-approval hold a job before it runs. +`ServiceTask.waitingOn` is the right shape; what is missing is a gate the job +cannot pass until the approval comes back. + +### Step 9 — Capacity + +Sequencing jobs to minimise substrate changeovers. Genuinely last: a small +manufacturer runs this on a whiteboard for years, and doing it badly is worse +than not doing it. + +--- + +## What Odoo gives that this plan does not + +The honest answer to "does this cover everything Odoo does" is **no, and not +close** — Odoo's manufacturing apps are a decade of work with a large installed +base driving the edges. What follows is the gap, grouped by whether a small or +mid-sized manufacturer would actually feel it. + +### Tier 1 — you cannot run a factory without these + +Four are in the plan above (BoM, work orders, routing, WIP). These are the ones +that are **not**, and they belong in it: + +| Missing | Why it bites | +| --- | --- | +| **UoM conversion**, not just a label | Buy in boxes of 12, hold in each, sell by the yard. The plan had UoM as a display field; Odoo has unit *categories* with conversion factors, and without them every purchase needs mental arithmetic. | +| **Multi-level BoM** | An assembly made of assemblies. The plan's `BomLine` is flat, which handles a cushion but not a sofa made of a frame made of rails. | +| **Scrap and expected yield** | Cutting loses material; printing loses a metre to setup. If the BoM says 10 and the floor uses 11, cost is wrong on every job. Textiles feel this hardest, but every process has it. | +| **Partial completion / backorders** | A job for 100 finishes 60 today. Without it the whole order is open or closed, and neither is true. | +| **Removal strategy** (FIFO / FEFO) | Once lots exist, "which lot do we pick" needs a rule. Odoo makes it configurable per location. Picking newest-first silently ages your oldest stock into scrap. | + +### Tier 2 — you will want these inside a year + +| Missing | Why | +| --- | --- | +| **Subcontracting** | Send materials out, get a finished component back. Odoo treats it as a first-class route. For a small manufacturer this is often *most* of production — the printer, the plater, the CNC shop. | +| **Reordering rules** | Min/max per product per location, generating POs automatically. holt has no `reorderPoint` at all. | +| **Landed costs** | Freight and duty allocated into inventory value. Anyone importing materials is understating cost of goods without it. | +| **Costing method** | Standard vs average vs FIFO. holt has one `baseCost` field and no method — fine for retail where you buy and sell the same thing, wrong once you make it. | +| **Quality control points** | An inspection with recorded measurements at a named step. The plan's approval gates are a subset — they hold a job, they do not record what was measured. | +| **Kit / phantom BoMs** | Sell an assembly, ship and pick its components. | +| **By-products and co-products** | One run yields two sellable things. Common in cutting and in food. | +| **Traceability reports** | Not the lot field — the *reports*. "Where did lot 240817-B go" downstream, and "what went into this unit" upstream. This is the thing a recall or a claim actually needs. | + +### Tier 3 — Odoo has them and most sites never switch them on + +Master production schedule, capacity planning with OEE, equipment maintenance, +PLM with engineering change orders, unbuild/disassembly, analytic accounting, +and multi-step warehouse routes with push/pull rules. Real features, genuinely +used at scale, and a distraction for anyone below it. + +### Outside manufacturing entirely + +Odoo is a suite, and its breadth is the other half of the comparison. holt has +no multi-currency, no multi-company or intercompany, no payroll, no fixed assets +or depreciation, no budgeting, no bank statement import and reconciliation, no +expenses, no purchase RFQ or vendor bidding, no dropshipping, and no document +OCR. Several of those matter long before manufacturing does. + +## The realistic read + +Reaching Odoo's manufacturing is not a project, it is a product line. What is +reachable is the subset a small manufacturer runs on: **UoM with conversion, +lots with FIFO/FEFO picking, multi-level BoMs with scrap, work orders with +routing and partial completion, subcontracting, and WIP costing.** That is a +serious piece of work and it is finite — and it sits on top of a customer, +catalogue, pricing, inventory and general-ledger layer that already exists, +which is the part usually underestimated. + +Where holt would keep an advantage is the front of the business: configure-to- +order pricing with grades and options, to-the-trade tiers, deposit-to-delivery +recognition, and commission that can count on either basis. Odoo does those +adequately; holt was built for them. + +## What not to build + +- **MRP netting and planning.** Large, and the businesses this would serve do not + have the demand signal to make it meaningful. +- **Labour capture**, unless they actually cost by labour rather than by + material and a shop rate. +- **Capacity scheduling** before volume makes it hurt (Step 9, and it can wait + past that). + +## Risks worth naming + +- Step 1 touches allocation, which touches money. The integration suite exists + precisely for this; run it per change rather than at the end. +- Lot-aware allocation changes what "available" means. Every report reading + `availableQuantity` needs checking, not just the allocator. +- Step 4(a) overloads `PurchaseOrder`. Name it clearly in the schema so the next + reader is not surprised.