Files
Nexus/packages/engine/src/estimate/export-serializer.ts
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

673 lines
20 KiB
TypeScript

import {
EstimateExportFormat,
type EstimateExportArtifactPayload,
type EstimateExportSummary,
type EstimateStatus,
type EstimateVersionStatus,
} from "@nexus/shared";
import { summarizeEstimateDemandLines } from "./metrics.js";
type ExcelJsModule = typeof import("exceljs");
type ExportProjectRef = {
id: string;
name: string;
shortCode?: string | null;
status?: string | null;
startDate?: Date | string | null;
endDate?: Date | string | null;
} | null;
type ExportAssumption = {
id: string;
category: string;
key: string;
label: string;
valueType: string;
value: unknown;
sortOrder: number;
notes?: string | null;
createdAt: Date;
updatedAt: Date;
};
type ExportScopeItem = {
id: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
name: string;
description?: string | null;
scene?: string | null;
page?: string | null;
location?: string | null;
assumptionCategory?: string | null;
technicalSpec: unknown;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
internalComments?: string | null;
externalComments?: string | null;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportDemandLine = {
id: string;
scopeItemId?: string | null;
roleId?: string | null;
resourceId?: string | null;
lineType: string;
name: string;
chapter?: string | null;
hours: number;
days?: number | null;
fte?: number | null;
rateSource?: string | null;
costRateCents: number;
billRateCents: number;
currency: string;
costTotalCents: number;
priceTotalCents: number;
monthlySpread: unknown;
staffingAttributes: unknown;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportResourceSnapshot = {
id: string;
resourceId?: string | null;
sourceEid?: string | null;
displayName: string;
chapter?: string | null;
roleId?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
fte?: number | null;
location?: string | null;
country?: string | null;
level?: string | null;
workType?: string | null;
attributes: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportMetric = {
id: string;
key: string;
label: string;
metricGroup?: string | null;
valueDecimal: number;
valueCents?: number | null;
currency?: string | null;
metadata: unknown;
createdAt: Date;
updatedAt: Date;
};
type ExportSheetRow = Record<string, unknown>;
let _excelJs: ExcelJsModule | null = null;
async function getExcelJS() {
if (!_excelJs) {
_excelJs = await import("exceljs");
}
return _excelJs;
}
export interface EstimateExportSource {
estimate: {
id: string;
projectId?: string | null;
name: string;
opportunityId?: string | null;
baseCurrency: string;
status: EstimateStatus | string;
createdAt: Date;
updatedAt: Date;
};
version: {
id: string;
versionNumber: number;
label?: string | null;
status: EstimateVersionStatus | string;
notes?: string | null;
lockedAt?: Date | null;
projectSnapshot: unknown;
createdAt: Date;
updatedAt: Date;
assumptions: ExportAssumption[];
scopeItems: ExportScopeItem[];
demandLines: ExportDemandLine[];
resourceSnapshots: ExportResourceSnapshot[];
metrics: ExportMetric[];
};
project: ExportProjectRef;
}
function serializeDate(value: Date | string | null | undefined) {
if (value instanceof Date) {
return value.toISOString();
}
return value ?? null;
}
function stringifyValue(value: unknown) {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
function toNumericRecord(value: unknown) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {} as Record<string, number>;
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
function escapeDelimitedValue(value: unknown, delimiter: string) {
const rendered = stringifyValue(value);
if (
rendered.includes(delimiter) ||
rendered.includes('"') ||
rendered.includes("\n") ||
rendered.includes("\r")
) {
return `"${rendered.replaceAll('"', '""')}"`;
}
return rendered;
}
function serializeDelimited(rows: Array<Record<string, unknown>>, delimiter: string) {
if (rows.length === 0) {
return "";
}
const columns = Array.from(
rows.reduce((keys, row) => {
for (const key of Object.keys(row)) {
keys.add(key);
}
return keys;
}, new Set<string>()),
);
const header = columns.map((column) => escapeDelimitedValue(column, delimiter));
const body = rows.map((row) =>
columns.map((column) => escapeDelimitedValue(row[column], delimiter)).join(delimiter),
);
return [header.join(delimiter), ...body].join("\n");
}
function buildSummary(source: EstimateExportSource): EstimateExportSummary {
const summarized = summarizeEstimateDemandLines(source.version.demandLines);
const metricsByKey = new Map(source.version.metrics.map((metric) => [metric.key, metric]));
const totalHours = metricsByKey.get("total_hours")?.valueDecimal ?? summarized.totalHours;
const totalCostCents = metricsByKey.get("total_cost")?.valueCents ?? summarized.totalCostCents;
const totalPriceCents = metricsByKey.get("total_price")?.valueCents ?? summarized.totalPriceCents;
const marginCents = metricsByKey.get("margin")?.valueCents ?? summarized.marginCents;
const marginPercent =
metricsByKey.get("margin_percent")?.valueDecimal ?? summarized.marginPercent;
return {
estimateId: source.estimate.id,
estimateName: source.estimate.name,
versionId: source.version.id,
versionNumber: source.version.versionNumber,
versionStatus: source.version.status as EstimateVersionStatus,
projectId: source.estimate.projectId ?? source.project?.id ?? null,
projectName: source.project?.name ?? null,
baseCurrency: source.estimate.baseCurrency,
assumptionCount: source.version.assumptions.length,
scopeItemCount: source.version.scopeItems.length,
demandLineCount: source.version.demandLines.length,
resourceSnapshotCount: source.version.resourceSnapshots.length,
totalHours,
totalCostCents,
totalPriceCents,
marginCents,
marginPercent,
};
}
function buildOverviewRows(source: EstimateExportSource, summary: EstimateExportSummary) {
return [
{ field: "estimate_id", value: summary.estimateId },
{ field: "estimate_name", value: summary.estimateName },
{ field: "estimate_status", value: source.estimate.status },
{ field: "version_id", value: summary.versionId },
{ field: "version_number", value: summary.versionNumber },
{ field: "version_status", value: summary.versionStatus },
{ field: "version_label", value: source.version.label ?? "" },
{ field: "version_notes", value: source.version.notes ?? "" },
{ field: "project_id", value: summary.projectId ?? "" },
{ field: "project_name", value: summary.projectName ?? "" },
{ field: "project_code", value: source.project?.shortCode ?? "" },
{ field: "base_currency", value: summary.baseCurrency },
{ field: "opportunity_id", value: source.estimate.opportunityId ?? "" },
{ field: "locked_at", value: serializeDate(source.version.lockedAt) ?? "" },
{
field: "generated_from_project_start",
value: serializeDate(source.project?.startDate) ?? "",
},
{ field: "generated_from_project_end", value: serializeDate(source.project?.endDate) ?? "" },
{ field: "assumption_count", value: summary.assumptionCount },
{ field: "scope_item_count", value: summary.scopeItemCount },
{ field: "demand_line_count", value: summary.demandLineCount },
{ field: "resource_snapshot_count", value: summary.resourceSnapshotCount },
{ field: "total_hours", value: summary.totalHours },
{ field: "total_cost_cents", value: summary.totalCostCents },
{ field: "total_price_cents", value: summary.totalPriceCents },
{ field: "margin_cents", value: summary.marginCents },
{ field: "margin_percent", value: summary.marginPercent },
];
}
function buildAssumptionRows(assumptions: ExportAssumption[]) {
return assumptions.map((assumption) => ({
id: assumption.id,
category: assumption.category,
key: assumption.key,
label: assumption.label,
value_type: assumption.valueType,
value: stringifyValue(assumption.value),
notes: assumption.notes ?? "",
sort_order: assumption.sortOrder,
}));
}
function buildScopeRows(scopeItems: ExportScopeItem[]) {
return scopeItems.map((scopeItem) => ({
id: scopeItem.id,
sequence_no: scopeItem.sequenceNo,
scope_type: scopeItem.scopeType,
package_code: scopeItem.packageCode ?? "",
name: scopeItem.name,
description: scopeItem.description ?? "",
scene: scopeItem.scene ?? "",
page: scopeItem.page ?? "",
location: scopeItem.location ?? "",
assumption_category: scopeItem.assumptionCategory ?? "",
frame_count: scopeItem.frameCount ?? "",
item_count: scopeItem.itemCount ?? "",
unit_mode: scopeItem.unitMode ?? "",
technical_spec: stringifyValue(scopeItem.technicalSpec),
internal_comments: scopeItem.internalComments ?? "",
external_comments: scopeItem.externalComments ?? "",
metadata: stringifyValue(scopeItem.metadata),
}));
}
function buildDemandRows(source: EstimateExportSource) {
const scopeItemsById = new Map(
source.version.scopeItems.map((scopeItem) => [scopeItem.id, scopeItem]),
);
return source.version.demandLines.map((line, index) => ({
line_no: index + 1,
line_id: line.id,
scope_item_id: line.scopeItemId ?? "",
scope_item_name: (line.scopeItemId ? scopeItemsById.get(line.scopeItemId)?.name : null) ?? "",
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
line_type: line.lineType,
name: line.name,
chapter: line.chapter ?? "",
hours: line.hours,
days: line.days ?? "",
fte: line.fte ?? "",
rate_source: line.rateSource ?? "",
cost_rate_cents: line.costRateCents,
bill_rate_cents: line.billRateCents,
currency: line.currency,
cost_total_cents: line.costTotalCents,
price_total_cents: line.priceTotalCents,
monthly_spread: stringifyValue(toNumericRecord(line.monthlySpread)),
staffing_attributes: stringifyValue(line.staffingAttributes),
metadata: stringifyValue(line.metadata),
}));
}
function buildResourceRows(resourceSnapshots: ExportResourceSnapshot[]) {
return resourceSnapshots.map((snapshot) => ({
id: snapshot.id,
resource_id: snapshot.resourceId ?? "",
source_eid: snapshot.sourceEid ?? "",
display_name: snapshot.displayName,
chapter: snapshot.chapter ?? "",
role_id: snapshot.roleId ?? "",
currency: snapshot.currency,
lcr_cents: snapshot.lcrCents,
ucr_cents: snapshot.ucrCents,
fte: snapshot.fte ?? "",
location: snapshot.location ?? "",
country: snapshot.country ?? "",
level: snapshot.level ?? "",
work_type: snapshot.workType ?? "",
attributes: stringifyValue(snapshot.attributes),
}));
}
function buildMetricRows(metrics: ExportMetric[]) {
return metrics.map((metric) => ({
id: metric.id,
key: metric.key,
label: metric.label,
metric_group: metric.metricGroup ?? "",
value_decimal: metric.valueDecimal,
value_cents: metric.valueCents ?? "",
currency: metric.currency ?? "",
metadata: stringifyValue(metric.metadata),
}));
}
function buildSapRows(source: EstimateExportSource, summary: EstimateExportSummary) {
return source.version.demandLines.map((line, index) => ({
record_type: "ESTIMATE_LINE",
estimate_id: summary.estimateId,
version_number: summary.versionNumber,
project_code: source.project?.shortCode ?? "",
project_name: summary.projectName ?? "",
line_no: index + 1,
line_name: line.name,
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
chapter: line.chapter ?? "",
hours: line.hours,
cost_rate_cents: line.costRateCents,
bill_rate_cents: line.billRateCents,
cost_total_cents: line.costTotalCents,
price_total_cents: line.priceTotalCents,
currency: line.currency,
rate_source: line.rateSource ?? "",
version_status: summary.versionStatus,
}));
}
function buildMmpRows(source: EstimateExportSource, summary: EstimateExportSummary) {
const monthKeys = Array.from(
new Set(
source.version.demandLines.flatMap((line) =>
Object.keys(toNumericRecord(line.monthlySpread)),
),
),
).sort();
return source.version.demandLines.map((line, index) => {
const baseRow: Record<string, unknown> = {
estimate_id: summary.estimateId,
version_number: summary.versionNumber,
project_id: summary.projectId ?? "",
project_code: source.project?.shortCode ?? "",
line_no: index + 1,
line_name: line.name,
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
total_hours: line.hours,
total_cost_cents: line.costTotalCents,
total_price_cents: line.priceTotalCents,
currency: line.currency,
};
for (const monthKey of monthKeys) {
baseRow[`month_${monthKey}`] = toNumericRecord(line.monthlySpread)[monthKey] ?? 0;
}
return baseRow;
});
}
function buildJsonDocument(source: EstimateExportSource, summary: EstimateExportSummary) {
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
estimate: {
...source.estimate,
createdAt: source.estimate.createdAt.toISOString(),
updatedAt: source.estimate.updatedAt.toISOString(),
},
project: source.project
? {
...source.project,
startDate: serializeDate(source.project.startDate),
endDate: serializeDate(source.project.endDate),
}
: null,
version: {
id: source.version.id,
versionNumber: source.version.versionNumber,
label: source.version.label ?? null,
status: source.version.status,
notes: source.version.notes ?? null,
lockedAt: serializeDate(source.version.lockedAt),
projectSnapshot: source.version.projectSnapshot,
createdAt: source.version.createdAt.toISOString(),
updatedAt: source.version.updatedAt.toISOString(),
},
summary,
assumptions: source.version.assumptions.map((assumption) => ({
...assumption,
createdAt: assumption.createdAt.toISOString(),
updatedAt: assumption.updatedAt.toISOString(),
})),
scopeItems: source.version.scopeItems.map((scopeItem) => ({
...scopeItem,
createdAt: scopeItem.createdAt.toISOString(),
updatedAt: scopeItem.updatedAt.toISOString(),
})),
demandLines: source.version.demandLines.map((line) => ({
...line,
createdAt: line.createdAt.toISOString(),
updatedAt: line.updatedAt.toISOString(),
})),
resourceSnapshots: source.version.resourceSnapshots.map((snapshot) => ({
...snapshot,
createdAt: snapshot.createdAt.toISOString(),
updatedAt: snapshot.updatedAt.toISOString(),
})),
metrics: source.version.metrics.map((metric) => ({
...metric,
createdAt: metric.createdAt.toISOString(),
updatedAt: metric.updatedAt.toISOString(),
})),
};
}
function base64ByteLength(content: string) {
const padding = content.endsWith("==") ? 2 : content.endsWith("=") ? 1 : 0;
return Math.floor((content.length * 3) / 4) - padding;
}
function buildSheetColumns(rows: ExportSheetRow[]) {
return Array.from(
rows.reduce((keys, row) => {
for (const key of Object.keys(row)) {
keys.add(key);
}
return keys;
}, new Set<string>()),
);
}
function toWorksheetCellValue(value: unknown): boolean | Date | number | string {
if (value == null) {
return "";
}
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
return stringifyValue(value);
}
function appendWorksheetFromRows(
workbook: InstanceType<ExcelJsModule["Workbook"]>,
sheetName: string,
rows: ExportSheetRow[],
): void {
const worksheet = workbook.addWorksheet(sheetName);
const columns = buildSheetColumns(rows);
if (columns.length === 0) {
return;
}
worksheet.addRow(columns);
for (const row of rows) {
worksheet.addRow(columns.map((column) => toWorksheetCellValue(row[column])));
}
}
function buildTextPayload(
format: EstimateExportFormat,
content: string,
summary: EstimateExportSummary,
options: {
mimeType: string;
fileExtension: string;
rowCount: number;
},
): EstimateExportArtifactPayload {
const lineCount = content.length === 0 ? 0 : content.split("\n").length;
return {
schemaVersion: 1,
format,
mimeType: options.mimeType,
encoding: "utf8",
fileExtension: options.fileExtension,
generatedAt: new Date().toISOString(),
byteLength: new TextEncoder().encode(content).length,
rowCount: options.rowCount,
lineCount,
previewText: content.split("\n").slice(0, 12).join("\n"),
content,
summary,
};
}
async function buildXlsxPayload(
source: EstimateExportSource,
summary: EstimateExportSummary,
): Promise<EstimateExportArtifactPayload> {
const overviewRows = buildOverviewRows(source, summary);
const assumptionRows = buildAssumptionRows(source.version.assumptions);
const scopeRows = buildScopeRows(source.version.scopeItems);
const demandRows = buildDemandRows(source);
const resourceRows = buildResourceRows(source.version.resourceSnapshots);
const metricRows = buildMetricRows(source.version.metrics);
const ExcelJS = await getExcelJS();
const workbook = new ExcelJS.Workbook();
const sheets = [
{ name: "Overview", rows: overviewRows },
{ name: "Assumptions", rows: assumptionRows },
{ name: "Scope", rows: scopeRows },
{ name: "DemandLines", rows: demandRows },
{ name: "Resources", rows: resourceRows },
{ name: "Metrics", rows: metricRows },
] as const;
for (const sheet of sheets) {
appendWorksheetFromRows(workbook, sheet.name, sheet.rows);
}
const buffer = await workbook.xlsx.writeBuffer();
const content = Buffer.from(buffer).toString("base64");
return {
schemaVersion: 1,
format: EstimateExportFormat.XLSX,
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
encoding: "base64",
fileExtension: "xlsx",
generatedAt: new Date().toISOString(),
byteLength: base64ByteLength(content),
rowCount:
overviewRows.length +
assumptionRows.length +
scopeRows.length +
demandRows.length +
resourceRows.length +
metricRows.length,
lineCount: null,
sheetNames: sheets.map((sheet) => sheet.name),
previewText: `Sheets: ${sheets.map((sheet) => sheet.name).join(", ")}`,
content,
summary,
};
}
export async function serializeEstimateExport(
source: EstimateExportSource,
format: EstimateExportFormat,
): Promise<EstimateExportArtifactPayload> {
const summary = buildSummary(source);
if (format === EstimateExportFormat.JSON) {
const content = JSON.stringify(buildJsonDocument(source, summary), null, 2);
return buildTextPayload(format, content, summary, {
mimeType: "application/json; charset=utf-8",
fileExtension: "json",
rowCount: summary.demandLineCount,
});
}
if (format === EstimateExportFormat.CSV) {
const rows = buildDemandRows(source);
return buildTextPayload(format, serializeDelimited(rows, ","), summary, {
mimeType: "text/csv; charset=utf-8",
fileExtension: "csv",
rowCount: rows.length,
});
}
if (format === EstimateExportFormat.SAP) {
const rows = buildSapRows(source, summary);
return buildTextPayload(format, serializeDelimited(rows, ";"), summary, {
mimeType: "text/plain; charset=utf-8",
fileExtension: "sap",
rowCount: rows.length,
});
}
if (format === EstimateExportFormat.MMP) {
const rows = buildMmpRows(source, summary);
return buildTextPayload(format, serializeDelimited(rows, "|"), summary, {
mimeType: "text/plain; charset=utf-8",
fileExtension: "mmp",
rowCount: rows.length,
});
}
return buildXlsxPayload(source, summary);
}