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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions app/__tests__/integration/fractionalStock.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});

Expand All @@ -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 () => {
Expand All @@ -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);
});

Expand Down Expand Up @@ -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();
});

Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
4 changes: 2 additions & 2 deletions app/__tests__/integration/tradingDay.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 14 additions & 2 deletions app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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?
Expand Down
Loading
Loading