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 = {}) { 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) => 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", }), }), ); }); });