cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
1111 lines
31 KiB
TypeScript
1111 lines
31 KiB
TypeScript
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");
|
|
});
|
|
});
|