From 612b746db703edc486f28f73242194475864c1d9 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 25 Aug 2026 13:54:17 -0400 Subject: [PATCH 1/5] fix(modules): an unknown feature key fails instead of doing nothing silently AppSettings.features is loose JSON, so a key that names no module is not a type error -- isFeatureEnabled ignores it and the module quietly stays at its registry default. The demo seed shipped four such keys: commission, storefront, invoicing and deliveryScheduling are not module keys at all. Only three of its seven were real. The consequence was not subtle. Seventeen real modules sat at their defaults on every fresh clone, so Invoices 404'd, and POS, tills, gift cards, purchasing, accounting, marketing, blog, time tracking and the client portal were absent from the nav entirely -- on a system that has all of them built. assertKnownModules() validates against MODULES and throws, naming the bad keys and listing the valid ones. Anything writing a features map should go through it, so a typo fails where it is written rather than becoming a missing screen weeks later. The demo seed now turns on what a furniture retailer would actually run. Deliberately left off: the two legacy-import modules (they exist for one migration path), dmarcTools (another vertical), and blogComments (nothing can post one without a public visitor). Co-Authored-By: Claude Opus 5 --- app/prisma/seed/demo/org.ts | 1 + app/src/lib/modules/registry.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) 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/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; +} From 5c445b1a1168aee289b43ad80486c1be773220c9 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 25 Aug 2026 13:54:31 -0400 Subject: [PATCH 2/5] feat(inventory): stock quantities are fractional A roll of fabric or wallpaper is a length, not a count. The selling side has always known that -- OrderLineItem.orderedQuantity and fulfilledQty are both Decimal -- 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. The split is invisible until somebody receives a part roll, and then every count, transfer and allocation silently rounds. A transfer of 18.75 yards stored 18. Both columns are now Decimal(12,3). Three places because fabric is sold to the eighth of a yard and 0.125 is exact at three; two would round it to 0.13 and lose an eighth on every cut. Postgres widens integer to numeric in place, so no data moves and every existing whole-unit row is unchanged. lib/inventory/quantity.ts owns the arithmetic -- deliberately not journalEntry's round2(), which is money and rounds like money. The important piece is qtyEquals(): planDraw decided whether a draw exhausted a position with `===`, and exact float equality on fractional quantities is how a position ends up stranded at 0.0000001 -- present in every count, sellable to nobody, never cleaned up because it never reads as exhausted. Ten call sites, all mechanical apart from the allocator. Verified against a real database: holding a part roll, selling to the eighth, cutting one order across two bolts and leaving the remnant sellable, shorting honestly rather than rounding, consuming an allocation with no sliver left behind, and 0.1 + 0.2 still exhausting a 0.3 position. Also adds docs/domains/manufacturing.md -- a feasibility plan for the wider make-to-order case that prompted this. Fractional stock is step one of it and was needed regardless, because holt already sells COM by the yard. Co-Authored-By: Claude Opus 5 --- .../fractionalStock.integration.test.ts | 192 ++++++++++++++++++ .../tradingDay.integration.test.ts | 4 +- .../migration.sql | 16 ++ app/prisma/schema.prisma | 16 +- app/src/lib/inventory/allocation.ts | 34 +++- app/src/lib/inventory/quantity.ts | 53 +++++ app/src/lib/inventory/snapshot.ts | 3 +- .../pages/api/warehouse/dashboard/summary.ts | 3 +- .../pages/api/warehouse/outbound-dashboard.ts | 3 +- .../api/warehouse/transfers/[id]/status.ts | 7 +- docs/domains/manufacturing.md | 171 ++++++++++++++++ 11 files changed, 482 insertions(+), 20 deletions(-) create mode 100644 app/__tests__/integration/fractionalStock.integration.test.ts create mode 100644 app/prisma/migrations/20260825140000_fractional_stock/migration.sql create mode 100644 app/src/lib/inventory/quantity.ts create mode 100644 docs/domains/manufacturing.md 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/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/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/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/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..845cd6bb --- /dev/null +++ b/docs/domains/manufacturing.md @@ -0,0 +1,171 @@ +# 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. + +Written against a textile and wallpaper manufacturer, because that is the case +that prompted it. 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 + +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. + +Purely additive: an order with no lots behaves exactly as today. + +### Step 3 — Unit of measure + +`Product.unitOfMeasure` (`EACH`, `YARD`, `ROLL`, `SQFT`), displayed on entry and +on every quantity. Mostly presentational once Step 1 has made quantities +fractional. Worth doing early because "3" meaning three yards or three rolls is +the kind of ambiguity that produces a wrong order. + +### Step 4 — Work orders + +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. + +### 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 + +`BomLine { parentProductId, componentProductId, quantity, unitOfMeasure }`. + +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 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. From d703fa6ba4cdabed7ad1a226f46715e3c413d6b8 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 25 Aug 2026 14:03:51 -0400 Subject: [PATCH 3/5] docs(manufacturing): broad-market scope, and an honest read against Odoo The plan was written against roll goods. Rescoped to discrete manufacturing generally, with the Tier-1 gaps folded into the steps rather than listed separately: UoM conversion (not just a label), multi-level BoMs with scrap percentage, partial job completion, and a removal strategy to go with lots. Adds what Odoo gives that this does not, in three tiers by whether a small manufacturer would feel it -- and says plainly that reaching Odoo's manufacturing is a product line rather than a project. What is reachable is the subset such a business actually runs on. Co-Authored-By: Claude Opus 5 --- docs/domains/manufacturing.md | 117 ++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 12 deletions(-) diff --git a/docs/domains/manufacturing.md b/docs/domains/manufacturing.md index 845cd6bb..bfcbae44 100644 --- a/docs/domains/manufacturing.md +++ b/docs/domains/manufacturing.md @@ -10,9 +10,13 @@ ordered, it is being made somewhere else, it has not arrived, and here is when i should"*. That is the production-tracking problem. What changes is that "somewhere else" becomes "here", and one hop becomes several. -Written against a textile and wallpaper manufacturer, because that is the case -that prompted it. See `docs/domains/revenue-recognition.md` for the accounting -half, which is already done. +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. --- @@ -72,7 +76,7 @@ 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 +### Step 2 — Lot identity, and how lots are picked A new `StockLot { id, lotNumber, productId, receivedAt, notes }`, and `InventoryPosition.stockLotId`. Then three behaviours: @@ -84,16 +88,24 @@ A new `StockLot { id, lotNumber, productId, receivedAt, notes }`, and - **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 +### Step 3 — Unit of measure, with conversion -`Product.unitOfMeasure` (`EACH`, `YARD`, `ROLL`, `SQFT`), displayed on entry and -on every quantity. Mostly presentational once Step 1 has made quantities -fractional. Worth doing early because "3" meaning three yards or three rolls is -the kind of ambiguity that produces a wrong order. +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. -### Step 4 — Work orders +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: @@ -109,6 +121,15 @@ the receiving path and the linkage to `SalesOrder`. 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 @@ -120,10 +141,19 @@ Movement between steps is already `InventoryTransfer`. This step is mostly about ### Step 6 — Bill of materials -`BomLine { parentProductId, componentProductId, quantity, unitOfMeasure }`. +`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 +fill). A BoM turns a specification into materials. Consumption on step completion — backflushing — reuses `consume()` from Step 1. ### Step 7 — WIP costing @@ -152,6 +182,69 @@ 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 From 1fa03a61b4c093d1ec7e60a670408ba3976c9de3 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 25 Aug 2026 17:55:00 -0400 Subject: [PATCH 4/5] fix(inventory): allocation tests compare quantities as numbers, not Decimals Prisma returns a Decimal for the widened columns, and `expect(x.quantity) .toBe(5)` compares it as the string "5". Six assertions in inventoryAllocation.integration.test.ts, all mechanical -- toQty() at the read. CI caught these because the local run before pushing covered the unit suite and the two integration files this change touches by name, not the whole suite. Co-Authored-By: Claude Opus 5 --- .../inventoryAllocation.integration.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 () => { From 22980fb9f703c3ca7bba9923919a0f9b63403dc6 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 25 Aug 2026 21:40:04 -0400 Subject: [PATCH 5/5] fix(inventory): the remaining Decimal-vs-number assertions in tests Same shape as the last commit, found by grepping every stock-quantity assertion across __tests__ at once instead of waiting for CI to surface them one file at a time: inventoryOrderWiring, transferForOrder and inventorySnapshotGenerate. Co-Authored-By: Claude Opus 5 --- .../integration/inventoryOrderWiring.integration.test.ts | 5 +++-- .../inventorySnapshotGenerate.integration.test.ts | 7 ++++--- .../integration/transferForOrder.integration.test.ts | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) 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/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(); });