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 `${rows .map( (r, i) => `${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 `${v}`; } if (typeof v === "boolean") return `${v ? 1 : 0}`; const text = typeof v === "object" ? JSON.stringify(v) : String(v); if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref); return `${xml(excelText(text))}`; }) .join("")}`, ) .join("")}`; } 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", `${sheets.map((s, i) => ``).join("")}`, ); files.set( "_rels/.rels", ``, ); files.set( "xl/workbook.xml", `${sheets.map((s, i) => ``).join("")}`, ); files.set( "xl/_rels/workbook.xml.rels", `${sheets.map((s, i) => ``).join("")}`, ); 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 };