9a42615a21
- dailyCostCents, hoursPerDay, percentage now validated at API boundary - vacation router no longer uses ctx.db as any - scenarioData reads through typed Zod schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
/**
|
|
* Typed Zod schemas for scenario simulation data structures.
|
|
*
|
|
* The scenario feature does not persist scenarioData in JSONB — it computes
|
|
* everything on-the-fly from assignments, projects, and resources. These schemas
|
|
* describe the shapes that flow in and out of the simulation layer so that any
|
|
* deserialization (e.g. from a future scenario snapshot) can be validated at
|
|
* the API boundary rather than silently returning wrong-typed data.
|
|
*
|
|
* TODO(scenario-snapshots): if we ever persist scenario results to a JSONB
|
|
* column or a dedicated table, add `.parse()` at the DB read-boundary here.
|
|
*/
|
|
|
|
import { z } from "zod";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input schemas (mirrored from scenario-procedure-support for central reference)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const ScenarioChangeInputSchema = z.object({
|
|
assignmentId: z.string().optional(),
|
|
resourceId: z.string().optional(),
|
|
roleId: z.string().optional(),
|
|
startDate: z.coerce.date(),
|
|
endDate: z.coerce.date(),
|
|
hoursPerDay: z.number().positive().max(24),
|
|
remove: z.boolean().optional(),
|
|
});
|
|
|
|
export const ScenarioSimulationRequestSchema = z.object({
|
|
projectId: z.string(),
|
|
changes: z.array(ScenarioChangeInputSchema).min(1),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Output schemas (describe the shape returned by simulateProjectScenario)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ScenarioSummarySchema = z.object({
|
|
totalCostCents: z.number().int(),
|
|
totalHours: z.number(),
|
|
headcount: z.number().int(),
|
|
skillCount: z.number().int(),
|
|
});
|
|
|
|
const ScenarioResourceImpactSchema = z.object({
|
|
resourceId: z.string(),
|
|
resourceName: z.string(),
|
|
chargeabilityTarget: z.number().nullable(),
|
|
currentUtilization: z.number(),
|
|
scenarioUtilization: z.number(),
|
|
utilizationDelta: z.number(),
|
|
isOverallocated: z.boolean(),
|
|
});
|
|
|
|
export const ScenarioSimulationResultSchema = z.object({
|
|
baseline: ScenarioSummarySchema,
|
|
scenario: ScenarioSummarySchema,
|
|
delta: z.object({
|
|
costCents: z.number().int(),
|
|
hours: z.number(),
|
|
headcount: z.number().int(),
|
|
skillCoveragePct: z.number().int(),
|
|
}),
|
|
resourceImpacts: z.array(ScenarioResourceImpactSchema),
|
|
warnings: z.array(z.string()),
|
|
budgetCents: z.number().int(),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Baseline output schema (describe the shape returned by readProjectScenarioBaseline)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ScenarioBaselineAllocationSchema = z.object({
|
|
id: z.string(),
|
|
resourceId: z.string().nullable(),
|
|
resourceName: z.string(),
|
|
resourceEid: z.string(),
|
|
lcrCents: z.number().int(),
|
|
roleId: z.string().nullable(),
|
|
roleName: z.string(),
|
|
roleColor: z.string().nullable(),
|
|
startDate: z.string(),
|
|
endDate: z.string(),
|
|
hoursPerDay: z.number(),
|
|
status: z.string(),
|
|
costCents: z.number().int(),
|
|
totalHours: z.number(),
|
|
workingDays: z.number(),
|
|
});
|
|
|
|
const ScenarioBaselineDemandSchema = z.object({
|
|
id: z.string(),
|
|
roleId: z.string().nullable(),
|
|
roleName: z.string(),
|
|
roleColor: z.string().nullable(),
|
|
startDate: z.string(),
|
|
endDate: z.string(),
|
|
hoursPerDay: z.number(),
|
|
headcount: z.number().int(),
|
|
status: z.string(),
|
|
});
|
|
|
|
export const ScenarioBaselineResultSchema = z.object({
|
|
project: z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
shortCode: z.string().nullable(),
|
|
startDate: z.date(),
|
|
endDate: z.date(),
|
|
budgetCents: z.number().int().nullable(),
|
|
orderType: z.string().nullable(),
|
|
}),
|
|
assignments: z.array(ScenarioBaselineAllocationSchema),
|
|
demands: z.array(ScenarioBaselineDemandSchema),
|
|
totalCostCents: z.number().int(),
|
|
totalHours: z.number(),
|
|
budgetCents: z.number().int().nullable(),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: validate scenario simulation result at the API boundary
|
|
//
|
|
// Usage:
|
|
// const result = parseScenarioSimulationResult(await simulateProjectScenario(db, input));
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function parseScenarioSimulationResult(
|
|
raw: unknown,
|
|
): z.infer<typeof ScenarioSimulationResultSchema> {
|
|
return ScenarioSimulationResultSchema.parse(raw);
|
|
}
|
|
|
|
export function parseScenarioBaselineResult(
|
|
raw: unknown,
|
|
): z.infer<typeof ScenarioBaselineResultSchema> {
|
|
return ScenarioBaselineResultSchema.parse(raw);
|
|
}
|
|
|
|
export type ScenarioChangeInput = z.infer<typeof ScenarioChangeInputSchema>;
|
|
export type ScenarioSimulationRequest = z.infer<typeof ScenarioSimulationRequestSchema>;
|
|
export type ScenarioSimulationResult = z.infer<typeof ScenarioSimulationResultSchema>;
|
|
export type ScenarioBaselineResult = z.infer<typeof ScenarioBaselineResultSchema>;
|