import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; let FileBlob; let SpreadsheetFile; let Workbook; const STANDARD_HEADERS = [ "ARRIVAL", "DEPARTURE", "NIGHTS", "ADULTS", "CHILDREN", "BLOCK_CODE", "NO_OF_ROOMS", "COMPANY_NAME", "CONFIRMATION_NO", "DISP_ROOM_NO", "RATE_AMOUNT", "FULL_NAME", "RES_COMMENT", "TRACE_TEXT", "PRODUCTS", "RATE_CODE", "ROOM_CATEGORY_LABEL", "REAL PRICE", "TOTAL PRICE", ]; const KB_HEADER = "KB(100/晚/间)"; const KB_SHEET = "DY-AI-Easy-KB"; const DATE_FIELDS = new Set(["ARRIVAL", "DEPARTURE"]); const INTEGER_FIELDS = new Set(["NIGHTS", "ADULTS", "CHILDREN", "NO_OF_ROOMS"]); const DECIMAL_FIELDS = new Set(["RATE_AMOUNT", "REAL PRICE", "TOTAL PRICE", KB_HEADER]); const TEXT_FIELDS = new Set([ "BLOCK_CODE", "COMPANY_NAME", "CONFIRMATION_NO", "DISP_ROOM_NO", "FULL_NAME", "RES_COMMENT", "TRACE_TEXT", "PRODUCTS", "RATE_CODE", "ROOM_CATEGORY_LABEL", ]); const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const DECIMAL_PATTERN = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/; const INVALID_SHEET_CHARS = /[:\\/?*\[\]]/; const HEADER_FILL = "#FFFFFF"; const BODY_FONT = "#222222"; const BORDER_COLOR = "#BFBFBF"; const MAX_PREVIEW_ROWS = 25; function fail(message) { throw new Error(message); } async function loadArtifactTool() { const configured = String(process.env.MONTHLY_REPORT_ARTIFACT_TOOL_MODULE ?? "").trim(); const module = configured ? await import(pathToFileURL(path.resolve(configured)).href) : await import("@oai/artifact-tool"); ({ FileBlob, SpreadsheetFile, Workbook } = module); if (!FileBlob || !SpreadsheetFile || !Workbook) fail("artifact-tool module is invalid"); } function safeText(value) { const text = String(value ?? ""); return /^[=+\-@]/.test(text) ? `'${text}` : text; } function excelDate(value) { if (!DATE_PATTERN.test(String(value ?? ""))) fail("invalid date value"); const [year, month, day] = value.split("-").map(Number); const parsed = new Date(Date.UTC(year, month - 1, day)); if ( parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day ) { fail("invalid date value"); } return parsed; } function dateKey(value) { if (value instanceof Date && !Number.isNaN(value.getTime())) { return value.toISOString().slice(0, 10); } if (typeof value === "number" && Number.isFinite(value)) { const epoch = Date.UTC(1899, 11, 30); return new Date(epoch + Math.floor(value) * 86400000).toISOString().slice(0, 10); } if (typeof value === "string" && DATE_PATTERN.test(value.slice(0, 10))) { return value.slice(0, 10); } return ""; } function decimalNumber(value) { const text = String(value ?? ""); const match = DECIMAL_PATTERN.exec(text); if (!match) fail("invalid decimal value"); const number = Number(text); if ( !Number.isFinite(number) || number < 0 || !Number.isSafeInteger(Math.round(number * 100)) ) { fail("unsafe decimal value"); } return number; } function columnName(index) { let value = index; let result = ""; while (value > 0) { value -= 1; result = String.fromCharCode(65 + (value % 26)) + result; value = Math.floor(value / 26); } return result; } function validatePayload(payload) { if (!payload || payload.schema_version !== "1.0") fail("invalid payload schema"); if ( !Number.isInteger(payload.report_year) || !Number.isInteger(payload.report_month) || payload.report_month < 1 || payload.report_month > 12 || !DATE_PATTERN.test(String(payload.as_of_date ?? "")) || typeof payload.filename !== "string" || !payload.filename.endsWith(".xlsx") || !Array.isArray(payload.channels) || payload.channels.length < 5 ) { fail("invalid monthly report contract"); } const names = new Set(); payload.channels.forEach((channel, channelIndex) => { const expectedHeaders = channel.worksheet === KB_SHEET ? [...STANDARD_HEADERS, KB_HEADER] : STANDARD_HEADERS; if ( typeof channel.worksheet !== "string" || channel.worksheet.length < 1 || channel.worksheet.length > 31 || channel.worksheet.trim() !== channel.worksheet || INVALID_SHEET_CHARS.test(channel.worksheet) || names.has(channel.worksheet) || channel.worksheet_order !== channelIndex + 1 || !Array.isArray(channel.headers) || JSON.stringify(channel.headers) !== JSON.stringify(expectedHeaders) || !Array.isArray(channel.rows) ) { fail("invalid channel worksheet contract"); } names.add(channel.worksheet); for (const row of channel.rows) { if (!row || Object.keys(row).length !== expectedHeaders.length) { fail("invalid monthly row contract"); } for (const header of expectedHeaders) { if (!Object.hasOwn(row, header)) fail("monthly row field is missing"); const value = row[header]; if (DATE_FIELDS.has(header)) { excelDate(value); } else if (INTEGER_FIELDS.has(header)) { if (!Number.isInteger(value) || value < 0) fail("invalid integer value"); } else if (DECIMAL_FIELDS.has(header)) { decimalNumber(value); } else if (TEXT_FIELDS.has(header)) { if (typeof value !== "string") fail("invalid text value"); } else { fail("unknown monthly row field"); } } } }); } function rowValues(channel, row) { return channel.headers.map((header) => { const value = row[header]; if (DATE_FIELDS.has(header)) return excelDate(value); if (INTEGER_FIELDS.has(header)) return value; if (DECIMAL_FIELDS.has(header)) return decimalNumber(value); return safeText(value); }); } function setColumnWidths(sheet, headers) { const widths = { ARRIVAL: 13, DEPARTURE: 13, NIGHTS: 9, ADULTS: 9, CHILDREN: 9, BLOCK_CODE: 18, NO_OF_ROOMS: 13, COMPANY_NAME: 28, CONFIRMATION_NO: 20, DISP_ROOM_NO: 18, RATE_AMOUNT: 14, FULL_NAME: 22, RES_COMMENT: 28, TRACE_TEXT: 26, PRODUCTS: 24, RATE_CODE: 17, ROOM_CATEGORY_LABEL: 22, "REAL PRICE": 14, "TOTAL PRICE": 16, [KB_HEADER]: 18, }; headers.forEach((header, index) => { sheet.getRange(`${columnName(index + 1)}1`).format.columnWidth = widths[header] ?? 13; }); } function writeSheet(workbook, channel) { const sheet = workbook.worksheets.add(channel.worksheet); const matrix = [channel.headers, ...channel.rows.map((row) => rowValues(channel, row))]; const lastColumn = columnName(channel.headers.length); const lastRow = matrix.length; const used = sheet.getRange(`A1:${lastColumn}${lastRow}`); used.values = matrix; used.format.font = { name: "Arial", size: 10, color: BODY_FONT }; used.format.verticalAlignment = "center"; used.format.borders = { top: { style: "thin", color: BORDER_COLOR }, bottom: { style: "thin", color: BORDER_COLOR }, left: { style: "thin", color: BORDER_COLOR }, right: { style: "thin", color: BORDER_COLOR }, }; setColumnWidths(sheet, channel.headers); const header = sheet.getRange(`A1:${lastColumn}1`); header.format.fill = HEADER_FILL; header.format.font = { name: "Arial", size: 10, bold: true, color: "#000000" }; header.format.horizontalAlignment = "center"; header.format.rowHeight = 24; header.format.wrapText = true; sheet.freezePanes.freezeRows(1); if (channel.rows.length > 0) { const data = sheet.getRange(`A2:${lastColumn}${lastRow}`); data.format.rowHeight = 22; sheet.getRange(`A2:B${lastRow}`).setNumberFormat("dd-mmm-yy"); sheet.getRange(`C2:E${lastRow}`).setNumberFormat("0"); sheet.getRange(`G2:G${lastRow}`).setNumberFormat("0"); sheet.getRange(`K2:K${lastRow}`).setNumberFormat("0.##"); sheet.getRange(`R2:S${lastRow}`).setNumberFormat("0.##"); if (channel.worksheet === KB_SHEET) { sheet.getRange(`T2:T${lastRow}`).setNumberFormat("0.##"); } sheet.getRange(`A2:K${lastRow}`).format.horizontalAlignment = "center"; sheet.getRange(`L2:${lastColumn}${lastRow}`).format.horizontalAlignment = "left"; sheet.getRange(`F2:${lastColumn}${lastRow}`).format.wrapText = true; } sheet.showGridLines = true; } function expectedCell(header, value) { if (DATE_FIELDS.has(header)) return { kind: "date", value: String(value) }; if (INTEGER_FIELDS.has(header)) return { kind: "number", value }; if (DECIMAL_FIELDS.has(header)) return { kind: "number", value: decimalNumber(value) }; // A leading apostrophe is the Excel formula-escape marker. The cell API // exposes the displayed text after consuming that marker. return { kind: "text", value: String(value ?? "") }; } async function validateWorkbook(workbook, payload, stage) { const names = workbook.worksheets.items.map((sheet) => sheet.name); const expectedNames = payload.channels.map((channel) => channel.worksheet); if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { fail(`${stage} worksheet names do not match`); } let formulaCount = 0; for (const channel of payload.channels) { const sheet = workbook.worksheets.getItem(channel.worksheet); const expectedRows = channel.rows.length + 1; const expectedColumns = channel.headers.length; const lastColumn = columnName(expectedColumns); const used = sheet.getUsedRange(); const values = used?.values ?? []; if ( values.length !== expectedRows || (values[0]?.length ?? 0) !== expectedColumns || !channel.headers.every((header, index) => values[0][index] === header) ) { fail(`${stage} worksheet dimensions or headers do not match`); } channel.rows.forEach((row, rowIndex) => { const actual = values[rowIndex + 1] ?? []; channel.headers.forEach((header, columnIndex) => { const expected = expectedCell(header, row[header]); const actualValue = actual[columnIndex]; if ( (expected.kind === "date" && dateKey(actualValue) !== expected.value) || (expected.kind === "number" && Number(actualValue) !== expected.value) || (expected.kind === "text" && String(actualValue ?? "") !== expected.value) ) { fail( `${stage} worksheet values do not match at ${channel.worksheet}!` + `${columnName(columnIndex + 1)}${rowIndex + 2}`, ); } }); }); const inspected = await workbook.inspect({ kind: "formula", sheetId: channel.worksheet, range: `A1:${lastColumn}${expectedRows}`, maxChars: 6000, options: { maxResults: 100 }, }); const inspectionText = String(inspected.ndjson ?? ""); formulaCount += inspectionText .split("\n") .filter((line) => line.includes('"kind":"formula"')).length; if (/#REF!|#DIV\/0!|#VALUE!|#NAME\?|#N\/A/.test(inspectionText)) { fail(`${stage} workbook contains a formula error`); } } if (formulaCount !== 0) fail(`${stage} workbook must contain no formulas`); return formulaCount; } async function main() { const [inputPath, outputPath, previewDir, summaryPath] = process.argv.slice(2); if (!inputPath || !outputPath || !previewDir || !summaryPath) { fail("usage: build_workbook.mjs INPUT_JSON OUTPUT_XLSX PREVIEW_DIR SUMMARY_JSON"); } await loadArtifactTool(); const payload = JSON.parse(await fs.readFile(inputPath, "utf8")); validatePayload(payload); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.mkdir(previewDir, { recursive: true }); await fs.mkdir(path.dirname(summaryPath), { recursive: true }); const workbook = Workbook.create(); payload.channels.forEach((channel) => writeSheet(workbook, channel)); await validateWorkbook(workbook, payload, "pre-export"); const xlsx = await SpreadsheetFile.exportXlsx(workbook); await xlsx.save(outputPath); await fs.chmod(outputPath, 0o600); const reopened = await SpreadsheetFile.importXlsx(await FileBlob.load(outputPath)); const formulaCount = await validateWorkbook(reopened, payload, "post-export"); const previews = []; for (let index = 0; index < payload.channels.length; index += 1) { const channel = payload.channels[index]; const lastColumn = columnName(channel.headers.length); const previewRows = Math.min(channel.rows.length + 1, MAX_PREVIEW_ROWS); const preview = await reopened.render({ sheetName: channel.worksheet, range: `A1:${lastColumn}${previewRows}`, autoCrop: "all", scale: 1.2, format: "png", }); const previewPath = path.join( previewDir, `sheet-${String(index + 1).padStart(2, "0")}.png`, ); await fs.writeFile(previewPath, new Uint8Array(await preview.arrayBuffer()), { mode: 0o600, }); previews.push(previewPath); } const stat = await fs.stat(outputPath); const semanticSha256 = crypto .createHash("sha256") .update(JSON.stringify({ channels: payload.channels }), "utf8") .digest("hex"); const summary = { status: "success", schema_version: payload.schema_version, filename: payload.filename, report_year: payload.report_year, report_month: payload.report_month, as_of_date: payload.as_of_date, sheet_names: payload.channels.map((channel) => channel.worksheet), row_counts: payload.channels.map((channel) => channel.rows.length), formula_count: formulaCount, semantic_sha256: semanticSha256, preview_count: previews.length, preview_rows: payload.channels.map((channel) => Math.min(channel.rows.length + 1, MAX_PREVIEW_ROWS)), byte_size: stat.size, }; await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); await fs.rm(`${outputPath}.inspect.ndjson`, { force: true }); process.stdout.write(`${JSON.stringify(summary)}\n`); } try { await main(); } catch (_error) { const outputPath = process.argv[3]; if (outputPath) { await fs.rm(`${outputPath}.inspect.ndjson`, { force: true }).catch(() => undefined); } if (String(process.env.MONTHLY_REPORT_DEBUG ?? "") === "1") { process.stderr.write(`${String(_error?.stack ?? _error)}\n`); } process.stderr.write( `${JSON.stringify({ status: "failed", code: "MONTHLY_REPORT_OUTPUT_VALIDATION_FAILED", })}\n`, ); process.exitCode = 4; }