274 lines
9.5 KiB
JavaScript
274 lines
9.5 KiB
JavaScript
const { pack, unpack } = require("./archive.cjs");
|
|
const xml = (value) =>
|
|
String(value ?? "").replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
|
);
|
|
const excelText = (value) =>
|
|
String(value)
|
|
.replace(/_x[\da-f]{4}_/gi, (match) => `_x005F_${match.slice(1)}`)
|
|
.replace(
|
|
/[\x00-\x08\x0b-\x0d\x0e-\x1f\ufffe\uffff]/g,
|
|
(char) => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}_`,
|
|
);
|
|
const column = (index) => {
|
|
let name = "";
|
|
for (index++; index; index = Math.floor((index - 1) / 26))
|
|
name = String.fromCharCode(65 + ((index - 1) % 26)) + name;
|
|
return name;
|
|
};
|
|
function sheet(rows) {
|
|
if (rows.length > 1048576 || rows.some((r) => r.length > 16384))
|
|
throw Error("Excel 행/열 한도 초과");
|
|
return `<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" state="frozen"/></sheetView></sheetViews><sheetData>${rows
|
|
.map(
|
|
(r, i) =>
|
|
`<row r="${i + 1}">${r
|
|
.map((v, j) => {
|
|
const ref = column(j) + (i + 1);
|
|
if (v == null) return "";
|
|
if (typeof v === "number") {
|
|
if (!Number.isFinite(v)) throw Error("유효하지 않은 Excel 숫자");
|
|
return `<c r="${ref}"><v>${v}</v></c>`;
|
|
}
|
|
if (typeof v === "boolean") return `<c r="${ref}" t="b"><v>${v ? 1 : 0}</v></c>`;
|
|
const text = typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref);
|
|
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(excelText(text))}</t></is></c>`;
|
|
})
|
|
.join("")}</row>`,
|
|
)
|
|
.join("")}</sheetData><pageSetup paperSize="9" orientation="landscape"/></worksheet>`;
|
|
}
|
|
function workbook(project) {
|
|
const files = new Map(),
|
|
sheets = [];
|
|
const escalationFields = [
|
|
"ANU",
|
|
"AGU",
|
|
"AJU",
|
|
"ASU",
|
|
"EscPlanned",
|
|
"EscActual",
|
|
"EscProgress",
|
|
"EscAuto",
|
|
"EscManualQuantity",
|
|
"EscConstruction",
|
|
"EscPS",
|
|
"EscBefore",
|
|
];
|
|
for (const [name, rows] of Object.entries(project.tables)) {
|
|
if (!Array.isArray(rows) || name.startsWith("Cad") || name.length > 31) continue;
|
|
// Binary originals stay in JSON/ESTX; Excel edits scalar table fields only.
|
|
const keys = [
|
|
...new Set(
|
|
rows.flatMap((r) =>
|
|
Object.keys(r).filter(
|
|
(k) =>
|
|
!k.startsWith("_") &&
|
|
r[k] != null &&
|
|
typeof r[k] !== "object" &&
|
|
!(name === "EstMainDat" && escalationFields.includes(k)),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
if (!keys.length) continue;
|
|
sheets.push({
|
|
name,
|
|
rows: [
|
|
keys,
|
|
...rows.map((r) =>
|
|
keys.map((k) => (typeof r[k] === "object" && r[k] != null ? null : r[k])),
|
|
),
|
|
],
|
|
});
|
|
}
|
|
for (const [name, rows, keys] of [
|
|
[
|
|
"Quantities",
|
|
project.quantities,
|
|
["Code", "Name", "TargetCode", "TargetKey", "formula", "digits"],
|
|
],
|
|
[
|
|
"Schedule",
|
|
project.schedule,
|
|
["id", "SourceKey", "Code", "Name", "start", "end", "weight", "progress"],
|
|
],
|
|
["ExcelQuantityLinks", project.excelQuantityLinks, ["target", "file", "sheet", "cell"]],
|
|
])
|
|
if (Array.isArray(rows) && rows.length)
|
|
sheets.push({
|
|
name,
|
|
rows: [keys, ...rows.map((row) => keys.map((key) => row[key] ?? null))],
|
|
});
|
|
if (project.quantitySheets?.length) {
|
|
const docs = [["Code", "Name", "digits", "roundMode"]],
|
|
lines = [["DocumentCode", "Position", "LineData", "Markers"]],
|
|
items = [
|
|
[
|
|
"DocumentCode",
|
|
"Position",
|
|
"Code",
|
|
"Name",
|
|
"Size",
|
|
"Unite",
|
|
"GN",
|
|
"TargetCode",
|
|
"TargetKey",
|
|
],
|
|
];
|
|
for (const document of project.quantitySheets) {
|
|
docs.push([
|
|
document.Code,
|
|
document.Name,
|
|
document.options?.digits ?? 2,
|
|
document.options?.roundMode ?? 2,
|
|
]);
|
|
document.rows.forEach((row, index) =>
|
|
lines.push([document.Code, index + 1, row.LineData, JSON.stringify(row.markers || [])]),
|
|
);
|
|
document.items.forEach((row, index) =>
|
|
items.push([
|
|
document.Code,
|
|
index + 1,
|
|
row.Code,
|
|
row.Name,
|
|
row.Size,
|
|
row.Unite,
|
|
row.GN,
|
|
row.TargetCode ?? row.LinkCode,
|
|
row.TargetKey,
|
|
]),
|
|
);
|
|
}
|
|
sheets.push(
|
|
{ name: "QDocuments", rows: docs },
|
|
{ name: "QLines", rows: lines },
|
|
{ name: "QItems", rows: items },
|
|
);
|
|
}
|
|
if (project.contractReview && Object.keys(project.contractReview.edits || {}).length) {
|
|
const keys = ["RowKey", "GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"];
|
|
sheets.push({
|
|
name: "Review",
|
|
rows: [
|
|
keys,
|
|
...Object.entries(project.contractReview.edits).map(([key, edit]) =>
|
|
keys.map((field) => (field === "RowKey" ? key : (edit[field] ?? null))),
|
|
),
|
|
],
|
|
});
|
|
}
|
|
if (project.annual?.length) {
|
|
const keys = ["name", "formula"];
|
|
sheets.push({
|
|
name: "Annual",
|
|
rows: [keys, ...project.annual.map((row) => keys.map((key) => row[key] ?? null))],
|
|
});
|
|
}
|
|
if (project.tables.EstMainDat?.length) {
|
|
const keys = ["RowKey", ...escalationFields];
|
|
const rows = project.tables.EstMainDat.flatMap((row, index) =>
|
|
Number(row.Level || 0) > 0 ||
|
|
[54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0))
|
|
? []
|
|
: [
|
|
keys.map((key) =>
|
|
key === "RowKey"
|
|
? String(row.PkID ?? (row.Code || `row:${index}`))
|
|
: (row[key] ?? null),
|
|
),
|
|
],
|
|
);
|
|
sheets.push({ name: "Escalation", rows: [keys, ...rows] });
|
|
sheets.push({
|
|
name: "EscalationSettings",
|
|
rows: [["EscDesignTotal"], [project.options?.EscDesignTotal ?? 0]],
|
|
});
|
|
}
|
|
if (project.progress?.length) {
|
|
const roundKeys = ["id", "name", "approved", "separateComponents", "amountDigits"],
|
|
inputKeys = [
|
|
"RoundId",
|
|
"RowKey",
|
|
"GumMode",
|
|
"Gum",
|
|
"GumNC",
|
|
"GumGC",
|
|
"GumJC",
|
|
"GumSC",
|
|
"SaMode",
|
|
"Sa",
|
|
"SaNC",
|
|
"SaGC",
|
|
"SaJC",
|
|
"SaSC",
|
|
"QuantityDigits",
|
|
"AmountDigits",
|
|
],
|
|
drafts = project.progress.filter((round) => !round.locked);
|
|
sheets.push({
|
|
name: "ProgressDrafts",
|
|
rows: [roundKeys, ...drafts.map((row) => roundKeys.map((key) => row[key] ?? null))],
|
|
});
|
|
sheets.push({
|
|
name: "ProgressInputs",
|
|
rows: [
|
|
inputKeys,
|
|
...drafts.flatMap((round) =>
|
|
Object.entries(round.inputs || {}).map(([key, input]) =>
|
|
inputKeys.map((field) =>
|
|
field === "RoundId" ? round.id : field === "RowKey" ? key : (input[field] ?? null),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
});
|
|
}
|
|
sheets.unshift({
|
|
name: "공사정보",
|
|
rows: [
|
|
["항목", "값"],
|
|
["공사명", project.name],
|
|
["형식", "est-web-xlsx-1"],
|
|
["설명", "표의 첫 행은 필드명입니다. 바이너리는 포함하지 않습니다."],
|
|
],
|
|
});
|
|
sheets.forEach((s, i) => files.set(`xl/worksheets/sheet${i + 1}.xml`, sheet(s.rows)));
|
|
const ns = "http://schemas.openxmlformats.org/";
|
|
files.set(
|
|
"[Content_Types].xml",
|
|
`<?xml version="1.0"?><Types xmlns="${ns}package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${sheets.map((s, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
|
|
);
|
|
files.set(
|
|
"_rels/.rels",
|
|
`<?xml version="1.0"?><Relationships xmlns="${ns}package/2006/relationships"><Relationship Id="rId1" Type="${ns}officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
|
|
);
|
|
files.set(
|
|
"xl/workbook.xml",
|
|
`<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="${ns}spreadsheetml/2006/main" xmlns:r="${ns}officeDocument/2006/relationships"><sheets>${sheets.map((s, i) => `<sheet name="${xml(s.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets><calcPr calcId="0" fullCalcOnLoad="1"/></workbook>`,
|
|
);
|
|
files.set(
|
|
"xl/_rels/workbook.xml.rels",
|
|
`<?xml version="1.0"?><Relationships xmlns="${ns}package/2006/relationships">${sheets.map((s, i) => `<Relationship Id="rId${i + 1}" Type="${ns}officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}</Relationships>`,
|
|
);
|
|
return pack(files);
|
|
}
|
|
function readWorkbook(bytes) {
|
|
const files = unpack(bytes),
|
|
selected = {};
|
|
if (!files.has("xl/workbook.xml") || !files.has("xl/_rels/workbook.xml.rels"))
|
|
throw Error("Excel 통합문서 구조 없음");
|
|
for (const [name, data] of files)
|
|
if (
|
|
name === "xl/workbook.xml" ||
|
|
name === "xl/_rels/workbook.xml.rels" ||
|
|
name === "xl/sharedStrings.xml" ||
|
|
/^xl\/worksheets\/[^/]+\.xml$/.test(name)
|
|
)
|
|
selected[name] = data.toString("utf8");
|
|
return selected;
|
|
}
|
|
module.exports = { workbook, readWorkbook, xml };
|