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; 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; } 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>, 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()), ); 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 = { 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()), ); } 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, 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 { 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 { 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); }