Files
mrp-qrcode/lib/schemas.ts
T
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

368 lines
11 KiB
TypeScript

import { z } from "zod";
// ---- shared --------------------------------------------------------------
const NonEmpty = z.string().trim().min(1, "Required").max(200);
const Code = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9._\-/]+$/, "Use letters, digits, . _ - /");
const OptionalText = z
.string()
.trim()
.max(5000)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional();
const JsonString = z
.string()
.max(10_000)
.refine(
(s) => {
if (s.length === 0) return true;
try {
JSON.parse(s);
return true;
} catch {
return false;
}
},
{ message: "Must be valid JSON" },
)
.transform((s) => (s.length === 0 ? null : s))
.nullable()
.optional();
const Pin = z.string().regex(/^\d{4}$/, "PIN must be exactly 4 digits");
// ---- users ---------------------------------------------------------------
export const CreateAdminSchema = z.object({
role: z.literal("admin"),
name: NonEmpty,
email: z.string().email().max(200),
password: z.string().min(8).max(200),
});
export const CreateOperatorSchema = z.object({
role: z.literal("operator"),
name: NonEmpty,
pin: Pin,
});
export const CreateUserSchema = z.discriminatedUnion("role", [
CreateAdminSchema,
CreateOperatorSchema,
]);
export const UpdateUserSchema = z
.object({
name: NonEmpty.optional(),
active: z.boolean().optional(),
email: z.string().email().max(200).optional(),
password: z.string().min(8).max(200).optional(),
pin: Pin.optional(),
})
.strict();
// ---- machines ------------------------------------------------------------
export const MachineKinds = [
"NCT_PUNCH",
"PRESS_BRAKE",
"RIVET",
"WELD",
"LASER",
"SHEAR",
"ASSEMBLY",
"OTHER",
] as const;
export const CreateMachineSchema = z.object({
name: NonEmpty,
kind: z.enum(MachineKinds),
location: OptionalText,
notes: OptionalText,
});
export const UpdateMachineSchema = z
.object({
name: NonEmpty.optional(),
kind: z.enum(MachineKinds).optional(),
location: OptionalText,
notes: OptionalText,
active: z.boolean().optional(),
})
.strict();
// ---- operation templates -------------------------------------------------
export const CreateTemplateSchema = z.object({
name: NonEmpty,
machineId: z.string().min(1).nullable().optional(),
defaultSettings: JsonString,
defaultInstructions: OptionalText,
qcRequired: z.boolean().default(false),
});
export const UpdateTemplateSchema = z
.object({
name: NonEmpty.optional(),
machineId: z.string().min(1).nullable().optional(),
defaultSettings: JsonString,
defaultInstructions: OptionalText,
qcRequired: z.boolean().optional(),
active: z.boolean().optional(),
})
.strict();
// ---- projects ------------------------------------------------------------
export const ProjectStatuses = ["planning", "in_progress", "completed", "cancelled"] as const;
export const CreateProjectSchema = z.object({
code: Code,
name: NonEmpty,
customerCode: z
.string()
.trim()
.max(64)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
dueDate: z.coerce.date().nullable().optional(),
notes: OptionalText,
});
export const UpdateProjectSchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
customerCode: z
.string()
.trim()
.max(64)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
dueDate: z.coerce.date().nullable().optional(),
status: z.enum(ProjectStatuses).optional(),
notes: OptionalText,
})
.strict();
// ---- assemblies / parts --------------------------------------------------
export const CreateAssemblySchema = z.object({
code: Code,
name: NonEmpty,
qty: z.coerce.number().int().positive().max(100000).default(1),
notes: OptionalText,
});
export const UpdateAssemblySchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
qty: z.coerce.number().int().positive().max(100000).optional(),
notes: OptionalText,
stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(),
})
.strict();
export const CreatePartSchema = z.object({
code: Code,
name: NonEmpty,
material: z
.string()
.trim()
.max(120)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
qty: z.coerce.number().int().positive().max(100000).default(1),
notes: OptionalText,
});
export const UpdatePartSchema = z
.object({
code: Code.optional(),
name: NonEmpty.optional(),
material: z
.string()
.trim()
.max(120)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
qty: z.coerce.number().int().positive().max(100000).optional(),
notes: OptionalText,
stepFileId: z.string().min(1).nullable().optional(),
drawingFileId: z.string().min(1).nullable().optional(),
cutFileId: z.string().min(1).nullable().optional(),
thumbnailFileId: z.string().min(1).nullable().optional(),
})
.strict();
// ---- operations ---------------------------------------------------------
// "partial" = an operation that was started, had units logged, and then paused.
// Behaves like "pending" for claim purposes (any operator can resume it) but
// visually distinct so admins can see work-in-flight that isn't actively
// being run right now.
// "qc_failed" = operator submitted a failing QC record on close. The step is
// blocked until an admin clears the failure via the qc-reset route (which
// rolls the step back to pending or partial depending on how much work was
// already logged against it).
export const OperationStatuses = [
"pending",
"in_progress",
"partial",
"completed",
"qc_failed",
] as const;
// "work" (default) = normal production step; may optionally require QC on close.
// "qc" = dedicated inspection step — the whole point of the op is the QC
// record, so close always requires the inline qc payload and we don't care
// about unit counts or machine assignment.
export const OperationKinds = ["work", "qc"] as const;
export const CreateOperationSchema = z.object({
templateId: z.string().min(1).nullable().optional(),
name: NonEmpty,
kind: z.enum(OperationKinds).default("work").optional(),
machineId: z.string().min(1).nullable().optional(),
settings: JsonString,
materialNotes: OptionalText,
instructions: OptionalText,
qcRequired: z.boolean().default(false),
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
sequence: z.coerce.number().int().positive().max(10000).optional(),
});
export const UpdateOperationSchema = z
.object({
templateId: z.string().min(1).nullable().optional(),
name: NonEmpty.optional(),
kind: z.enum(OperationKinds).optional(),
machineId: z.string().min(1).nullable().optional(),
settings: JsonString,
materialNotes: OptionalText,
instructions: OptionalText,
qcRequired: z.boolean().optional(),
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
sequence: z.coerce.number().int().positive().max(10000).optional(),
status: z.enum(OperationStatuses).optional(),
})
.strict();
export const ReorderOperationsSchema = z.object({
order: z.array(z.string().min(1)).min(1),
});
// ---- operator scan actions ----------------------------------------------
// A scan-page "Pause" — stops the clock but does not complete the step. The
// operator can enter a partial unit count before dropping the claim.
// ---- fasteners ----------------------------------------------------------
export const CreateFastenerSchema = z.object({
partNumber: z.string().trim().min(1).max(120),
description: NonEmpty,
qty: z.coerce.number().int().positive().max(1_000_000),
supplier: z
.string()
.trim()
.max(200)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(),
notes: OptionalText,
});
export const UpdateFastenerSchema = z
.object({
partNumber: z.string().trim().min(1).max(120).optional(),
description: NonEmpty.optional(),
qty: z.coerce.number().int().positive().max(1_000_000).optional(),
supplier: z
.string()
.trim()
.max(200)
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(),
notes: OptionalText,
})
.strict();
// ---- purchase orders ----------------------------------------------------
export const PoStatuses = ["draft", "sent", "partial", "received", "cancelled"] as const;
// A single line on a draft PO. Fastener must belong to the same project (the
// route handler verifies that; we keep the schema pure).
const PoLineInput = z.object({
fastenerId: z.string().min(1),
qty: z.coerce.number().int().positive().max(1_000_000),
unitCost: z.coerce.number().min(0).max(1_000_000).nullable().optional(),
});
export const CreatePOSchema = z.object({
vendor: NonEmpty,
notes: OptionalText,
lines: z.array(PoLineInput).min(1, "Add at least one line"),
});
// PATCH: vendor + notes are free to change any time. Lines can only be
// rewritten while the PO is in draft — the route enforces that.
export const UpdatePOSchema = z
.object({
vendor: NonEmpty.optional(),
notes: OptionalText,
lines: z.array(PoLineInput).min(1).optional(),
})
.strict();
// Status transitions are validated in the route (see PO_TRANSITIONS).
export const UpdatePOStatusSchema = z.object({
status: z.enum(PoStatuses),
});
// Receipt: bump receivedQty by `qty` for each listed line. If every line on
// the PO is fully received the route auto-moves status to "received";
// otherwise it moves to "partial".
export const ReceivePOSchema = z.object({
receipts: z
.array(
z.object({
lineId: z.string().min(1),
qty: z.coerce.number().int().positive().max(1_000_000),
}),
)
.min(1),
});
export const ReleaseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
});
// A scan-page "Done". If the operation was flagged qcRequired the operator
// must stamp an inline pass/fail before we allow the close.
export const CloseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
qc: z
.object({
passed: z.boolean(),
notes: OptionalText,
measurements: JsonString,
})
.optional(),
});