Files
Nexus/packages/application/src/use-cases/dispo-import/shared.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

656 lines
17 KiB
TypeScript

import path from "node:path";
import type { Prisma, PrismaClient } from "@capakraken/db";
import {
DispoImportSourceKind,
DispoStagedRecordType,
ImportBatchStatus,
ResourceType,
StagedRecordStatus,
} from "@capakraken/db";
import {
createWeekdayAvailabilityFromFte,
normalizeCanonicalResourceIdentity,
normalizeDispoChapterToken,
} from "@capakraken/shared";
export type DispoImportDbClient = Pick<
PrismaClient,
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedClient"
| "stagedProject"
| "stagedResource"
| "stagedVacation"
| "stagedUnresolvedRecord"
>;
export interface DispoReferenceImportInput {
importBatchId?: string;
notes?: string | null;
referenceWorkbookPath: string;
}
export interface DispoChargeabilityImportInput {
chargeabilityWorkbookPath: string;
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
}
export interface DispoRosterImportInput {
costWorkbookPath?: string;
importBatchId?: string;
notes?: string | null;
rosterWorkbookPath: string;
}
export interface DispoPlanningImportInput {
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
planningWorkbookPath: string;
}
export interface ParsedCountryReference {
sourceRow: number;
countryCode: string;
name: string;
dailyWorkingHours: number;
metroCities: string[];
scheduleRules?: Prisma.InputJsonValue;
}
export interface ParsedOrgUnitReference {
sourceRow: number;
level: number;
name: string;
parentName: string | null;
sortOrder: number;
}
export interface ParsedManagementLevelGroupReference {
sourceRow: number;
name: string;
targetPercentage: number;
sortOrder: number;
levels: string[];
}
export interface ParsedClientReference {
sourceColumn: string;
sourceRow: number;
clientCode: string | null;
name: string;
parentClientCode: string | null;
parentName: string | null;
sortOrder: number;
}
export interface ParsedReferenceWorkbook {
clients: ParsedClientReference[];
countries: ParsedCountryReference[];
managementLevelGroups: ParsedManagementLevelGroupReference[];
orgUnits: ParsedOrgUnitReference[];
warnings: string[];
}
export interface ParsedChargeabilityResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
fte: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceType: ResourceType;
roleTokens: string[];
sourceRow: number;
warnings: string[];
}
export interface ParsedUnresolvedRecord {
message: string;
normalizedData: Record<string, unknown>;
projectKey?: string | null;
recordType: DispoStagedRecordType;
resolutionHint?: string | null;
resourceExternalId?: string | null;
sourceColumn?: string | null;
sourceRow: number;
warnings: string[];
}
export interface ParsedChargeabilityWorkbook {
resources: ParsedChargeabilityResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedRosterResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
clientUnitName: string | null;
countryCode: string | null;
dailyWorkingHoursPerFte: number | null;
department: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
firstDayInDispo: Date | null;
fte: number | null;
lastDayInDispo: Date | null;
lcrCents: number | null;
mainSkillset: string | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceHoursPerWeek: number | null;
resourceType: ResourceType;
rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING";
rateResolutionLevel: string | null;
roleTokens: string[];
sapEmployeeName: string | null;
sapOrgUnitLevelFive: string | null;
sapOrgUnitLevelSix: string | null;
sapOrgUnitLevelSeven: string | null;
sourceRow: number;
sourceSheet: string;
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
export interface ParsedRosterWorkbook {
excludedCanonicalExternalIds: string[];
ignoredPseudoDemandRows: number;
resources: ParsedRosterResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedPlanningAssignment {
assignmentDate: Date;
chapterToken: string | null;
hoursPerDay: number;
isInternal: boolean;
isTbd: boolean;
isUnassigned: boolean;
percentage: number;
projectKey: string | null;
rawToken: string;
resourceExternalId: string;
roleName: string | null;
roleToken: string | null;
slotFraction: number;
sourceColumn: string;
sourceRow: number;
utilizationCategoryCode: string | null;
warnings: string[];
winProbability: number | null;
}
export interface ParsedPlanningVacation {
endDate: Date;
halfDayPart: string | null;
holidayName: string | null;
isHalfDay: boolean;
isPublicHoliday: boolean;
note: string | null;
rawToken: string;
resourceExternalId: string;
sourceColumn: string;
sourceRow: number;
startDate: Date;
vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK";
warnings: string[];
}
export interface ParsedPlanningAvailabilityRule {
availableHours: number | null;
effectiveEndDate: Date | null;
effectiveStartDate: Date | null;
isResolved: boolean;
percentage: number | null;
rawToken: string;
resourceExternalId: string;
ruleType: string;
sourceColumn: string;
sourceRow: number;
warnings: string[];
}
export interface ParsedPlanningWorkbook {
assignments: ParsedPlanningAssignment[];
availabilityRules: ParsedPlanningAvailabilityRule[];
unresolved: ParsedUnresolvedRecord[];
vacations: ParsedPlanningVacation[];
warnings: string[];
}
const COUNTRY_REFERENCE_CONFIG = {
"Costa Rica": {
code: "CR",
dailyWorkingHours: 8,
},
Germany: {
code: "DE",
dailyWorkingHours: 8,
},
Hungary: {
code: "HU",
dailyWorkingHours: 8,
},
India: {
code: "IN",
dailyWorkingHours: 9,
},
Italy: {
code: "IT",
dailyWorkingHours: 8,
},
Portugal: {
code: "PT",
dailyWorkingHours: 8,
},
Spain: {
code: "ES",
dailyWorkingHours: 8,
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
},
"United Kingdom": {
code: "GB",
dailyWorkingHours: 8,
},
} as const satisfies Record<
string,
{
code: string;
dailyWorkingHours: number;
scheduleRules?: Prisma.InputJsonValue;
}
>;
const CLIENT_CODE_OVERRIDES = {
BMW: "BMW",
DAIMLER: "DAIMLER",
"EXOR-STELLANTIS": "STELLANTIS",
VOLKSWAGEN: "VW",
"TATA MOTORS GROUP": "JLR",
} as const satisfies Record<string, string>;
const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]);
function collapseWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function normalizeText(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = collapseWhitespace(String(value));
return normalized.length > 0 ? normalized : null;
}
export function normalizeNullableWorkbookValue(value: unknown): string | null {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
return NULLISH_TOKENS.has(normalized) ? null : normalized;
}
export function buildFallbackAccentureEmail(canonicalExternalId: string): string {
return `${canonicalExternalId}@accenture.com`;
}
export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean {
return typeof value === "string" && value.toLowerCase().startsWith("demand_");
}
export function sanitizeClientName(value: string): string {
return collapseWhitespace(value.replace(/\s*-\s*$/, ""));
}
export function getWorkbookFileName(workbookPath: string): string {
return path.basename(workbookPath);
}
export function findSectionRow(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
firstCellValue: string,
): number {
const normalizedTarget = firstCellValue.toLowerCase();
for (let index = 0; index < rows.length; index += 1) {
const current = normalizeText(rows[index]?.[0]);
if (current?.toLowerCase() === normalizedTarget) {
return index + 1;
}
}
throw new Error(`Section row "${firstCellValue}" not found`);
}
export function getCountryReferenceConfig(countryName: string) {
return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null;
}
export function deriveCountryCodeFromMetroCity(
metroCityName: string | null | undefined,
): string | null {
if (!metroCityName) {
return null;
}
for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) {
if (metroCityName === countryName) {
return config.code;
}
const isGermanCity =
countryName === "Germany" &&
["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName);
const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon";
const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham";
const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica";
if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) {
return config.code;
}
}
return null;
}
export function normalizeClientCode(masterClientName: string): string | null {
return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null;
}
export function ensurePercentageValue(value: number | null): number | null {
if (value === null) {
return null;
}
return value <= 1 ? Math.round(value * 10000) / 100 : value;
}
export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string {
return enterpriseId
.split(".")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function deriveRoleTokens(...values: Array<string | null | undefined>): string[] {
const tokenSet = new Set<string>();
const combinedValue = values
.map((value) => normalizeText(value))
.filter((value): value is string => Boolean(value))
.join(" ")
.toUpperCase();
if (
combinedValue.includes("2D") ||
combinedValue.includes("NUKE") ||
combinedValue.includes("PHOTOSHOP") ||
combinedValue.includes("RETOUCH") ||
combinedValue.includes("ARTWORK") ||
combinedValue.includes("MOTION DESIGN")
) {
tokenSet.add("2D");
}
if (combinedValue.includes("3D") || combinedValue.includes("MODELING")) {
tokenSet.add("3D");
}
if (
combinedValue.includes("PROGRAM/DELIVERY MGMT") ||
combinedValue.includes("PROJECT MANAGEMENT") ||
combinedValue.includes("PRODUCER") ||
combinedValue.includes("HEAD")
) {
tokenSet.add("PM");
}
if (
combinedValue.includes("ART DIRECTION") ||
combinedValue.includes("ART DIRECTOR") ||
combinedValue.includes("DIGITAL DESIGNER")
) {
tokenSet.add("AD");
}
return Array.from(tokenSet);
}
export function deriveNormalizedChapter(
rawChapter: string | null,
roleTokens: string[],
): { chapter: string | null; chapterCode: string | null } {
const firstRoleToken = roleTokens[0] ?? null;
if (firstRoleToken) {
const normalizedChapter = normalizeDispoChapterToken(firstRoleToken);
if (normalizedChapter) {
return {
chapter: normalizedChapter,
chapterCode: firstRoleToken,
};
}
}
return {
chapter: rawChapter,
chapterCode: null,
};
}
export function mapChargeabilityResourceType(rawValue: string | null): {
resourceType: ResourceType;
warning: string | null;
} {
if (!rawValue) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
const normalizedValue = rawValue.toLowerCase();
if (normalizedValue.includes("freelancer")) {
return { resourceType: ResourceType.FREELANCER, warning: null };
}
if (normalizedValue.includes("intern")) {
return { resourceType: ResourceType.INTERN, warning: null };
}
if (normalizedValue.includes("student")) {
return { resourceType: ResourceType.STUDENT, warning: null };
}
if (normalizedValue.includes("apprentice")) {
return { resourceType: ResourceType.APPRENTICE, warning: null };
}
if (
normalizedValue === "production studios" ||
normalizedValue === "near&offshore" ||
normalizedValue === "accenture" ||
normalizedValue === "long-term absence"
) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
return {
resourceType: ResourceType.EMPLOYEE,
warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`,
};
}
export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue {
return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue;
}
export function buildBatchSummaryEntry(summary: Record<string, unknown>): Prisma.InputJsonValue {
return summary as Prisma.InputJsonValue;
}
export async function ensureImportBatch(
db: Pick<PrismaClient, "importBatch">,
input: {
chargeabilitySourceFile?: string;
importBatchId?: string;
notes?: string | null;
planningSourceFile?: string;
referenceSourceFile?: string;
},
): Promise<{ id: string; summary: Record<string, unknown> }> {
if (input.importBatchId) {
const existing = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, summary: true },
});
if (!existing) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
const updated = await db.importBatch.update({
where: { id: input.importBatchId },
data: {
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: updated.id,
summary: toJsonObject(updated.summary),
};
}
const created = await db.importBatch.create({
data: {
sourceSystem: "DISPO_V2",
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: created.id,
summary: toJsonObject(created.summary),
};
}
export async function finalizeImportBatchStage(
db: Pick<PrismaClient, "importBatch">,
input: {
batchId: string;
existingSummary: Record<string, unknown>;
key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster";
summary: Record<string, unknown>;
},
) {
const nextSummary = {
...input.existingSummary,
[input.key]: input.summary,
};
await db.importBatch.update({
where: { id: input.batchId },
data: {
status: ImportBatchStatus.STAGED,
stagedAt: new Date(),
summary: buildBatchSummaryEntry(nextSummary),
},
});
}
export function toJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null {
return value ? normalizeCanonicalResourceIdentity(value) : null;
}
export function createSourceTrace(
sourceKind: DispoImportSourceKind,
sourceWorkbook: string,
sourceSheet: string,
sourceRow: number,
sourceColumn?: string | null,
) {
return {
sourceKind,
sourceWorkbook,
sourceSheet,
sourceRow,
...(sourceColumn !== undefined ? { sourceColumn } : {}),
};
}
export const DISPO_REFERENCE_SHEET = "EID-Attr";
export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr";
export const DISPO_CHARGEABILITY_SHEET = "ChgFC";
export const DISPO_PLANNING_SHEET = "Dispo";
export const DISPO_ROSTER_SHEET = "DispoRoster";
export const DISPO_ROSTER_SAP_SHEET = "SAP_data";
export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };