manufacturing

This commit is contained in:
2026-03-15 11:12:58 -05:00
parent 6644ba2932
commit 0596970b99
25 changed files with 2097 additions and 37 deletions

View File

@@ -0,0 +1,76 @@
-- CreateTable
CREATE TABLE "WorkOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderNumber" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"projectId" TEXT,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"completedQuantity" INTEGER NOT NULL DEFAULT 0,
"dueDate" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrder_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderMaterialIssue" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"componentItemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" 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 "WorkOrderMaterialIssue_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderCompletion" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" 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 "WorkOrderCompletion_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderCompletion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkOrder_workOrderNumber_key" ON "WorkOrder"("workOrderNumber");
-- CreateIndex
CREATE INDEX "WorkOrder_itemId_createdAt_idx" ON "WorkOrder"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrder_projectId_dueDate_idx" ON "WorkOrder"("projectId", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_status_dueDate_idx" ON "WorkOrder"("status", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_warehouseId_createdAt_idx" ON "WorkOrder"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_workOrderId_createdAt_idx" ON "WorkOrderMaterialIssue"("workOrderId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_componentItemId_createdAt_idx" ON "WorkOrderMaterialIssue"("componentItemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderCompletion_workOrderId_createdAt_idx" ON "WorkOrderCompletion"("workOrderId", "createdAt");

View File

@@ -22,6 +22,8 @@ model User {
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
}
model Role {
@@ -125,6 +127,8 @@ model InventoryItem {
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
}
model Warehouse {
@@ -137,6 +141,8 @@ model Warehouse {
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
}
model Customer {
@@ -203,6 +209,8 @@ model WarehouseLocation {
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
@@unique([warehouseId, code])
@@index([warehouseId])
@@ -399,12 +407,75 @@ model Project {
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
@@index([customerId, createdAt])
@@index([ownerId, dueDate])
@@index([status, priority])
}
model WorkOrder {
id String @id @default(cuid())
workOrderNumber String @unique
itemId String
projectId String?
warehouseId String
locationId String
status String
quantity Int
completedQuantity Int @default(0)
dueDate DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
@@index([itemId, createdAt])
@@index([projectId, dueDate])
@@index([status, dueDate])
@@index([warehouseId, createdAt])
}
model WorkOrderMaterialIssue {
id String @id @default(cuid())
workOrderId String
componentItemId String
warehouseId String
locationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
componentItem InventoryItem @relation(fields: [componentItemId], references: [id], onDelete: Restrict)
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([workOrderId, createdAt])
@@index([componentItemId, createdAt])
}
model WorkOrderCompletion {
id String @id @default(cuid())
workOrderId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([workOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique

View File

@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { manufacturingRouter } from "./modules/manufacturing/router.js";
import { projectsRouter } from "./modules/projects/router.js";
import { purchasingRouter } from "./modules/purchasing/router.js";
import { salesRouter } from "./modules/sales/router.js";
@@ -56,6 +57,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/manufacturing", manufacturingRouter);
app.use("/api/v1/projects", projectsRouter);
app.use("/api/v1/purchasing", purchasingRouter);
app.use("/api/v1/sales", salesRouter);

View File

@@ -13,6 +13,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.crmWrite]: "Manage CRM records",
[permissions.inventoryRead]: "View inventory items and BOMs",
[permissions.inventoryWrite]: "Manage inventory items and BOMs",
[permissions.manufacturingRead]: "View manufacturing work orders and execution data",
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
[permissions.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",

View File

@@ -0,0 +1,180 @@
import { permissions } from "@mrp/shared";
import { workOrderStatuses } from "@mrp/shared/dist/manufacturing/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createWorkOrder,
getWorkOrderById,
issueWorkOrderMaterial,
listManufacturingItemOptions,
listManufacturingProjectOptions,
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderStatus,
} from "./service.js";
const workOrderSchema = z.object({
itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
});
const workOrderFiltersSchema = z.object({
q: z.string().optional(),
status: z.enum(workOrderStatuses).optional(),
projectId: z.string().optional(),
itemId: z.string().optional(),
});
const statusUpdateSchema = z.object({
status: z.enum(workOrderStatuses),
});
const materialIssueSchema = z.object({
componentItemId: z.string().trim().min(1),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
quantity: z.number().int().positive(),
notes: z.string(),
});
const completionSchema = z.object({
quantity: z.number().int().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const manufacturingRouter = Router();
manufacturingRouter.get("/items/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingItemOptions());
});
manufacturingRouter.get("/projects/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const parsed = workOrderFiltersSchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order filters are invalid.");
}
return ok(response, await listWorkOrders(parsed.data));
});
manufacturingRouter.get("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return fail(response, 404, "WORK_ORDER_NOT_FOUND", "Work order was not found.");
}
return ok(response, workOrder);
});
manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await createWorkOrder(parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await updateWorkOrder(workOrderId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = statusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
}
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = materialIssueSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Material-issue payload is invalid.");
}
const result = await issueWorkOrderMaterial(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.post("/work-orders/:workOrderId/completions", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = completionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Completion payload is invalid.");
}
const result = await recordWorkOrderCompletion(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});

View File

@@ -0,0 +1,671 @@
import type {
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
} from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const workOrderModel = (prisma as any).workOrder;
type WorkOrderRecord = {
id: string;
workOrderNumber: string;
status: string;
quantity: number;
completedQuantity: number;
dueDate: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
item: {
id: string;
sku: string;
name: string;
type: string;
unitOfMeasure: string;
bomLines: Array<{
quantity: number;
unitOfMeasure: string;
componentItem: {
id: string;
sku: string;
name: string;
};
}>;
};
project: {
id: string;
projectNumber: string;
name: string;
customer: {
name: string;
};
} | null;
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
materialIssues: Array<{
id: string;
quantity: number;
notes: string;
createdAt: Date;
componentItem: {
id: string;
sku: string;
name: string;
};
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
completions: Array<{
id: string;
quantity: number;
notes: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
};
function buildInclude() {
return {
item: {
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
},
project: {
include: {
customer: {
select: {
name: true,
},
},
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
materialIssues: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
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" }],
},
completions: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
};
}
function getUserName(user: { firstName: string; lastName: string } | null) {
return user ? `${user.firstName} ${user.lastName}`.trim() : "System";
}
function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
return {
id: record.id,
workOrderNumber: record.workOrderNumber,
status: record.status as WorkOrderStatus,
itemId: record.item.id,
itemSku: record.item.sku,
itemName: record.item.name,
projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null,
quantity: record.quantity,
completedQuantity: record.completedQuantity,
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
warehouseId: record.warehouse.id,
warehouseCode: record.warehouse.code,
warehouseName: record.warehouse.name,
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
const issuedByComponent = new Map<string, number>();
for (const issue of record.materialIssues) {
issuedByComponent.set(issue.componentItem.id, (issuedByComponent.get(issue.componentItem.id) ?? 0) + issue.quantity);
}
return {
...mapSummary(record),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
itemType: record.item.type,
itemUnitOfMeasure: record.item.unitOfMeasure,
projectCustomerName: record.project?.customer.name ?? null,
dueQuantity: record.quantity - record.completedQuantity,
materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
return {
componentItemId: line.componentItem.id,
componentSku: line.componentItem.sku,
componentName: line.componentItem.name,
unitOfMeasure: line.unitOfMeasure,
quantityPer: line.quantity,
requiredQuantity,
issuedQuantity,
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
};
}),
materialIssues: record.materialIssues.map((issue) => ({
id: issue.id,
componentItemId: issue.componentItem.id,
componentSku: issue.componentItem.sku,
componentName: issue.componentItem.name,
quantity: issue.quantity,
warehouseId: issue.warehouse.id,
warehouseCode: issue.warehouse.code,
warehouseName: issue.warehouse.name,
locationId: issue.location.id,
locationCode: issue.location.code,
locationName: issue.location.name,
notes: issue.notes,
createdAt: issue.createdAt.toISOString(),
createdByName: getUserName(issue.createdBy),
})),
completions: record.completions.map((completion) => ({
id: completion.id,
quantity: completion.quantity,
notes: completion.notes,
createdAt: completion.createdAt.toISOString(),
createdByName: getUserName(completion.createdBy),
})),
};
}
async function nextWorkOrderNumber() {
const next = (await workOrderModel.count()) + 1;
return `WO-${String(next).padStart(5, "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 validateWorkOrderInput(payload: WorkOrderInput) {
const item = await prisma.inventoryItem.findUnique({
where: { id: payload.itemId },
select: {
id: true,
type: true,
status: true,
},
});
if (!item) {
return { ok: false as const, reason: "Build item was not found." };
}
if (item.status !== "ACTIVE") {
return { ok: false as const, reason: "Build item must be active." };
}
if (item.type !== "ASSEMBLY" && item.type !== "MANUFACTURED") {
return { ok: false as const, reason: "Work orders can only be created for assembly or manufactured items." };
}
if (payload.projectId) {
const project = await prisma.project.findUnique({
where: { id: payload.projectId },
select: { id: true },
});
if (!project) {
return { ok: false as const, reason: "Linked project 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." };
}
return { ok: true as const };
}
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
const items = await prisma.inventoryItem.findMany({
where: {
status: "ACTIVE",
type: {
in: ["ASSEMBLY", "MANUFACTURED"],
},
},
select: {
id: true,
sku: true,
name: true,
type: true,
unitOfMeasure: true,
},
orderBy: [{ sku: "asc" }],
});
return items;
}
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
const projects = await prisma.project.findMany({
where: {
status: {
notIn: ["COMPLETE"],
},
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return projects.map((project) => ({
id: project.id,
projectNumber: project.projectNumber,
name: project.name,
customerName: project.customer.name,
status: project.status,
}));
}
export async function listWorkOrders(filters: {
q?: string;
status?: WorkOrderStatus;
projectId?: string;
itemId?: string;
} = {}) {
const query = filters.q?.trim();
const workOrders = await workOrderModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.projectId ? { projectId: filters.projectId } : {}),
...(filters.itemId ? { itemId: filters.itemId } : {}),
...(query
? {
OR: [
{ workOrderNumber: { contains: query } },
{ item: { sku: { contains: query } } },
{ item: { name: { contains: query } } },
{ project: { projectNumber: { contains: query } } },
{ project: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return workOrders.map((workOrder: unknown) => mapSummary(workOrder as WorkOrderRecord));
}
export async function getWorkOrderById(workOrderId: string) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
}
export async function createWorkOrder(payload: WorkOrderInput) {
const validated = await validateWorkOrderInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
const workOrderNumber = await nextWorkOrderNumber();
const created = await workOrderModel.create({
data: {
workOrderNumber,
itemId: payload.itemId,
projectId: payload.projectId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
quantity: payload.quantity,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
const workOrder = await getWorkOrderById(created.id);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
id: true,
completedQuantity: true,
},
});
if (!existing) {
return { ok: false as const, reason: "Work order was not found." };
}
if (payload.quantity < existing.completedQuantity) {
return { ok: false as const, reason: "Planned quantity cannot be less than the already completed quantity." };
}
const validated = await validateWorkOrderInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
await workOrderModel.update({
where: { id: workOrderId },
data: {
itemId: payload.itemId,
projectId: payload.projectId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
quantity: payload.quantity,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
});
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
id: true,
status: true,
quantity: true,
completedQuantity: true,
},
});
if (!existing) {
return { ok: false as const, reason: "Work order was not found." };
}
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
}
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
return { ok: false as const, reason: "Use the completion action to finish a work order." };
}
await workOrderModel.update({
where: { id: workOrderId },
data: {
status,
},
});
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
return { ok: false as const, reason: "Material can only be issued to released or active work orders." };
}
const componentRequirement = (workOrder as WorkOrderRecord).item.bomLines.find((line) => line.componentItem.id === payload.componentItemId);
if (!componentRequirement) {
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
}
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 currentDetail = mapDetail(workOrder as WorkOrderRecord);
const currentRequirement = currentDetail.materialRequirements.find(
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
);
if (!currentRequirement) {
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
}
if (payload.quantity > currentRequirement.remainingQuantity) {
return { ok: false as const, reason: "Material issue exceeds the remaining required quantity." };
}
const onHand = await getItemLocationOnHand(payload.componentItemId, payload.warehouseId, payload.locationId);
if (onHand < payload.quantity) {
return { ok: false as const, reason: "Material issue would drive the selected location below zero on-hand." };
}
await prisma.$transaction(async (tx) => {
const transactionClient = tx as any;
await transactionClient.workOrderMaterialIssue.create({
data: {
workOrderId,
componentItemId: payload.componentItemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.inventoryTransaction.create({
data: {
itemId: payload.componentItemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: `${(workOrder as WorkOrderRecord).workOrderNumber} material issue`,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.workOrder.update({
where: { id: workOrderId },
data: {
status: workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD" ? "IN_PROGRESS" : workOrder.status,
},
});
});
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}
export async function recordWorkOrderCompletion(workOrderId: string, payload: WorkOrderCompletionInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
return { ok: false as const, reason: "Completion can only be posted to released or active work orders." };
}
const remainingQuantity = workOrder.quantity - workOrder.completedQuantity;
if (payload.quantity > remainingQuantity) {
return { ok: false as const, reason: "Completion quantity exceeds the remaining build quantity." };
}
const nextCompletedQuantity = workOrder.completedQuantity + payload.quantity;
const nextStatus = nextCompletedQuantity >= workOrder.quantity ? "COMPLETE" : "IN_PROGRESS";
await prisma.$transaction(async (tx) => {
const transactionClient = tx as any;
await transactionClient.workOrderCompletion.create({
data: {
workOrderId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.inventoryTransaction.create({
data: {
itemId: workOrder.item.id,
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
transactionType: "RECEIPT",
quantity: payload.quantity,
reference: `${workOrder.workOrderNumber} production completion`,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.workOrder.update({
where: { id: workOrderId },
data: {
completedQuantity: nextCompletedQuantity,
status: nextStatus,
},
});
});
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}