chore(repo): initialize planarchy workspace
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
import { normalizeCanonicalResourceIdentity } from "@planarchy/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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user