import { AllocationStatus, EstimateExportFormat, EstimateStatus, EstimateVersionStatus, } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { approveEstimateVersion, createEstimate, createEstimateExport, createEstimatePlanningHandoff, createEstimateRevision, listEstimates, submitEstimateVersion, } from "../index.js"; describe("estimate use-cases", () => { it("creates an estimate with an initial version payload", async () => { const create = vi.fn().mockResolvedValue({ id: "est_1", versions: [{ id: "ver_1" }] }); const db = { estimate: { create, }, }; const result = await createEstimate(db as never, { name: "CGI Estimate", baseCurrency: "EUR", status: EstimateStatus.DRAFT, assumptions: [ { category: "commercial", key: "pricingStructure", label: "Pricing Structure", valueType: "string", value: "fixed-bid", sortOrder: 0, }, ], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], }); expect(result).toEqual({ id: "est_1", versions: [{ id: "ver_1" }] }); expect(create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ name: "CGI Estimate", latestVersionNumber: 1, versions: { create: expect.objectContaining({ versionNumber: 1, assumptions: { create: [ expect.objectContaining({ key: "pricingStructure", value: "fixed-bid", }), ], }, }), }, }), }), ); }); it("lists estimates with optional query filters", async () => { const findMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findMany, }, }; await listEstimates(db as never, { status: EstimateStatus.DRAFT, query: "cgi", }); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: EstimateStatus.DRAFT, OR: expect.any(Array), }), }), ); }); it("submits the working estimate version and moves the estimate into review", async () => { const estimateId = "est_1"; const workingVersionId = "ver_working"; const existing = { id: estimateId, status: EstimateStatus.DRAFT, latestVersionNumber: 1, versions: [ { id: workingVersionId, versionNumber: 1, status: EstimateVersionStatus.WORKING, lockedAt: null, assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, { id: "ver_old_submit", versionNumber: 0, status: EstimateVersionStatus.SUBMITTED, lockedAt: new Date("2026-03-01"), assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, ], }; const refreshed = { ...existing, status: EstimateStatus.IN_REVIEW, versions: [ { ...existing.versions[0], status: EstimateVersionStatus.SUBMITTED, lockedAt: new Date("2026-03-13"), }, { ...existing.versions[1], status: EstimateVersionStatus.SUPERSEDED, }, ], }; const findUnique = vi.fn().mockResolvedValueOnce(existing).mockResolvedValueOnce(refreshed); const updateMany = vi.fn().mockResolvedValue({ count: 1 }); const updateVersion = vi.fn().mockResolvedValue({}); const updateEstimate = vi.fn().mockResolvedValue({}); const db = { estimate: { findUnique, update: updateEstimate, }, estimateVersion: { updateMany, update: updateVersion, }, $transaction: vi.fn(async (callback) => callback({ estimateVersion: { updateMany, update: updateVersion, }, estimate: { update: updateEstimate, }, }), ), }; const result = await submitEstimateVersion(db as never, { estimateId }); expect(result.status).toBe(EstimateStatus.IN_REVIEW); expect(updateMany).toHaveBeenCalledWith({ where: { id: { in: ["ver_old_submit"] } }, data: { status: EstimateVersionStatus.SUPERSEDED }, }); expect(updateVersion).toHaveBeenCalledWith( expect.objectContaining({ where: { id: workingVersionId }, data: expect.objectContaining({ status: EstimateVersionStatus.SUBMITTED, lockedAt: expect.any(Date), }), }), ); expect(updateEstimate).toHaveBeenCalledWith({ where: { id: estimateId }, data: { status: EstimateStatus.IN_REVIEW }, }); }); it("creates a new working revision cloned from the selected locked version", async () => { const estimateId = "est_2"; const sourceScopeId = "scope_old"; const existing = { id: estimateId, status: EstimateStatus.APPROVED, latestVersionNumber: 2, versions: [ { id: "ver_approved", versionNumber: 2, label: "Approved", status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), notes: "approved baseline", projectSnapshot: { shortCode: "PRJ" }, assumptions: [ { id: "assumption_1", category: "commercial", key: "pricing", label: "Pricing", valueType: "string", value: "fixed", sortOrder: 0, notes: null, }, ], scopeItems: [ { id: sourceScopeId, sequenceNo: 1, scopeType: "SHOT", packageCode: null, name: "Shot 010", description: null, scene: null, page: null, location: null, assumptionCategory: null, technicalSpec: {}, frameCount: null, itemCount: null, unitMode: null, internalComments: null, externalComments: null, sortOrder: 0, metadata: {}, }, ], demandLines: [ { id: "line_1", scopeItemId: sourceScopeId, roleId: null, resourceId: null, lineType: "LABOR", name: "Comp", chapter: null, hours: 40, days: null, fte: null, rateSource: null, costRateCents: 5000, billRateCents: 8000, currency: "EUR", costTotalCents: 200000, priceTotalCents: 320000, monthlySpread: {}, staffingAttributes: {}, metadata: {}, }, ], resourceSnapshots: [ { id: "snap_1", resourceId: null, sourceEid: null, displayName: "Alex Artist", chapter: null, roleId: null, currency: "EUR", lcrCents: 5000, ucrCents: 8000, fte: null, location: null, country: null, level: null, workType: null, attributes: {}, }, ], metrics: [ { id: "metric_1", key: "total_hours", label: "Total Hours", metricGroup: "summary", valueDecimal: 40, valueCents: null, currency: null, metadata: {}, }, ], exports: [], }, ], }; const refreshed = { ...existing, status: EstimateStatus.DRAFT, latestVersionNumber: 3, versions: [ { ...existing.versions[0], }, { id: "ver_new", versionNumber: 3, label: "Revision 3", status: EstimateVersionStatus.WORKING, lockedAt: null, notes: "Revision created from v2", projectSnapshot: { shortCode: "PRJ" }, assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], }, ], }; const findUnique = vi.fn().mockResolvedValueOnce(existing).mockResolvedValueOnce(refreshed); const createVersion = vi.fn().mockResolvedValue({ id: "ver_new" }); const createAssumptions = vi.fn().mockResolvedValue({ count: 1 }); const createScopeItem = vi.fn().mockResolvedValue({ id: "scope_new" }); const createDemandLines = vi.fn().mockResolvedValue({ count: 1 }); const createSnapshots = vi.fn().mockResolvedValue({ count: 1 }); const createMetrics = vi.fn().mockResolvedValue({ count: 1 }); const updateEstimate = vi.fn().mockResolvedValue({}); const db = { estimate: { findUnique, update: updateEstimate, }, estimateVersion: { create: createVersion, }, estimateAssumption: { createMany: createAssumptions, }, scopeItem: { create: createScopeItem, }, estimateDemandLine: { createMany: createDemandLines, }, resourceCostSnapshot: { createMany: createSnapshots, }, estimateMetric: { createMany: createMetrics, }, $transaction: vi.fn(async (callback) => callback({ estimateVersion: { create: createVersion, }, estimateAssumption: { createMany: createAssumptions, }, scopeItem: { create: createScopeItem, }, estimateDemandLine: { createMany: createDemandLines, }, resourceCostSnapshot: { createMany: createSnapshots, }, estimateMetric: { createMany: createMetrics, }, estimate: { update: updateEstimate, }, }), ), }; const result = await createEstimateRevision(db as never, { estimateId }); expect(result.latestVersionNumber).toBe(3); expect(createVersion).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ estimateId, versionNumber: 3, status: EstimateVersionStatus.WORKING, }), }), ); expect(createDemandLines).toHaveBeenCalledWith({ data: [ expect.objectContaining({ estimateVersionId: "ver_new", scopeItemId: "scope_new", name: "Comp", }), ], }); expect(updateEstimate).toHaveBeenCalledWith({ where: { id: estimateId }, data: { latestVersionNumber: 3, status: EstimateStatus.DRAFT, }, }); }); it("creates an export artifact for the selected estimate version", async () => { const createdAt = new Date("2026-03-13T08:00:00.000Z"); const findUnique = vi .fn() .mockResolvedValueOnce({ id: "est_3", name: "CGI Estimate", baseCurrency: "EUR", createdAt, updatedAt: createdAt, status: EstimateStatus.APPROVED, latestVersionNumber: 1, project: { id: "project_1", shortCode: "CGI-001", name: "CGI Project", status: "ACTIVE", }, versions: [ { id: "ver_1", versionNumber: 1, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), createdAt, updatedAt: createdAt, assumptions: [], scopeItems: [], demandLines: [ { id: "line_1", scopeItemId: null, roleId: "role_1", resourceId: "resource_1", lineType: "LABOR", name: "Comp Artist", chapter: "Compositing", hours: 40, days: 5, fte: 1, rateSource: "resource", costRateCents: 5000, billRateCents: 8000, currency: "EUR", costTotalCents: 200000, priceTotalCents: 320000, monthlySpread: { "2026-03": 40 }, staffingAttributes: {}, metadata: {}, createdAt, updatedAt: createdAt, }, ], resourceSnapshots: [], metrics: [ { id: "metric_1", key: "total_hours", label: "Total Hours", metricGroup: "summary", valueDecimal: 40, valueCents: null, currency: null, metadata: {}, createdAt, updatedAt: createdAt, }, ], exports: [], projectSnapshot: { startDate: "2026-03-01T00:00:00.000Z", endDate: "2026-04-01T00:00:00.000Z", }, }, ], }) .mockResolvedValueOnce({ id: "est_3", name: "CGI Estimate", baseCurrency: "EUR", createdAt, updatedAt: createdAt, status: EstimateStatus.APPROVED, latestVersionNumber: 1, project: { id: "project_1", shortCode: "CGI-001", name: "CGI Project", status: "ACTIVE", }, versions: [ { id: "ver_1", versionNumber: 1, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), createdAt, updatedAt: createdAt, assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [ { id: "exp_1", format: EstimateExportFormat.JSON, fileName: "cgi-estimate-v1.json", payload: {}, }, ], projectSnapshot: {}, }, ], }); const createExport = vi.fn().mockResolvedValue({ id: "exp_1" }); const db = { estimate: { findUnique, }, estimateExport: { create: createExport, }, }; await createEstimateExport(db as never, { estimateId: "est_3", format: EstimateExportFormat.JSON, }); expect(createExport).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ estimateVersionId: "ver_1", format: EstimateExportFormat.JSON, fileName: "cgi-estimate-v1.json", payload: expect.objectContaining({ format: EstimateExportFormat.JSON, encoding: "utf8", mimeType: "application/json; charset=utf-8", content: expect.stringContaining('"estimateId": "est_3"'), summary: expect.objectContaining({ estimateId: "est_3", projectName: "CGI Project", totalHours: 40, demandLineCount: 1, }), }), }), }), ); }); it("approves the submitted estimate version and marks the estimate approved", async () => { const findUnique = vi .fn() .mockResolvedValueOnce({ id: "est_4", status: EstimateStatus.IN_REVIEW, latestVersionNumber: 2, versions: [ { id: "ver_submit", versionNumber: 2, status: EstimateVersionStatus.SUBMITTED, lockedAt: new Date("2026-03-13"), assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, { id: "ver_approved_old", versionNumber: 1, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-01"), assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, ], }) .mockResolvedValueOnce({ id: "est_4", status: EstimateStatus.APPROVED, latestVersionNumber: 2, versions: [], }); const updateMany = vi.fn().mockResolvedValue({ count: 1 }); const updateVersion = vi.fn().mockResolvedValue({}); const updateEstimate = vi.fn().mockResolvedValue({}); const db = { estimate: { findUnique, update: updateEstimate, }, estimateVersion: { updateMany, update: updateVersion, }, $transaction: vi.fn(async (callback) => callback({ estimateVersion: { updateMany, update: updateVersion, }, estimate: { update: updateEstimate, }, }), ), }; const result = await approveEstimateVersion(db as never, { estimateId: "est_4", }); expect(result.status).toBe(EstimateStatus.APPROVED); expect(updateMany).toHaveBeenCalledWith({ where: { id: { in: ["ver_approved_old"] } }, data: { status: EstimateVersionStatus.SUPERSEDED }, }); expect(updateVersion).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "ver_submit" }, data: expect.objectContaining({ status: EstimateVersionStatus.APPROVED, }), }), ); }); it("creates planning allocations from an approved estimate version", async () => { const estimate = { id: "est_plan", projectId: "project_1", status: EstimateStatus.APPROVED, latestVersionNumber: 2, versions: [ { id: "ver_approved", versionNumber: 2, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), assumptions: [], scopeItems: [], demandLines: [ { id: "line_resource", scopeItemId: null, roleId: "role_comp", resourceId: "resource_1", lineType: "LABOR", name: "Senior Comp", chapter: "Comp", hours: 80, days: 10, fte: 1, rateSource: "RESOURCE", costRateCents: 5000, billRateCents: 9000, currency: "EUR", costTotalCents: 400000, priceTotalCents: 720000, monthlySpread: {}, staffingAttributes: {}, metadata: {}, }, { id: "line_placeholder", scopeItemId: null, roleId: "role_fx", resourceId: null, lineType: "LABOR", name: "FX Artist", chapter: "FX", hours: 160, days: 20, fte: 2, rateSource: "ROLE", costRateCents: 4500, billRateCents: 8000, currency: "EUR", costTotalCents: 720000, priceTotalCents: 1280000, monthlySpread: {}, staffingAttributes: {}, metadata: {}, }, ], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, ], }; const project = { id: "project_1", shortCode: "PRJ", name: "Project One", status: "ACTIVE", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), orderType: "CHARGEABLE", allocationType: "EXT", winProbability: 100, budgetCents: 0, responsiblePerson: null, }; const estimateFindUnique = vi.fn().mockResolvedValue(estimate); const projectFindUnique = vi.fn().mockResolvedValue(project); const resourceFindMany = vi.fn().mockResolvedValue([ { id: "resource_1", availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, }, }, ]); const allocationFindMany = vi.fn().mockResolvedValue([]); const demandRequirementFindMany = vi.fn().mockResolvedValue([]); const demandRequirementCreate = vi .fn() .mockResolvedValueOnce({ id: "demand_resource", projectId: "project_1", startDate: project.startDate, endDate: project.endDate, hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", headcount: 1, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" }, }) .mockResolvedValueOnce({ id: "demand_placeholder", projectId: "project_1", startDate: project.startDate, endDate: project.endDate, hoursPerDay: 4, percentage: 50, role: "FX Artist", roleId: "role_fx", headcount: 2, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" }, }); const demandRequirementUpdate = vi.fn().mockResolvedValue({}); const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", lcrCents: 5000, availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, }, }); const assignmentFindMany = vi.fn().mockResolvedValue([]); const assignmentCreate = vi.fn().mockResolvedValue({ id: "assignment_1", projectId: "project_1", demandRequirementId: "demand_resource", resourceId: "resource_1", startDate: project.startDate, endDate: project.endDate, hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", dailyCostCents: 40000, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 }, project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" }, demandRequirement: { id: "demand_resource", projectId: "project_1", startDate: project.startDate, endDate: project.endDate, hoursPerDay: 8, percentage: 100, role: "Compositor", roleId: "role_comp", headcount: 1, status: AllocationStatus.PROPOSED, }, }); const assignmentUpdate = vi.fn().mockResolvedValue({}); const auditLogCreate = vi.fn().mockResolvedValue({}); const vacationFindMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findUnique: estimateFindUnique, }, project: { findUnique: projectFindUnique, }, allocation: { findMany: allocationFindMany, }, demandRequirement: { findMany: demandRequirementFindMany, }, assignment: { findMany: assignmentFindMany, }, resource: { findMany: resourceFindMany, }, $transaction: vi.fn(async (callback) => callback({ project: { findUnique: projectFindUnique, }, resource: { findUnique: resourceFindUnique, }, allocation: { findMany: allocationFindMany, }, demandRequirement: { create: demandRequirementCreate, findUnique: vi.fn().mockResolvedValue({ id: "demand_resource", projectId: "project_1", }), update: demandRequirementUpdate, }, assignment: { findMany: assignmentFindMany, create: assignmentCreate, update: assignmentUpdate, }, auditLog: { create: auditLogCreate, }, vacation: { findMany: vacationFindMany, }, }), ), }; const result = await createEstimatePlanningHandoff(db as never, { estimateId: "est_plan", }); expect(result).toMatchObject({ estimateId: "est_plan", estimateVersionId: "ver_approved", projectId: "project_1", createdCount: 2, assignedCount: 1, placeholderCount: 1, fallbackPlaceholderCount: 0, }); expect(demandRequirementCreate).toHaveBeenNthCalledWith( 1, expect.objectContaining({ data: expect.objectContaining({ roleId: "role_comp", headcount: 1, metadata: expect.objectContaining({ estimateHandoff: expect.objectContaining({ estimateDemandLineId: "line_resource", handoffMode: "resource", }), }), }), }), ); expect(demandRequirementCreate).toHaveBeenNthCalledWith( 2, expect.objectContaining({ data: expect.objectContaining({ roleId: "role_fx", headcount: 2, metadata: expect.objectContaining({ estimateHandoff: expect.objectContaining({ estimateDemandLineId: "line_placeholder", handoffMode: "placeholder", }), }), }), }), ); expect(assignmentCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ demandRequirementId: "demand_resource", resourceId: "resource_1", roleId: "role_comp", metadata: expect.objectContaining({ estimateHandoff: expect.objectContaining({ estimateDemandLineId: "line_resource", handoffMode: "resource", }), }), }), }), ); expect(demandRequirementUpdate).not.toHaveBeenCalled(); expect(assignmentUpdate).not.toHaveBeenCalled(); }); it("blocks duplicate planning handoff for the same approved version", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_plan_dup", projectId: "project_dup", status: EstimateStatus.APPROVED, latestVersionNumber: 1, versions: [ { id: "ver_dup", versionNumber: 1, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, ], }); const projectFindUnique = vi.fn().mockResolvedValue({ id: "project_dup", shortCode: "PRJ", name: "Project Dup", status: "ACTIVE", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), orderType: "CHARGEABLE", allocationType: "EXT", winProbability: 100, budgetCents: 0, responsiblePerson: null, }); const demandRequirementFindMany = vi.fn().mockResolvedValue([ { id: "demand_existing", projectId: "project_dup", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), hoursPerDay: 8, percentage: 100, role: "Developer", roleId: null, headcount: 1, status: "ACTIVE", metadata: { estimateHandoff: { estimateVersionId: "ver_dup" } }, createdAt: new Date(), updatedAt: new Date(), }, ]); const assignmentFindMany = vi.fn().mockResolvedValue([]); const db = { estimate: { findUnique: estimateFindUnique, }, project: { findUnique: projectFindUnique, }, demandRequirement: { findMany: demandRequirementFindMany, }, assignment: { findMany: assignmentFindMany, }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; await expect( createEstimatePlanningHandoff(db as never, { estimateId: "est_plan_dup" }), ).rejects.toThrow("Planning handoff already exists for this approved version"); }); it("blocks duplicate planning handoff when only an explicit assignment row remains", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_plan_assignment_dup", projectId: "project_dup", status: EstimateStatus.APPROVED, latestVersionNumber: 1, versions: [ { id: "ver_dup_assignment", versionNumber: 1, status: EstimateVersionStatus.APPROVED, lockedAt: new Date("2026-03-13"), assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], projectSnapshot: {}, }, ], }); const projectFindUnique = vi.fn().mockResolvedValue({ id: "project_dup", shortCode: "PRJ", name: "Project Dup", status: "ACTIVE", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), orderType: "CHARGEABLE", allocationType: "EXT", winProbability: 100, budgetCents: 0, responsiblePerson: null, }); const db = { estimate: { findUnique: estimateFindUnique, }, project: { findUnique: projectFindUnique, }, demandRequirement: { findMany: vi.fn().mockResolvedValue([]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { id: "assignment_existing", demandRequirementId: null, resourceId: "resource_1", projectId: "project_dup", startDate: new Date("2026-03-16"), endDate: new Date("2026-03-27"), hoursPerDay: 8, percentage: 100, role: "Comp", roleId: "role_comp", dailyCostCents: 32000, status: AllocationStatus.PROPOSED, metadata: { estimateHandoff: { estimateVersionId: "ver_dup_assignment", }, }, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, resource: { findMany: vi.fn().mockResolvedValue([]), }, }; await expect( createEstimatePlanningHandoff(db as never, { estimateId: "est_plan_assignment_dup", }), ).rejects.toThrow("Planning handoff already exists for this approved version"); }); });