350 lines
10 KiB
TypeScript
350 lines
10 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.
|
|
export const OperationStatuses = ["pending", "in_progress", "partial", "completed"] as const;
|
|
|
|
export const CreateOperationSchema = z.object({
|
|
templateId: z.string().min(1).nullable().optional(),
|
|
name: NonEmpty,
|
|
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(),
|
|
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(),
|
|
});
|