manufacturing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
180
server/src/modules/manufacturing/router.ts
Normal file
180
server/src/modules/manufacturing/router.ts
Normal 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);
|
||||
});
|
||||
671
server/src/modules/manufacturing/service.ts
Normal file
671
server/src/modules/manufacturing/service.ts
Normal 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." };
|
||||
}
|
||||
Reference in New Issue
Block a user