Files
wyndham-ARR/monthly_reports/xlsx/build_workbook.mjs
2026-07-29 16:38:05 +08:00

441 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "KB100/晚/间)";
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;
}