inventory control

This commit is contained in:
2026-03-15 14:00:12 -05:00
parent 16582d3cea
commit 1fcb0c5480
14 changed files with 986 additions and 205 deletions

View File

@@ -0,0 +1,43 @@
CREATE TABLE "InventoryTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"fromWarehouseId" TEXT NOT NULL,
"fromLocationId" TEXT NOT NULL,
"toWarehouseId" TEXT NOT NULL,
"toLocationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransfer_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromWarehouseId_fkey" FOREIGN KEY ("fromWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromLocationId_fkey" FOREIGN KEY ("fromLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toWarehouseId_fkey" FOREIGN KEY ("toWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toLocationId_fkey" FOREIGN KEY ("toLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "InventoryReservation" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT,
"locationId" TEXT,
"workOrderId" TEXT,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT,
"quantity" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryReservation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "InventoryTransfer_itemId_createdAt_idx" ON "InventoryTransfer"("itemId", "createdAt");
CREATE INDEX "InventoryReservation_itemId_status_createdAt_idx" ON "InventoryReservation"("itemId", "status", "createdAt");
CREATE INDEX "InventoryReservation_warehouseId_locationId_status_idx" ON "InventoryReservation"("warehouseId", "locationId", "status");
CREATE INDEX "InventoryReservation_workOrderId_status_idx" ON "InventoryReservation"("workOrderId", "status");

View File

@@ -28,6 +28,7 @@ model User {
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
}
model Role {
@@ -134,6 +135,8 @@ model InventoryItem {
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
operations InventoryItemOperation[]
reservations InventoryReservation[]
transfers InventoryTransfer[]
}
model Warehouse {
@@ -148,6 +151,9 @@ model Warehouse {
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
}
model Customer {
@@ -216,6 +222,9 @@ model WarehouseLocation {
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
@@unique([warehouseId, code])
@@index([warehouseId])
@@ -243,6 +252,51 @@ model InventoryTransaction {
@@index([locationId, createdAt])
}
model InventoryTransfer {
id String @id @default(cuid())
itemId String
fromWarehouseId String
fromLocationId String
toWarehouseId String
toLocationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
fromWarehouse Warehouse @relation("InventoryTransferFromWarehouse", fields: [fromWarehouseId], references: [id], onDelete: Restrict)
fromLocation WarehouseLocation @relation("InventoryTransferFromLocation", fields: [fromLocationId], references: [id], onDelete: Restrict)
toWarehouse Warehouse @relation("InventoryTransferToWarehouse", fields: [toWarehouseId], references: [id], onDelete: Restrict)
toLocation WarehouseLocation @relation("InventoryTransferToLocation", fields: [toLocationId], references: [id], onDelete: Restrict)
createdBy User? @relation("InventoryTransferCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
}
model InventoryReservation {
id String @id @default(cuid())
itemId String
warehouseId String?
locationId String?
workOrderId String?
sourceType String
sourceId String?
quantity Int
status String @default("ACTIVE")
notes 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: SetNull)
location WarehouseLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([itemId, status, createdAt])
@@index([warehouseId, locationId, status])
@@index([workOrderId, status])
}
model Vendor {
id String @id @default(cuid())
name String
@@ -480,6 +534,7 @@ model WorkOrder {
operations WorkOrderOperation[]
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
reservations InventoryReservation[]
@@index([itemId, createdAt])
@@index([projectId, dueDate])

View File

@@ -7,6 +7,8 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryReservation,
createInventoryTransfer,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
@@ -67,6 +69,22 @@ const inventoryTransactionSchema = z.object({
notes: z.string(),
});
const inventoryTransferSchema = z.object({
quantity: z.number().int().positive(),
fromWarehouseId: z.string().trim().min(1),
fromLocationId: z.string().trim().min(1),
toWarehouseId: z.string().trim().min(1),
toLocationId: z.string().trim().min(1),
notes: z.string(),
});
const inventoryReservationSchema = z.object({
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1).nullable(),
locationId: z.string().trim().min(1).nullable(),
notes: z.string(),
});
const warehouseLocationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
@@ -176,6 +194,44 @@ inventoryRouter.post("/items/:itemId/transactions", requirePermissions([permissi
return ok(response, result.item, 201);
});
inventoryRouter.post("/items/:itemId/transfers", 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 = inventoryTransferSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transfer payload is invalid.");
}
const result = await createInventoryTransfer(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.post("/items/:itemId/reservations", 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 = inventoryReservationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
}
const result = await createInventoryReservation(itemId, parsed.data);
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

@@ -4,7 +4,12 @@ import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOperationDto,
InventoryReservationDto,
InventoryReservationInput,
InventoryReservationStatus,
InventoryStockBalanceDto,
InventoryTransferDto,
InventoryTransferInput,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
@@ -67,6 +72,8 @@ type InventoryDetailRecord = {
bomLines: BomLineRecord[];
operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[];
reservations: InventoryReservationRecord[];
transfers: InventoryTransferRecord[];
};
type InventoryTransactionRecord = {
@@ -109,6 +116,61 @@ type WarehouseDetailRecord = {
locations: WarehouseLocationRecord[];
};
type InventoryReservationRecord = {
id: string;
quantity: number;
status: string;
sourceType: string;
sourceId: string | null;
notes: string;
createdAt: Date;
warehouse: {
id: string;
code: string;
name: string;
} | null;
location: {
id: string;
code: string;
name: string;
} | null;
workOrder: {
id: string;
workOrderNumber: string;
} | null;
};
type InventoryTransferRecord = {
id: string;
quantity: number;
notes: string;
createdAt: Date;
fromWarehouse: {
id: string;
code: string;
name: string;
};
fromLocation: {
id: string;
code: string;
name: string;
};
toWarehouse: {
id: string;
code: string;
name: string;
};
toLocation: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
};
function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
return {
id: record.id,
@@ -172,7 +234,48 @@ function mapInventoryTransaction(record: InventoryTransactionRecord): InventoryT
};
}
function buildStockBalances(transactions: InventoryTransactionRecord[]): InventoryStockBalanceDto[] {
function mapReservation(record: InventoryReservationRecord): InventoryReservationDto {
return {
id: record.id,
quantity: record.quantity,
status: record.status as InventoryReservationStatus,
sourceType: record.sourceType,
sourceId: record.sourceId,
sourceLabel: record.workOrder ? record.workOrder.workOrderNumber : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
warehouseId: record.warehouse?.id ?? null,
warehouseCode: record.warehouse?.code ?? null,
warehouseName: record.warehouse?.name ?? null,
locationId: record.location?.id ?? null,
locationCode: record.location?.code ?? null,
locationName: record.location?.name ?? null,
};
}
function mapTransfer(record: InventoryTransferRecord): InventoryTransferDto {
return {
id: record.id,
quantity: record.quantity,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
fromWarehouseId: record.fromWarehouse.id,
fromWarehouseCode: record.fromWarehouse.code,
fromWarehouseName: record.fromWarehouse.name,
fromLocationId: record.fromLocation.id,
fromLocationCode: record.fromLocation.code,
fromLocationName: record.fromLocation.name,
toWarehouseId: record.toWarehouse.id,
toWarehouseCode: record.toWarehouse.code,
toWarehouseName: record.toWarehouse.name,
toLocationId: record.toLocation.id,
toLocationCode: record.toLocation.code,
toLocationName: record.toLocation.name,
};
}
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>();
for (const transaction of transactions) {
@@ -194,11 +297,40 @@ function buildStockBalances(transactions: InventoryTransactionRecord[]): Invento
locationCode: transaction.location.code,
locationName: transaction.location.name,
quantityOnHand: signedQuantity,
quantityReserved: 0,
quantityAvailable: signedQuantity,
});
}
for (const reservation of reservations) {
if (!reservation.warehouse || !reservation.location || reservation.status !== "ACTIVE") {
continue;
}
const key = `${reservation.warehouse.id}:${reservation.location.id}`;
const current = grouped.get(key);
if (current) {
current.quantityReserved += reservation.quantity;
current.quantityAvailable = current.quantityOnHand - current.quantityReserved;
continue;
}
grouped.set(key, {
warehouseId: reservation.warehouse.id,
warehouseCode: reservation.warehouse.code,
warehouseName: reservation.warehouse.name,
locationId: reservation.location.id,
locationCode: reservation.location.code,
locationName: reservation.location.name,
quantityOnHand: 0,
quantityReserved: reservation.quantity,
quantityAvailable: -reservation.quantity,
});
}
return [...grouped.values()]
.filter((balance) => balance.quantityOnHand !== 0)
.filter((balance) => balance.quantityOnHand !== 0 || balance.quantityReserved !== 0)
.sort((left, right) =>
`${left.warehouseCode}-${left.locationCode}`.localeCompare(`${right.warehouseCode}-${right.locationCode}`)
);
@@ -235,7 +367,13 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapInventoryTransaction);
const stockBalances = buildStockBalances(record.inventoryTransactions);
const activeReservations = record.reservations.filter((reservation) => reservation.status === "ACTIVE");
const stockBalances = buildStockBalances(record.inventoryTransactions, activeReservations);
const reservedQuantity = activeReservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
const transferHistory = record.transfers
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapTransfer);
return {
...mapSummary({
@@ -258,8 +396,12 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
operations: record.operations.slice().sort((a, b) => a.position - b.position).map(mapOperation),
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
reservedQuantity,
availableQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityAvailable, 0),
stockBalances,
recentTransactions,
transfers: transferHistory,
reservations: record.reservations.slice().sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()).map(mapReservation),
};
}
@@ -353,6 +495,24 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
.filter((location) => location.code.length > 0 && location.name.length > 0);
}
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
itemId,
warehouseId,
locationId,
},
select: {
transactionType: true,
quantity: true,
},
});
return transactions.reduce((total, transaction) => {
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
}, 0);
}
async function validateBomLines(parentItemId: string | null, bomLines: InventoryBomLineInput[]) {
const normalized = normalizeBomLines(bomLines);
@@ -434,6 +594,22 @@ async function validateOperations(type: InventoryItemType, operations: Inventory
return { ok: true as const, operations: normalized };
}
async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) {
const reservations = await prisma.inventoryReservation.findMany({
where: {
itemId,
warehouseId,
locationId,
status: "ACTIVE",
},
select: {
quantity: true,
},
});
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
}
export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters),
@@ -529,6 +705,70 @@ export async function getInventoryItemById(itemId: string) {
},
orderBy: [{ createdAt: "desc" }],
},
reservations: {
include: {
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
workOrder: {
select: {
id: true,
workOrderNumber: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
transfers: {
include: {
fromWarehouse: {
select: {
id: true,
code: true,
name: true,
},
},
fromLocation: {
select: {
id: true,
code: true,
name: true,
},
},
toWarehouse: {
select: {
id: true,
code: true,
name: true,
},
},
toLocation: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
},
});
@@ -579,14 +819,13 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
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." };
if (signedQuantity < 0) {
const onHand = await getItemLocationOnHand(itemId, payload.warehouseId, payload.locationId);
const reserved = await getActiveReservedQuantity(itemId, payload.warehouseId, payload.locationId);
if (onHand - reserved + signedQuantity < 0) {
return { ok: false as const, reason: "Transaction would drive available quantity below zero at the selected location." };
}
}
await prisma.inventoryTransaction.create({
@@ -606,6 +845,134 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryTransfer(itemId: string, payload: InventoryTransferInput, 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 [fromLocation, toLocation] = await Promise.all([
prisma.warehouseLocation.findUnique({
where: { id: payload.fromLocationId },
select: { id: true, warehouseId: true },
}),
prisma.warehouseLocation.findUnique({
where: { id: payload.toLocationId },
select: { id: true, warehouseId: true },
}),
]);
if (!fromLocation || fromLocation.warehouseId !== payload.fromWarehouseId) {
return { ok: false as const, reason: "Source location is invalid for the selected source warehouse." };
}
if (!toLocation || toLocation.warehouseId !== payload.toWarehouseId) {
return { ok: false as const, reason: "Destination location is invalid for the selected destination warehouse." };
}
const onHand = await getItemLocationOnHand(itemId, payload.fromWarehouseId, payload.fromLocationId);
const reserved = await getActiveReservedQuantity(itemId, payload.fromWarehouseId, payload.fromLocationId);
if (onHand - reserved < payload.quantity) {
return { ok: false as const, reason: "Transfer quantity exceeds available stock at the source location." };
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTransfer.create({
data: {
itemId,
fromWarehouseId: payload.fromWarehouseId,
fromLocationId: payload.fromLocationId,
toWarehouseId: payload.toWarehouseId,
toLocationId: payload.toLocationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.fromWarehouseId,
locationId: payload.fromLocationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: "Inventory transfer out",
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.toWarehouseId,
locationId: payload.toLocationId,
transactionType: "RECEIPT",
quantity: payload.quantity,
reference: "Inventory transfer in",
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 createInventoryReservation(itemId: string, payload: InventoryReservationInput) {
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." };
}
if ((payload.warehouseId && !payload.locationId) || (!payload.warehouseId && payload.locationId)) {
return { ok: false as const, reason: "Reservation warehouse and location must be provided together." };
}
if (payload.warehouseId && payload.locationId) {
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: { warehouseId: true },
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Reservation location is invalid for the selected warehouse." };
}
const onHand = await getItemLocationOnHand(itemId, payload.warehouseId, payload.locationId);
const reserved = await getActiveReservedQuantity(itemId, payload.warehouseId, payload.locationId);
if (onHand - reserved < payload.quantity) {
return { ok: false as const, reason: "Reservation quantity exceeds available stock at the selected location." };
}
}
await prisma.inventoryReservation.create({
data: {
itemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
sourceType: "MANUAL",
sourceId: null,
quantity: payload.quantity,
status: "ACTIVE",
notes: payload.notes,
},
});
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) {

View File

@@ -366,6 +366,10 @@ function addMinutes(value: Date, minutes: number) {
return new Date(value.getTime() + minutes * 60 * 1000);
}
function shouldReserveForStatus(status: string) {
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
}
function buildWorkOrderOperationPlan(
itemOperations: WorkOrderRecord["item"]["operations"],
quantity: number,
@@ -463,6 +467,46 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
});
}
async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return;
}
await prisma.inventoryReservation.deleteMany({
where: {
workOrderId,
sourceType: "WORK_ORDER",
},
});
if (!shouldReserveForStatus(workOrder.status)) {
return;
}
const reservations = workOrder.materialRequirements
.filter((requirement) => requirement.remainingQuantity > 0)
.map((requirement) => ({
itemId: requirement.componentItemId,
warehouseId: workOrder.warehouseId,
locationId: workOrder.locationId,
workOrderId,
sourceType: "WORK_ORDER",
sourceId: workOrderId,
quantity: requirement.remainingQuantity,
status: "ACTIVE",
notes: `${workOrder.workOrderNumber} component demand`,
}));
if (reservations.length === 0) {
return;
}
await prisma.inventoryReservation.createMany({
data: reservations,
});
}
async function nextWorkOrderNumber() {
const next = (await workOrderModel.count()) + 1;
return `WO-${String(next).padStart(5, "0")}`;
@@ -696,6 +740,7 @@ export async function createWorkOrder(payload: WorkOrderInput) {
});
await regenerateWorkOrderOperations(created.id);
await syncWorkOrderReservations(created.id);
const workOrder = await getWorkOrderById(created.id);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
@@ -738,6 +783,7 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
});
await regenerateWorkOrderOperations(workOrderId);
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
@@ -773,6 +819,8 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
},
});
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
@@ -859,6 +907,8 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
});
});
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}
@@ -917,6 +967,8 @@ export async function recordWorkOrderCompletion(workOrderId: string, payload: Wo
});
});
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}