inventory

This commit is contained in:
2026-03-14 22:37:09 -05:00
parent 6589581908
commit 10b47da724
14 changed files with 651 additions and 43 deletions

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "InventoryTransaction" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"transactionType" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"reference" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransaction_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "InventoryTransaction_itemId_createdAt_idx" ON "InventoryTransaction"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_warehouseId_createdAt_idx" ON "InventoryTransaction"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_locationId_createdAt_idx" ON "InventoryTransaction"("locationId", "createdAt");

View File

@@ -19,6 +19,7 @@ model User {
updatedAt DateTime @updatedAt
userRoles UserRole[]
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
}
model Role {
@@ -117,6 +118,7 @@ model InventoryItem {
updatedAt DateTime @updatedAt
bomLines InventoryBomLine[] @relation("InventoryBomParent")
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
inventoryTransactions InventoryTransaction[]
}
model Warehouse {
@@ -127,6 +129,7 @@ model Warehouse {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
}
model Customer {
@@ -188,11 +191,34 @@ model WarehouseLocation {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
@@unique([warehouseId, code])
@@index([warehouseId])
}
model InventoryTransaction {
id String @id @default(cuid())
itemId String
warehouseId String
locationId String
transactionType String
quantity Int
reference String
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
@@index([warehouseId, createdAt])
@@index([locationId, createdAt])
}
model Vendor {
id String @id @default(cuid())
name String

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared";
import { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { Router } from "express";
import { z } from "zod";
@@ -7,11 +7,13 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
getWarehouseById,
listInventoryItemOptions,
listInventoryItems,
listWarehouseLocationOptions,
listWarehouses,
updateInventoryItem,
updateWarehouse,
@@ -19,7 +21,7 @@ import {
const bomLineSchema = z.object({
componentItemId: z.string().trim().min(1),
quantity: z.number().positive(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
notes: z.string(),
position: z.number().int().nonnegative(),
@@ -45,6 +47,15 @@ const inventoryListQuerySchema = z.object({
type: z.enum(inventoryItemTypes).optional(),
});
const inventoryTransactionSchema = z.object({
transactionType: z.enum(inventoryTransactionTypes),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
reference: z.string().max(120),
notes: z.string(),
});
const warehouseLocationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
@@ -84,6 +95,10 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
return ok(response, await listInventoryItemOptions());
});
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions());
});
inventoryRouter.get("/items/:itemId", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
@@ -131,6 +146,25 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW
return ok(response, item);
});
inventoryRouter.post("/items/:itemId/transactions", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryTransactionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transaction payload is invalid.");
}
const result = await createInventoryTransaction(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.get("/warehouses", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouses());
});

View File

@@ -3,13 +3,18 @@ import type {
InventoryBomLineInput,
InventoryItemDetailDto,
InventoryItemInput,
InventoryStockBalanceDto,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
WarehouseLocationDto,
WarehouseLocationInput,
WarehouseSummaryDto,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransactionDto,
InventoryTransactionInput,
InventoryTransactionType,
InventoryItemType,
InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
@@ -44,6 +49,30 @@ type InventoryDetailRecord = {
createdAt: Date;
updatedAt: Date;
bomLines: BomLineRecord[];
inventoryTransactions: InventoryTransactionRecord[];
};
type InventoryTransactionRecord = {
id: string;
transactionType: string;
quantity: number;
reference: string;
notes: string;
createdAt: Date;
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type WarehouseLocationRecord = {
@@ -85,6 +114,64 @@ function mapWarehouseLocation(record: WarehouseLocationRecord): WarehouseLocatio
};
}
function getSignedQuantity(transactionType: InventoryTransactionType, quantity: number) {
return transactionType === "RECEIPT" || transactionType === "ADJUSTMENT_IN" ? quantity : -quantity;
}
function mapInventoryTransaction(record: InventoryTransactionRecord): InventoryTransactionDto {
const transactionType = record.transactionType as InventoryTransactionType;
const signedQuantity = getSignedQuantity(transactionType, record.quantity);
return {
id: record.id,
transactionType,
quantity: record.quantity,
signedQuantity,
notes: record.notes,
reference: record.reference,
createdAt: record.createdAt.toISOString(),
warehouseId: record.warehouse.id,
warehouseCode: record.warehouse.code,
warehouseName: record.warehouse.name,
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
};
}
function buildStockBalances(transactions: InventoryTransactionRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>();
for (const transaction of transactions) {
const transactionType = transaction.transactionType as InventoryTransactionType;
const signedQuantity = getSignedQuantity(transactionType, transaction.quantity);
const key = `${transaction.warehouse.id}:${transaction.location.id}`;
const current = grouped.get(key);
if (current) {
current.quantityOnHand += signedQuantity;
continue;
}
grouped.set(key, {
warehouseId: transaction.warehouse.id,
warehouseCode: transaction.warehouse.code,
warehouseName: transaction.warehouse.name,
locationId: transaction.location.id,
locationCode: transaction.location.code,
locationName: transaction.location.name,
quantityOnHand: signedQuantity,
});
}
return [...grouped.values()]
.filter((balance) => balance.quantityOnHand !== 0)
.sort((left, right) =>
`${left.warehouseCode}-${left.locationCode}`.localeCompare(`${right.warehouseCode}-${right.locationCode}`)
);
}
function mapSummary(record: {
id: string;
sku: string;
@@ -112,6 +199,12 @@ function mapSummary(record: {
}
function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
const recentTransactions = record.inventoryTransactions
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapInventoryTransaction);
const stockBalances = buildStockBalances(record.inventoryTransactions);
return {
...mapSummary({
id: record.id,
@@ -130,6 +223,9 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
stockBalances,
recentTransactions,
};
}
@@ -192,7 +288,7 @@ function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
return bomLines
.map((line, index) => ({
componentItemId: line.componentItemId,
quantity: line.quantity,
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
notes: line.notes,
position: line.position ?? (index + 1) * 10,
@@ -217,6 +313,10 @@ async function validateBomLines(parentItemId: string | null, bomLines: Inventory
return { ok: false as const, reason: "BOM line quantity must be greater than zero." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity))) {
return { ok: false as const, reason: "BOM line quantity must be a whole number." };
}
if (parentItemId && normalized.some((line) => line.componentItemId === parentItemId)) {
return { ok: false as const, reason: "An item cannot reference itself in its BOM." };
}
@@ -298,12 +398,108 @@ export async function getInventoryItemById(itemId: string) {
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
inventoryTransactions: {
include: {
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
},
});
return item ? mapDetail(item) : null;
}
export async function listWarehouseLocationOptions() {
const warehouses = await prisma.warehouse.findMany({
include: {
locations: {
orderBy: [{ code: "asc" }],
},
},
orderBy: [{ code: "asc" }],
});
return warehouses.flatMap((warehouse): WarehouseLocationOptionDto[] =>
warehouse.locations.map((location) => ({
warehouseId: warehouse.id,
warehouseCode: warehouse.code,
warehouseName: warehouse.name,
locationId: location.id,
locationCode: location.code,
locationName: location.name,
}))
);
}
export async function createInventoryTransaction(itemId: string, payload: InventoryTransactionInput, createdById?: string | null) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
select: { id: true },
});
if (!item) {
return { ok: false as const, reason: "Inventory item was not found." };
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const detail = await getInventoryItemById(itemId);
if (!detail) {
return { ok: false as const, reason: "Inventory item was not found." };
}
const signedQuantity = getSignedQuantity(payload.transactionType, payload.quantity);
if (signedQuantity < 0 && detail.onHandQuantity + signedQuantity < 0) {
return { ok: false as const, reason: "Transaction would drive on-hand quantity below zero." };
}
await prisma.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: payload.transactionType,
quantity: payload.quantity,
reference: payload.reference.trim(),
notes: payload.notes,
createdById: createdById ?? null,
},
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryItem(payload: InventoryItemInput) {
const validatedBom = await validateBomLines(null, payload.bomLines);
if (!validatedBom.ok) {
@@ -328,23 +524,12 @@ export async function createInventoryItem(payload: InventoryItemInput) {
}
: undefined,
},
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
select: {
id: true,
},
});
return mapDetail(item);
return getInventoryItemById(item.id);
}
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) {
@@ -379,23 +564,12 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
create: validatedBom.bomLines,
},
},
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
select: {
id: true,
},
});
return mapDetail(item);
return getInventoryItemById(item.id);
}
export async function listWarehouses() {