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>
179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
import { normalizeCanonicalResourceIdentity } from "@capakraken/shared";
|
|
import { readWorksheetMatrix } from "./read-workbook.js";
|
|
import { normalizeNullableWorkbookValue, normalizeText } from "./shared.js";
|
|
|
|
const RESOURCE_ROSTER_MASTER_SHEET = "Dispo Namen";
|
|
|
|
const HEADERS = {
|
|
chapter: "Chapter",
|
|
employeeName: "Mitarbeiter (laut Dispo)",
|
|
experience: "Experience",
|
|
fte: "FTE",
|
|
lcr: "LCR (EUR)",
|
|
level: "Level",
|
|
location: "Location",
|
|
status: "Status",
|
|
typeOfWork: "Type of work",
|
|
ucr: "UCR (EUR)",
|
|
} as const;
|
|
|
|
export interface ParsedResourceRosterRate {
|
|
canonicalExternalId: string;
|
|
chapter: string | null;
|
|
experience: string | null;
|
|
fte: number | null;
|
|
lcrCents: number | null;
|
|
level: string | null;
|
|
location: string | null;
|
|
sourceRow: number;
|
|
status: string | null;
|
|
typeOfWork: string | null;
|
|
ucrCents: number | null;
|
|
warnings: string[];
|
|
}
|
|
|
|
export interface ParsedResourceRosterLevelAverage {
|
|
lcrCents: number | null;
|
|
level: string;
|
|
sampleCount: number;
|
|
ucrCents: number | null;
|
|
}
|
|
|
|
export interface ParsedResourceRosterMasterWorkbook {
|
|
levelAverages: Map<string, ParsedResourceRosterLevelAverage>;
|
|
rates: Map<string, ParsedResourceRosterRate>;
|
|
warnings: string[];
|
|
}
|
|
|
|
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
|
|
const headerMap = new Map<string, number>();
|
|
|
|
headerRow.forEach((value, index) => {
|
|
const normalized = normalizeText(value);
|
|
if (normalized) {
|
|
headerMap.set(normalized, index);
|
|
}
|
|
});
|
|
|
|
return headerMap;
|
|
}
|
|
|
|
function getCellValue(
|
|
row: ReadonlyArray<unknown>,
|
|
headerMap: Map<string, number>,
|
|
headerName: string,
|
|
): unknown {
|
|
const index = headerMap.get(headerName);
|
|
if (index === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return row[index] ?? null;
|
|
}
|
|
|
|
function parseOptionalNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
|
|
const normalized = normalizeNullableWorkbookValue(value);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(normalized.replace(",", "."));
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function toCents(value: number | null): number | null {
|
|
return value === null ? null : Math.round(value * 100);
|
|
}
|
|
|
|
export async function parseResourceRosterMasterWorkbook(
|
|
workbookPath: string,
|
|
): Promise<ParsedResourceRosterMasterWorkbook> {
|
|
const rows = await readWorksheetMatrix(workbookPath, RESOURCE_ROSTER_MASTER_SHEET);
|
|
const headerMap = buildHeaderMap(rows[0] ?? []);
|
|
const warnings: string[] = [];
|
|
const rates = new Map<string, ParsedResourceRosterRate>();
|
|
const levelBuckets = new Map<string, { lcr: number[]; ucr: number[] }>();
|
|
|
|
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
|
|
const row = rows[rowNumber - 1] ?? [];
|
|
const employeeName = normalizeNullableWorkbookValue(
|
|
getCellValue(row, headerMap, HEADERS.employeeName),
|
|
);
|
|
|
|
if (!employeeName) {
|
|
continue;
|
|
}
|
|
|
|
const canonicalExternalId = normalizeCanonicalResourceIdentity(employeeName);
|
|
const level = normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.level));
|
|
const ucrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.ucr));
|
|
const lcrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.lcr));
|
|
const recordWarnings: string[] = [];
|
|
|
|
if (rates.has(canonicalExternalId)) {
|
|
recordWarnings.push(`Duplicate rate row ${rowNumber} ignored for ${canonicalExternalId}`);
|
|
warnings.push(recordWarnings[0] ?? `Duplicate rate row ${rowNumber} ignored`);
|
|
continue;
|
|
}
|
|
|
|
if (ucrValue === null || lcrValue === null) {
|
|
recordWarnings.push(`Incomplete rate row for ${canonicalExternalId}`);
|
|
}
|
|
|
|
rates.set(canonicalExternalId, {
|
|
canonicalExternalId,
|
|
sourceRow: rowNumber,
|
|
ucrCents: toCents(ucrValue),
|
|
lcrCents: toCents(lcrValue),
|
|
fte: parseOptionalNumber(getCellValue(row, headerMap, HEADERS.fte)),
|
|
level,
|
|
typeOfWork: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.typeOfWork)),
|
|
chapter: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.chapter)),
|
|
location: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.location)),
|
|
status: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.status)),
|
|
experience: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.experience)),
|
|
warnings: recordWarnings,
|
|
});
|
|
|
|
if (level) {
|
|
const bucket = levelBuckets.get(level) ?? { lcr: [], ucr: [] };
|
|
if (lcrValue !== null) {
|
|
bucket.lcr.push(lcrValue);
|
|
}
|
|
if (ucrValue !== null) {
|
|
bucket.ucr.push(ucrValue);
|
|
}
|
|
levelBuckets.set(level, bucket);
|
|
}
|
|
}
|
|
|
|
const levelAverages = new Map<string, ParsedResourceRosterLevelAverage>();
|
|
for (const [level, bucket] of levelBuckets.entries()) {
|
|
const lcrAverage =
|
|
bucket.lcr.length > 0
|
|
? Math.round((bucket.lcr.reduce((sum, value) => sum + value, 0) / bucket.lcr.length) * 100)
|
|
: null;
|
|
const ucrAverage =
|
|
bucket.ucr.length > 0
|
|
? Math.round((bucket.ucr.reduce((sum, value) => sum + value, 0) / bucket.ucr.length) * 100)
|
|
: null;
|
|
|
|
levelAverages.set(level, {
|
|
level,
|
|
sampleCount: Math.max(bucket.lcr.length, bucket.ucr.length),
|
|
lcrCents: lcrAverage,
|
|
ucrCents: ucrAverage,
|
|
});
|
|
}
|
|
|
|
return {
|
|
rates,
|
|
levelAverages,
|
|
warnings,
|
|
};
|
|
}
|