Files
CapaKraken/packages/application/src/__tests__/commit-dispo-import-batch.test.ts
T
Hartmut 625a842d89 feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-14 23:29:07 +01:00

709 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { commitDispoImportBatch } from "../index.js";
import { deriveTbdDispoProjectIdentity } from "../use-cases/dispo-import/tbd-projects.js";
function createCommitDb(overrides: Record<string, unknown> = {}) {
const tx = {
importBatch: {
update: vi.fn().mockResolvedValue({}),
},
utilizationCategory: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([{ id: "util_chg", code: "Chg" }]),
},
role: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([{ id: "role_ad", name: "Art Director" }]),
},
stagedResource: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedProject: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedAssignment: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedVacation: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
stagedAvailabilityRule: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findFirst: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
country: {
findMany: vi.fn().mockResolvedValue([{ id: "country_de", code: "DE" }]),
},
metroCity: {
findMany: vi.fn().mockResolvedValue([]),
},
managementLevelGroup: {
findMany: vi.fn().mockResolvedValue([]),
},
managementLevel: {
findMany: vi.fn().mockResolvedValue([]),
},
client: {
findMany: vi.fn().mockResolvedValue([{ id: "client_bmw", code: "BMW", name: "BMW" }]),
},
orgUnit: {
findMany: vi.fn().mockResolvedValue([{ id: "org_ad", level: 7, name: "Art Direction" }]),
},
resource: {
upsert: vi.fn().mockResolvedValue({
id: "res_1",
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
}),
update: vi.fn().mockResolvedValue({}),
},
resourceRole: {
upsert: vi.fn().mockResolvedValue({}),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue({}),
},
project: {
upsert: vi.fn().mockResolvedValue({ id: "proj_1" }),
},
assignment: {
upsert: vi.fn().mockResolvedValue({}),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
...overrides,
};
const db = {
importBatch: {
findUnique: vi.fn().mockResolvedValue({ id: "batch_1", status: "STAGED", summary: {} }),
update: vi.fn().mockResolvedValue({}),
},
stagedUnresolvedRecord: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(async (callback: (client: typeof tx) => Promise<unknown>) => callback(tx)),
...overrides,
};
return { db, tx };
}
describe("commitDispoImportBatch", () => {
it("commits resolved staged data and keeps [tbd] unresolved rows staged", async () => {
const { db, tx } = createCommitDb();
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: 'Planning token "[tbd]" references [tbd] and requires project resolution',
resolutionHint: "Resolve [tbd] rows before final project creation",
normalizedData: { rawToken: "[tbd]" },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_charge",
sourceKind: "CHARGEABILITY",
canonicalExternalId: "ada.director",
displayName: "Ada Director",
email: null,
chapter: "Art Direction",
chargeabilityTarget: 77.5,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: null,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: null,
availability: null,
rawPayload: {},
warnings: [],
},
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "ada.director",
displayName: "Ada Director",
email: null,
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {
sapOrgUnitLevelSeven: "Art Direction",
vacationDaysPerYear: 30,
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "11035763",
projectKey: "11035763",
name: "BMW Launch",
clientCode: "BMW",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: false,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_1",
resourceExternalId: "ada.director",
projectKey: "11035763",
assignmentDate: new Date("2026-01-05T00:00:00.000Z"),
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-05T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
{
id: "sa_2",
resourceExternalId: "ada.director",
projectKey: "11035763",
assignmentDate: new Date("2026-01-06T00:00:00.000Z"),
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
]);
tx.stagedVacation.findMany.mockResolvedValue([
{
id: "sv_1",
resourceExternalId: "ada.director",
vacationType: "PUBLIC_HOLIDAY",
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-01T00:00:00.000Z"),
note: "New Year",
holidayName: "New Year",
isHalfDay: false,
halfDayPart: null,
},
]);
tx.stagedAvailabilityRule.findMany.mockResolvedValue([
{
id: "sar_1",
resourceExternalId: "ada.director",
effectiveStartDate: new Date("2026-01-05T00:00:00.000Z"),
effectiveEndDate: new Date("2026-01-05T00:00:00.000Z"),
availableHours: 4,
percentage: 50,
ruleType: "PART_TIME",
},
{
id: "sar_2",
resourceExternalId: "ada.director",
effectiveStartDate: new Date("2026-01-12T00:00:00.000Z"),
effectiveEndDate: new Date("2026-01-12T00:00:00.000Z"),
availableHours: 4,
percentage: 50,
ruleType: "PART_TIME",
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 1,
updatedEntitlements: 1,
updatedResourceAvailabilities: 1,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.resource.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
eid: "ada.director",
email: "ada.director@accenture.com",
enterpriseId: "ada.director",
lcrCents: 1000,
ucrCents: 1500,
}),
}),
);
expect(tx.resource.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
availability: expect.objectContaining({
monday: 4,
tuesday: 8,
}),
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
dailyCostCents: 4000,
endDate: new Date("2026-01-06T00:00:00.000Z"),
startDate: new Date("2026-01-05T00:00:00.000Z"),
}),
}),
);
expect(tx.vacationEntitlement.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
entitledDays: 30,
year: 2026,
}),
}),
);
expect(tx.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "COMMITTED",
}),
}),
);
});
it("commits [tbd] rows as provisional projects when explicitly enabled", async () => {
const { db, tx } = createCommitDb();
const rawToken = "[DAI] 590 C AMG GT Stills [tbd]{CH} HB_";
const tbdProject = deriveTbdDispoProjectIdentity(rawToken, "Chg");
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
normalizedData: { rawToken },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "h.noerenberg",
displayName: "Hartmut Norenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_tbd_1",
shortCode: tbdProject.shortCode,
projectKey: tbdProject.projectKey,
name: tbdProject.name,
clientCode: "DAI",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: true,
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
warnings: [],
rawPayload: {
rawTokens: [rawToken],
},
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_tbd_1",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-03T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-03T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
{
id: "sa_tbd_2",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-04T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
importTbdProjects: true,
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 0,
updatedEntitlements: 0,
updatedResourceAvailabilities: 0,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.project.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { shortCode: tbdProject.shortCode },
create: expect.objectContaining({
name: tbdProject.name,
shortCode: tbdProject.shortCode,
status: "DRAFT",
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
endDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
}),
}),
);
expect(tx.stagedProject.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { importBatchId: "batch_1" },
}),
);
});
it("blocks commit when non-[tbd] unresolved rows remain", async () => {
const { db } = createCommitDb();
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_blocker",
recordType: "ASSIGNMENT",
message: "Unable to resolve project key from planning token",
resolutionHint: "Add a WBS token",
normalizedData: { rawToken: "[BMW] Launch" },
status: "UNRESOLVED",
},
]);
await expect(
commitDispoImportBatch(db as never, { importBatchId: "batch_1" }),
).rejects.toThrow(/blocking unresolved staged record/);
expect(db.$transaction).not.toHaveBeenCalled();
});
it("falls back to the staged resource role when an assignment row has no role", async () => {
const { db, tx } = createCommitDb({
role: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([
{ id: "role_ad", name: "Art Director" },
{ id: "role_pm", name: "Project Manager" },
]),
},
});
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "catharina.voelkle",
displayName: "Catharina Voelkle",
email: null,
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {
department: "Digital Designer",
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "generic",
projectKey: "generic",
name: "Generic Work",
clientCode: "BMW",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: false,
startDate: new Date("2026-03-12T00:00:00.000Z"),
endDate: new Date("2026-03-12T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_missing_role",
resourceExternalId: "catharina.voelkle",
projectKey: "generic",
assignmentDate: new Date("2026-03-12T00:00:00.000Z"),
startDate: new Date("2026-03-12T00:00:00.000Z"),
endDate: new Date("2026-03-12T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
roleToken: null,
roleName: null,
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: false,
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result.counts.committedAssignments).toBe(1);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
role: "Art Director",
}),
}),
);
});
it("creates a role from the roster department when no canonical dispo role exists", async () => {
const { db, tx } = createCommitDb({
role: {
upsert: vi.fn(async ({ where }: { where: { name: string } }) => ({
id: `role_${where.name.toLowerCase().replace(/\s+/g, "_")}`,
name: where.name,
})),
findMany: vi.fn().mockResolvedValue([{ id: "role_ad", name: "Art Director" }]),
},
});
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "dennis.steinschulte",
displayName: "Dennis Steinschulte",
email: null,
chapter: "Product Data Management",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: [],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {
department: "Vis Logic",
},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_1",
shortCode: "INT-MO",
projectKey: "generic",
name: "Management & Operations",
clientCode: "BMW",
utilizationCategoryCode: "M&O",
allocationType: "INT",
orderType: "NONCHARGEABLE",
winProbability: 100,
isInternal: true,
isTbd: false,
startDate: new Date("2026-01-12T00:00:00.000Z"),
endDate: new Date("2026-01-12T00:00:00.000Z"),
warnings: [],
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_missing_role",
resourceExternalId: "dennis.steinschulte",
projectKey: "generic",
assignmentDate: new Date("2026-01-12T00:00:00.000Z"),
startDate: new Date("2026-01-12T00:00:00.000Z"),
endDate: new Date("2026-01-12T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
roleToken: null,
roleName: null,
utilizationCategoryCode: "M&O",
isInternal: true,
isUnassigned: false,
isTbd: false,
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
});
expect(result.counts.committedAssignments).toBe(1);
expect(tx.role.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { name: "Vis Logic" },
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
role: "Vis Logic",
}),
}),
);
});
});