This commit is contained in:
2026-03-18 12:05:28 -05:00
parent f12744f05d
commit 69dfec98ad
16 changed files with 245 additions and 18 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "WorkOrder" ADD COLUMN "holdReason" TEXT;

View File

@@ -653,6 +653,7 @@ model WorkOrder {
warehouseId String
locationId String
status String
holdReason String?
quantity Int
completedQuantity Int @default(0)
dueDate DateTime?

View File

@@ -59,6 +59,7 @@ const workOrderFiltersSchema = z.object({
const statusUpdateSchema = z.object({
status: z.enum(workOrderStatuses),
reason: z.string().nullable().optional(),
});
const materialIssueSchema = z.object({
@@ -215,7 +216,7 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
}
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status, request.authUser?.id);
const result = await updateWorkOrderStatus(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}

View File

@@ -15,6 +15,7 @@ import type {
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderStatusUpdateInput,
WorkOrderSummaryDto,
} from "@mrp/shared";
@@ -41,6 +42,7 @@ type WorkOrderRecord = {
id: string;
workOrderNumber: string;
status: string;
holdReason: string | null;
quantity: number;
completedQuantity: number;
dueDate: Date | null;
@@ -390,6 +392,7 @@ function mapDetail(
return {
...mapSummary(record),
notes: record.notes,
holdReason: record.holdReason,
createdAt: record.createdAt.toISOString(),
itemType: record.item.type,
itemUnitOfMeasure: record.item.unitOfMeasure,
@@ -1294,12 +1297,18 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
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, actorId?: string | null) {
export async function updateWorkOrderStatus(
workOrderId: string,
payload: WorkOrderStatusUpdateInput,
actorId?: string | null
) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
id: true,
workOrderNumber: true,
status: true,
holdReason: true,
quantity: true,
completedQuantity: true,
},
@@ -1309,18 +1318,24 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
return { ok: false as const, reason: "Work order was not found." };
}
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
if (existing.status === "COMPLETE" && payload.status !== "COMPLETE") {
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
}
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
if (payload.status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
return { ok: false as const, reason: "Use the completion action to finish a work order." };
}
const nextHoldReason = payload.reason?.trim() ?? "";
if (payload.status === "ON_HOLD" && nextHoldReason.length === 0) {
return { ok: false as const, reason: "An On Hold reason is required before the work order can be paused." };
}
await workOrderModel.update({
where: { id: workOrderId },
data: {
status,
status: payload.status,
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
},
});
@@ -1333,10 +1348,12 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
entityType: "work-order",
entityId: workOrderId,
action: "status.updated",
summary: `Updated work order ${workOrder.workOrderNumber} to ${status}.`,
summary: `Updated work order ${workOrder.workOrderNumber} to ${payload.status}.`,
metadata: {
workOrderNumber: workOrder.workOrderNumber,
status,
previousStatus: existing.status,
status: payload.status,
holdReason: payload.status === "ON_HOLD" ? nextHoldReason : null,
},
});
}

View File

@@ -14,6 +14,7 @@ import {
listProjectQuoteOptions,
listProjectShipmentOptions,
updateProject,
updateProjectMilestoneStatus,
} from "./service.js";
const projectSchema = z.object({
@@ -51,6 +52,10 @@ const projectOptionQuerySchema = z.object({
customerId: z.string().optional(),
});
const milestoneStatusSchema = z.object({
status: z.enum(projectMilestoneStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -147,3 +152,23 @@ projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]
return ok(response, result.project);
});
projectsRouter.patch("/:projectId/milestones/:milestoneId/status", requirePermissions([permissions.projectsWrite]), async (request, response) => {
const projectId = getRouteParam(request.params.projectId);
const milestoneId = getRouteParam(request.params.milestoneId);
if (!projectId || !milestoneId) {
return fail(response, 400, "INVALID_INPUT", "Project or milestone id is invalid.");
}
const parsed = milestoneStatusSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Project milestone status payload is invalid.");
}
const result = await updateProjectMilestoneStatus(projectId, milestoneId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.project);
});

View File

@@ -11,6 +11,8 @@ import type {
ProjectInput,
ProjectMilestoneDto,
ProjectMilestoneInput,
ProjectMilestoneStatus,
ProjectMilestoneStatusUpdateInput,
ProjectOwnerOptionDto,
ProjectPriority,
ProjectRollupDto,
@@ -1266,3 +1268,60 @@ export async function updateProject(projectId: string, payload: ProjectInput, ac
}
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}
export async function updateProjectMilestoneStatus(
projectId: string,
milestoneId: string,
payload: ProjectMilestoneStatusUpdateInput,
actorId?: string | null
) {
const existing = await prisma.projectMilestone.findUnique({
where: { id: milestoneId },
select: {
id: true,
projectId: true,
title: true,
status: true,
completedAt: true,
project: {
select: {
id: true,
projectNumber: true,
},
},
},
});
if (!existing || existing.projectId !== projectId) {
return { ok: false as const, reason: "Project milestone was not found." };
}
const nextStatus = payload.status as ProjectMilestoneStatus;
await prisma.projectMilestone.update({
where: { id: milestoneId },
data: {
status: nextStatus,
completedAt: nextStatus === "COMPLETE" ? existing.completedAt ?? new Date() : null,
},
});
const project = await getProjectById(projectId);
if (project) {
await logAuditEvent({
actorId,
entityType: "project",
entityId: projectId,
action: "milestone.status.updated",
summary: `Updated milestone ${existing.title} on ${existing.project.projectNumber} to ${nextStatus}.`,
metadata: {
projectNumber: existing.project.projectNumber,
milestoneId,
milestoneTitle: existing.title,
previousStatus: existing.status,
status: nextStatus,
},
});
}
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}