Files
CapaKraken/packages/application/src/use-cases/dispo-import/commit-dispo-import-batch.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
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>
2026-03-27 13:18:09 +01:00

574 lines
22 KiB
TypeScript

import {
DISPO_REQUIRED_ROLE_SEEDS,
DISPO_UTILIZATION_CATEGORIES,
normalizeDispoRoleToken,
} from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import {
AllocationStatus,
ImportBatchStatus,
ProjectStatus,
StagedRecordStatus,
VacationStatus,
} from "@capakraken/db";
import {
buildBatchSummaryEntry,
buildFallbackAccentureEmail,
toJsonObject,
} from "./shared.js";
import { recomputeResourceValueScores } from "../resource/recompute-resource-value-scores.js";
import { classifyDispoProject } from "./tbd-projects.js";
import type { CommitDbClient, MergedStagedResource, TxClient } from "./commit-dispo-batch-types.js";
import { validateDispoBatch } from "./validate-dispo-batch.js";
import type { ReferenceDataMaps } from "./build-dispo-maps.js";
import {
buildReferenceDataMaps,
inferRoleNameFromResource,
mergeStagedResources,
parseWeekdayAvailability,
} from "./build-dispo-maps.js";
import {
aggregateAssignments,
deriveOverlayAvailability,
} from "./determine-placement.js";
export interface CommitDispoImportBatchInput {
allowTbdUnresolved?: boolean;
importTbdProjects?: boolean;
importBatchId: string;
}
export interface CommitDispoImportBatchResult {
batchId: string;
counts: {
committedAssignments: number;
committedProjects: number;
committedResources: number;
committedVacations: number;
updatedEntitlements: number;
updatedResourceAvailabilities: number;
upsertedResourceRoles: number;
};
unresolved: {
blocked: number;
skippedTbd: number;
};
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizeDate(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function roundToOneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
async function upsertRoleSeeds(tx: TxClient) {
for (const role of DISPO_REQUIRED_ROLE_SEEDS) {
await tx.role.upsert({
where: { name: role.name },
update: { description: role.description, isActive: true },
create: { ...role, isActive: true },
});
}
}
async function upsertUtilizationCategories(tx: TxClient) {
for (const category of DISPO_UTILIZATION_CATEGORIES) {
await tx.utilizationCategory.upsert({
where: { code: category.code },
update: {
description: category.description,
isActive: true,
isDefault: category.isDefault,
name: category.name,
sortOrder: category.sortOrder,
},
create: category,
});
}
}
async function ensureInferredRolesExist(
tx: TxClient,
mergedResources: Map<string, MergedStagedResource>,
roleIdByName: Map<string, string>,
) {
const inferredRoleNames = new Set<string>();
for (const resource of mergedResources.values()) {
const name = inferRoleNameFromResource(resource);
if (name) inferredRoleNames.add(name);
}
for (const roleName of inferredRoleNames) {
if (roleIdByName.has(roleName)) continue;
const role = await tx.role.upsert({
where: { name: roleName },
update: { description: "Imported Dispo resource role", isActive: true },
create: { name: roleName, description: "Imported Dispo resource role", isActive: true },
select: { id: true, name: true },
});
roleIdByName.set(role.name, role.id);
}
}
function buildResourceData(
resource: MergedStagedResource,
maps: ReferenceDataMaps,
batchId: string,
) {
const managementGroup = resource.managementLevelGroupName
? maps.managementLevelGroupByName.get(resource.managementLevelGroupName)
: null;
const defaultChargeabilityTarget = managementGroup
? roundToOneDecimal(managementGroup.targetPercentage * 100)
: 80;
const availability = parseWeekdayAvailability(resource.availability, resource.fte);
const rawPayload = resource.rawPayload;
const orgUnitId =
(typeof rawPayload.sapOrgUnitLevelSeven === "string"
? maps.orgUnitIdByLevelAndName.get(`7:${rawPayload.sapOrgUnitLevelSeven.toLowerCase()}`)
: null) ??
(typeof rawPayload.sapOrgUnitLevelSix === "string"
? maps.orgUnitIdByLevelAndName.get(`6:${rawPayload.sapOrgUnitLevelSix.toLowerCase()}`)
: null) ??
(typeof rawPayload.sapOrgUnitLevelFive === "string"
? maps.orgUnitIdByLevelAndName.get(`5:${rawPayload.sapOrgUnitLevelFive.toLowerCase()}`)
: null) ??
null;
const shared = {
availability: availability as unknown as Prisma.InputJsonValue,
chapter: resource.chapter,
chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
clientUnitId: resource.clientUnitName
? (maps.clientIdByCode.get(resource.clientUnitName) ?? maps.clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
: null,
countryId: resource.countryCode ? (maps.countryIdByCode.get(resource.countryCode) ?? null) : null,
displayName: resource.displayName ?? resource.canonicalExternalId,
email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
fte: resource.fte ?? 1,
lcrCents: resource.lcrCents ?? 0,
managementLevelGroupId: resource.managementLevelGroupName ? (managementGroup?.id ?? null) : null,
managementLevelId: resource.managementLevelName
? (maps.managementLevelIdByName.get(resource.managementLevelName) ?? null)
: null,
metroCityId: resource.metroCityName
? (maps.metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
: null,
orgUnitId,
resourceType: resource.resourceType ?? "EMPLOYEE",
ucrCents: resource.ucrCents ?? 0,
} as const;
const dynamicFields = {
dispoImport: {
importBatchId: batchId,
sourceKinds: resource.sourceKinds,
warnings: resource.warnings,
},
} as Prisma.InputJsonValue;
return { shared, dynamicFields, managementGroup };
}
interface CommitResourcesResult {
resourceIdByKey: Map<string, string>;
resourceRoleNameByKey: Map<string, string>;
updatedEntitlements: number;
updatedResourceAvailabilities: number;
upsertedResourceRoles: number;
}
async function commitResources(
tx: TxClient,
mergedResources: Map<string, MergedStagedResource>,
maps: ReferenceDataMaps,
batchId: string,
stagedVacations: Awaited<ReturnType<TxClient["stagedVacation"]["findMany"]>>,
stagedAvailabilityRules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
): Promise<CommitResourcesResult> {
const resourceIdByKey = new Map<string, string>();
const resourceRoleNameByKey = new Map<string, string>();
let upsertedResourceRoles = 0;
let updatedResourceAvailabilities = 0;
let updatedEntitlements = 0;
for (const resource of mergedResources.values()) {
const inferredRoleName = inferRoleNameFromResource(resource);
if (inferredRoleName) {
resourceRoleNameByKey.set(resource.canonicalExternalId, inferredRoleName);
}
const { shared, dynamicFields } = buildResourceData(resource, maps, batchId);
const committed = await tx.resource.upsert({
where: { eid: resource.canonicalExternalId },
update: { ...shared, enterpriseId: resource.canonicalExternalId, dynamicFields },
create: {
...shared,
dynamicFields,
eid: resource.canonicalExternalId,
enterpriseId: resource.canonicalExternalId,
},
select: { id: true, availability: true },
});
resourceIdByKey.set(resource.canonicalExternalId, committed.id);
// Upsert resource roles
const resourceRoleNames = new Set(
Array.from(resource.roleTokens)
.map((roleToken) => normalizeDispoRoleToken(roleToken))
.filter((roleName): roleName is string => Boolean(roleName)),
);
if (inferredRoleName) resourceRoleNames.add(inferredRoleName);
for (const roleName of resourceRoleNames) {
const roleId = maps.roleIdByName.get(roleName);
if (!roleId) continue;
await tx.resourceRole.upsert({
where: { resourceId_roleId: { resourceId: committed.id, roleId } },
update: {},
create: { resourceId: committed.id, roleId },
});
upsertedResourceRoles += 1;
}
// Upsert vacation entitlements
if (resource.vacationDaysPerYear !== null && resource.vacationDaysPerYear !== undefined) {
const entitlementYears = new Set<number>(
stagedVacations
.filter((v) => v.resourceExternalId === resource.canonicalExternalId)
.map((v) => v.startDate.getUTCFullYear()),
);
if (entitlementYears.size === 0) entitlementYears.add(new Date().getUTCFullYear());
for (const year of entitlementYears) {
await tx.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: committed.id, year } },
update: { entitledDays: resource.vacationDaysPerYear },
create: { resourceId: committed.id, year, entitledDays: resource.vacationDaysPerYear },
});
updatedEntitlements += 1;
}
}
// Apply availability overlay rules
const resourceRules = stagedAvailabilityRules.filter(
(rule) => rule.resourceExternalId === resource.canonicalExternalId,
);
if (resourceRules.length > 0) {
const overlaidAvailability = deriveOverlayAvailability(
parseWeekdayAvailability(committed.availability, resource.fte),
resourceRules,
);
await tx.resource.update({
where: { id: committed.id },
data: {
availability: overlaidAvailability as unknown as Prisma.InputJsonValue,
dynamicFields: {
dispoImport: {
availabilityRules: resourceRules.map((rule) => ({
availableHours: rule.availableHours,
effectiveEndDate: rule.effectiveEndDate?.toISOString().slice(0, 10) ?? null,
effectiveStartDate: rule.effectiveStartDate?.toISOString().slice(0, 10) ?? null,
percentage: rule.percentage,
ruleType: rule.ruleType,
})),
importBatchId: batchId,
sourceKinds: resource.sourceKinds,
warnings: resource.warnings,
},
} as Prisma.InputJsonValue,
},
});
updatedResourceAvailabilities += 1;
}
}
return { resourceIdByKey, resourceRoleNameByKey, updatedEntitlements, updatedResourceAvailabilities, upsertedResourceRoles };
}
async function commitProjects(
tx: TxClient,
stagedProjects: Awaited<ReturnType<TxClient["stagedProject"]["findMany"]>>,
maps: ReferenceDataMaps,
batchId: string,
importTbdProjects: boolean,
): Promise<Map<string, string>> {
const projectIdByShortCode = new Map<string, string>();
for (const stagedProject of stagedProjects) {
if (stagedProject.isTbd && !importTbdProjects) continue;
const shortCode = stagedProject.shortCode ?? stagedProject.projectKey;
const classification = classifyDispoProject(stagedProject.utilizationCategoryCode ?? null);
const projectStatus = stagedProject.isTbd ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE;
const dynamicFields = {
dispoImport: {
importBatchId: batchId,
isInternal: stagedProject.isInternal,
isTbd: stagedProject.isTbd,
projectKey: stagedProject.projectKey,
rawTokens: Array.isArray(asObject(stagedProject.rawPayload).rawTokens)
? asObject(stagedProject.rawPayload).rawTokens
: [],
warnings: stagedProject.warnings,
},
} as Prisma.InputJsonValue;
const project = await tx.project.upsert({
where: { shortCode },
update: {
allocationType: stagedProject.allocationType ?? classification.allocationType,
budgetCents: 0,
clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null,
dynamicFields,
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
name: stagedProject.name ?? shortCode,
orderType: stagedProject.orderType ?? classification.orderType,
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
status: projectStatus,
utilizationCategoryId: stagedProject.utilizationCategoryCode
? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
: null,
winProbability: stagedProject.winProbability ?? 100,
},
create: {
shortCode,
name: stagedProject.name ?? shortCode,
orderType: stagedProject.orderType ?? classification.orderType,
allocationType: stagedProject.allocationType ?? classification.allocationType,
budgetCents: 0,
startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
status: projectStatus,
winProbability: stagedProject.winProbability ?? 100,
utilizationCategoryId: stagedProject.utilizationCategoryCode
? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
: null,
clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null,
dynamicFields,
},
select: { id: true },
});
projectIdByShortCode.set(shortCode, project.id);
}
return projectIdByShortCode;
}
export async function commitDispoImportBatch(
db: CommitDbClient,
input: CommitDispoImportBatchInput,
): Promise<CommitDispoImportBatchResult> {
const validation = await validateDispoBatch(db, input);
const result = await db.$transaction(async (tx) => {
await tx.importBatch.update({
where: { id: validation.batchId },
data: { status: ImportBatchStatus.COMMITTING },
});
await upsertUtilizationCategories(tx);
await upsertRoleSeeds(tx);
const [
stagedResources, stagedProjects, stagedAssignments, stagedVacations,
stagedAvailabilityRules, adminUser, countries, metroCities,
managementLevelGroups, managementLevels, clients, orgUnits, roles,
utilizationCategories,
] = await Promise.all([
tx.stagedResource.findMany({ where: { importBatchId: validation.batchId } }),
tx.stagedProject.findMany({ where: { importBatchId: validation.batchId } }),
tx.stagedAssignment.findMany({ where: { importBatchId: validation.batchId } }),
tx.stagedVacation.findMany({ where: { importBatchId: validation.batchId } }),
tx.stagedAvailabilityRule.findMany({ where: { importBatchId: validation.batchId } }),
tx.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } }),
tx.country.findMany({ select: { id: true, code: true } }),
tx.metroCity.findMany({ select: { id: true, name: true } }),
tx.managementLevelGroup.findMany({ select: { id: true, name: true, targetPercentage: true } }),
tx.managementLevel.findMany({ select: { id: true, name: true } }),
tx.client.findMany({ select: { id: true, code: true, name: true } }),
tx.orgUnit.findMany({ select: { id: true, level: true, name: true } }),
tx.role.findMany({ select: { id: true, name: true } }),
tx.utilizationCategory.findMany({ select: { id: true, code: true } }),
]);
if (!adminUser) {
throw new Error("Cannot commit Dispo import without an ADMIN user in the database");
}
const maps = buildReferenceDataMaps({
clients, countries, managementLevelGroups, managementLevels,
metroCities, orgUnits, roles, utilizationCategories,
});
const mergedResources = mergeStagedResources(stagedResources);
await ensureInferredRolesExist(tx, mergedResources, maps.roleIdByName);
const resourceResult = await commitResources(
tx, mergedResources, maps, validation.batchId,
stagedVacations, stagedAvailabilityRules,
);
const projectIdByShortCode = await commitProjects(
tx, stagedProjects, maps, validation.batchId, input.importTbdProjects ?? false,
);
// Commit assignments
const aggregatedAssignments = aggregateAssignments(
stagedAssignments, resourceResult.resourceIdByKey, projectIdByShortCode,
maps.roleIdByName, resourceResult.resourceRoleNameByKey, input.importTbdProjects ?? false,
);
for (const assignment of aggregatedAssignments) {
const dailyCostCents = Math.round(
assignment.hoursPerDay * (mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0),
);
const metadata = {
dispoImport: {
importBatchId: validation.batchId,
sourceDates: assignment.sourceDates,
utilizationCategoryCode: assignment.utilizationCategoryCode,
},
} as Prisma.InputJsonValue;
await tx.assignment.upsert({
where: {
unique_assignment: {
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignment.startDate,
endDate: assignment.endDate,
},
},
update: {
dailyCostCents, metadata,
percentage: assignment.percentage,
role: assignment.roleName,
roleId: assignment.roleId,
status: AllocationStatus.PROPOSED,
},
create: {
dailyCostCents, metadata,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
role: assignment.roleName,
roleId: assignment.roleId,
startDate: assignment.startDate,
status: AllocationStatus.PROPOSED,
},
});
await tx.resourceRole.upsert({
where: { resourceId_roleId: { resourceId: assignment.resourceId, roleId: assignment.roleId } },
update: {},
create: { resourceId: assignment.resourceId, roleId: assignment.roleId },
});
resourceResult.upsertedResourceRoles += 1;
}
// Commit vacations
for (const stagedVacation of stagedVacations) {
const resourceId = resourceResult.resourceIdByKey.get(stagedVacation.resourceExternalId);
if (!resourceId) {
throw new Error(`Unable to resolve resource "${stagedVacation.resourceExternalId}" during vacation commit`);
}
const existing = await tx.vacation.findFirst({
where: {
endDate: stagedVacation.endDate, halfDayPart: stagedVacation.halfDayPart,
isHalfDay: stagedVacation.isHalfDay, note: stagedVacation.note, resourceId,
startDate: stagedVacation.startDate, status: VacationStatus.APPROVED,
type: stagedVacation.vacationType,
},
select: { id: true },
});
if (existing) {
await tx.vacation.update({
where: { id: existing.id },
data: { approvedAt: new Date(), approvedById: adminUser.id, note: stagedVacation.note, requestedById: adminUser.id },
});
} else {
await tx.vacation.create({
data: {
approvedAt: new Date(), approvedById: adminUser.id,
endDate: stagedVacation.endDate, halfDayPart: stagedVacation.halfDayPart,
isHalfDay: stagedVacation.isHalfDay, note: stagedVacation.note,
requestedById: adminUser.id, resourceId,
startDate: stagedVacation.startDate, status: VacationStatus.APPROVED,
type: stagedVacation.vacationType,
},
});
}
}
// Mark staged records as committed
await Promise.all([
tx.stagedResource.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
tx.stagedProject.updateMany({
where: input.importTbdProjects ? { importBatchId: validation.batchId } : { importBatchId: validation.batchId, isTbd: false },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedAssignment.updateMany({
where: input.importTbdProjects
? { importBatchId: validation.batchId, isUnassigned: false }
: { importBatchId: validation.batchId, isTbd: false, isUnassigned: false },
data: { status: StagedRecordStatus.COMMITTED },
}),
tx.stagedVacation.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
tx.stagedAvailabilityRule.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
]);
await tx.importBatch.update({
where: { id: validation.batchId },
data: {
committedAt: new Date(),
status: ImportBatchStatus.COMMITTED,
summary: buildBatchSummaryEntry({
...toJsonObject(validation.batchSummary),
commit: {
committedAssignments: aggregatedAssignments.length,
committedProjects: projectIdByShortCode.size,
committedResources: mergedResources.size,
committedVacations: stagedVacations.length,
skippedTbdUnresolved: validation.skippedTbdUnresolved,
updatedEntitlements: resourceResult.updatedEntitlements,
updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities,
upsertedResourceRoles: resourceResult.upsertedResourceRoles,
},
}),
},
});
return {
batchId: validation.batchId,
counts: {
committedAssignments: aggregatedAssignments.length,
committedProjects: projectIdByShortCode.size,
committedResources: mergedResources.size,
committedVacations: stagedVacations.length,
updatedEntitlements: resourceResult.updatedEntitlements,
updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities,
upsertedResourceRoles: resourceResult.upsertedResourceRoles,
},
unresolved: { blocked: 0, skippedTbd: validation.skippedTbdUnresolved },
} satisfies CommitDispoImportBatchResult;
}, { maxWait: 30_000, timeout: 600_000 });
if ("resource" in db && db.resource) {
await recomputeResourceValueScores(db);
}
return result;
}