feat: prepare ARR for controlled public deployment
This commit is contained in:
440
monthly_reports/xlsx/build_workbook.mjs
Normal file
440
monthly_reports/xlsx/build_workbook.mjs
Normal file
@@ -0,0 +1,440 @@
|
||||
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;
|
||||
}
|
||||
5
monthly_reports/xlsx/package.json
Normal file
5
monthly_reports/xlsx/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "arr-monthly-report-xlsx",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
Reference in New Issue
Block a user