const EstExchange = (() => { const decodeExcelText = (value) => value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); function xmlText(buffer) { const bytes = new Uint8Array(buffer); if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le", { fatal: true }).decode(bytes); if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes); const header = new TextDecoder("ascii").decode(bytes.slice(0, 250)), declared = header.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase() || "utf-8", encoding = ["cp949", "ks_c_5601-1987", "euc-kr", "ms949"].includes(declared) ? "euc-kr" : declared; if (!["utf-8", "utf8", "euc-kr", "utf-16", "utf-16le", "utf-16be"].includes(encoding)) throw Error("지원하지 않는 XML 인코딩: " + encoding); return new TextDecoder(encoding, { fatal: true }).decode(bytes); } function parse(source) { if (/ [...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""), ) : []; const tables = {}; for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) { const name = sheet.getAttribute("name"); if ( (!EstNative.empty().tables[name] && ![ "Quantities", "Schedule", "ExcelQuantityLinks", "QDocuments", "QLines", "QItems", "Review", "Annual", "ProgressDrafts", "ProgressInputs", "Escalation", "EscalationSettings", ].includes(name)) || name.startsWith("Cad") ) continue; const id = sheet.getAttributeNS( "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id", ), rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find( (r) => r.getAttribute("Id") === id, ), target = rel?.getAttribute("Target") || ""; const path = target.startsWith("/") ? target.slice(1) : "xl/" + target; if (!files[path]) throw Error(`Excel 시트 파일 없음: ${name}`); const rows = [...parse(files[path]).getElementsByTagNameNS("*", "row")] .map((row) => { const values = []; for (const cell of row.getElementsByTagNameNS("*", "c")) { const address = cell.getAttribute("r") || "", letters = address.match(/^[A-Z]+/)?.[0]; if (!letters) throw Error("Excel 셀 주소 오류"); const at = [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1; if (at >= 16384) throw Error("Excel 열 범위 오류"); if (cell.getElementsByTagNameNS("*", "f").length) throw Error(`${name} ${address}: 수식은 Excel에서 값으로 붙여넣은 뒤 가져오세요.`); const type = cell.getAttribute("t"), v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent; let value; if (type === "s") { value = shared[Number(v)]; if (value == null) throw Error("Excel 공유 문자열 오류"); } else if (type === "inlineStr") value = [...cell.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""); else if (type === "b") value = v === "1"; else if (type === "e") throw Error(`${name} ${address}: Excel 오류값`); else if (type === "str") value = v || ""; else if (v != null) { value = Number(v); if (!Number.isFinite(value)) throw Error("Excel 숫자 오류"); } values[at] = typeof value === "string" ? decodeExcelText(value) : value; } return values; }) .filter((row) => row.some((v) => v != null)); const headers = rows.shift() || []; if ( !headers.length || headers.some( (k) => typeof k !== "string" || !/^[A-Za-z][A-Za-z0-9_]*$/.test(k) || ["constructor", "prototype"].includes(k), ) || new Set(headers).size !== headers.length ) throw Error(`${name}: 필드명 행이 올바르지 않습니다.`); tables[name] = rows.map((values) => Object.fromEntries(headers.map((k, i) => [k, values[i] ?? null])), ); } if (!Object.keys(tables).length) throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요."); return tables; } function quantitySheets(project, tables) { const names = ["QDocuments", "QLines", "QItems"]; if (!names.some((name) => Object.hasOwn(tables, name))) return null; if (!names.every((name) => Object.hasOwn(tables, name))) throw Error("수량산출서는 QDocuments·QLines·QItems 시트가 모두 필요합니다."); const old = new Map((project.quantitySheets || []).map((sheet) => [sheet.Code, sheet])); const docs = new Map(); for (const row of tables.QDocuments) { if (typeof row.Code !== "string" || !row.Code || docs.has(row.Code)) throw Error("수량산출서 코드가 비어 있거나 중복됩니다."); if (row.Name != null && typeof row.Name !== "string") throw Error("수량산출서 이름 오류"); const digits = row.digits ?? 2, roundMode = row.roundMode ?? 2; if ( !Number.isInteger(digits) || digits < 0 || digits > 12 || !Number.isInteger(roundMode) || roundMode < 0 || roundMode > 4 ) throw Error("수량산출서 자릿수·반올림 방식 오류"); docs.set(row.Code, { ...old.get(row.Code), Code: row.Code, Name: row.Name ?? "", rows: [], items: [], options: { ...old.get(row.Code)?.options, digits, roundMode }, }); } function grouped(rows) { const groups = new Map(), positions = new Set(); for (const row of rows) { if (!docs.has(row.DocumentCode)) throw Error(`수량산출서 코드 없음: ${row.DocumentCode}`); if (!Number.isInteger(row.Position) || row.Position < 1) throw Error("수량산출서 행 순서 오류"); const key = JSON.stringify([row.DocumentCode, row.Position]); if (positions.has(key)) throw Error("수량산출서 행 순서 중복"); positions.add(key); if (!groups.has(row.DocumentCode)) groups.set(row.DocumentCode, []); groups.get(row.DocumentCode).push(row); } for (const group of groups.values()) group.sort((a, b) => a.Position - b.Position); return groups; } const lines = grouped(tables.QLines), items = grouped(tables.QItems); for (const [code, doc] of docs) { doc.rows = (lines.get(code) || []).map((row) => { if (row.LineData != null && typeof row.LineData !== "string") throw Error("산출근거 문자 오류"); let markers; try { markers = JSON.parse(row.Markers || "[]"); } catch { throw Error("수량 결과 표지 형식 오류"); } if (!Array.isArray(markers) || markers.some((value) => typeof value !== "string")) throw Error("수량 결과 표지 형식 오류"); return { LineData: row.LineData ?? "", markers }; }); const prior = new Map((old.get(code)?.items || []).map((item) => [item.Code, item])); const seen = new Set(); doc.items = (items.get(code) || []).map((row) => { if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code)) throw Error("수량 항목 코드가 비어 있거나 중복됩니다."); seen.add(row.Code); for (const key of ["Name", "Size", "Unite", "TargetCode", "TargetKey"]) if (row[key] != null && typeof row[key] !== "string") throw Error(`수량 항목 ${key} 형식 오류`); const previous = prior.get(row.Code), next = { ...previous, Code: row.Code }; for (const key of ["Name", "Size", "Unite"]) { if (row[key] != null) next[key] = row[key]; else if (!previous || previous[key] != null) next[key] = ""; } if (row.GN != null) { if (typeof row.GN !== "number" || !Number.isFinite(row.GN)) throw Error("수량 항목 수치 오류"); next.GN = row.GN; } else if (!previous || previous.GN != null) next.GN = 0; if (row.TargetCode != null) { if ( !previous || Object.hasOwn(previous, "TargetCode") || row.TargetCode !== previous.LinkCode ) next.TargetCode = row.TargetCode; } else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = ""; if (row.TargetKey != null) next.TargetKey = row.TargetKey; else if (previous?.TargetKey) next.TargetKey = ""; return next; }); } return [...docs.values()]; } function apply(project, tables) { const next = structuredClone(project); const sheets = quantitySheets(next, tables); if (sheets) next.quantitySheets = sheets; if (Object.hasOwn(tables, "ProgressDrafts") || Object.hasOwn(tables, "ProgressInputs")) { if (!Object.hasOwn(tables, "ProgressDrafts") || !Object.hasOwn(tables, "ProgressInputs")) throw Error("기성 Excel에는 ProgressDrafts와 ProgressInputs 시트가 모두 필요합니다."); const old = next.progress || [], locked = old.filter((round) => round.locked); if (old.slice(0, locked.length).some((round) => !round.locked)) throw Error("확정 기성 회차 순서 오류"); const previous = new Map( old.filter((round) => !round.locked).map((round) => [round.id, round]), ); const ids = new Set(locked.map((round) => round.id)), drafts = new Map(); for (const row of tables.ProgressDrafts) { if (typeof row.id !== "string" || !row.id || ids.has(row.id)) throw Error("기성 회차 ID가 없거나 중복됩니다."); ids.add(row.id); const round = { ...previous.get(row.id), id: row.id, name: row.name ?? "", locked: false, approved: row.approved ?? false, separateComponents: row.separateComponents ?? true, inputs: Object.create(null), }; delete round.contract; delete round.result; if (row.amountDigits == null) delete round.amountDigits; else round.amountDigits = row.amountDigits; drafts.set(row.id, round); } const inputFields = [ "GumMode", "Gum", "GumNC", "GumGC", "GumJC", "GumSC", "SaMode", "Sa", "SaNC", "SaGC", "SaJC", "SaSC", "QuantityDigits", "AmountDigits", ]; for (const row of tables.ProgressInputs) { const round = drafts.get(row.RoundId); if ( !round || typeof row.RowKey !== "string" || !row.RowKey || Object.hasOwn(round.inputs, row.RowKey) ) throw Error("기성 입력의 회차·행 식별자가 없거나 중복됩니다."); const prior = previous.get(row.RoundId)?.inputs?.[row.RowKey] || {}; const input = Object.fromEntries( Object.entries(prior).filter(([key]) => !inputFields.includes(key)), ); for (const field of inputFields) if (row[field] != null) input[field] = row[field]; round.inputs[row.RowKey] = input; } next.progress = [...locked, ...drafts.values()]; } for (const [table, rows] of Object.entries(tables)) { if ( [ "QDocuments", "QLines", "QItems", "ProgressDrafts", "ProgressInputs", "Escalation", "EscalationSettings", "ExcelQuantityLinks", ].includes(table) ) continue; if (table === "Annual") { next.annual = rows.map((row) => { if (typeof row.name !== "string" || typeof row.formula !== "string") throw Error("연차 이름·수식 형식 오류"); return { name: row.name, formula: row.formula }; }); continue; } if (table === "Review") { const edits = {}; for (const row of rows) { if ( typeof row.RowKey !== "string" || !row.RowKey.startsWith("row:") || Object.hasOwn(edits, row.RowKey) ) throw Error("계약심사 내역 식별자가 없거나 중복됩니다."); const edit = {}; for (const field of ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"]) if (row[field] != null) edit[field] = row[field]; edits[row.RowKey] = edit; } next.contractReview = { edits }; EstReview.validate(next); continue; } if (table === "Quantities") { const seen = new Set(); next.quantities = rows.map((row) => { if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code)) throw Error("수식표 코드가 비어 있거나 중복됩니다."); seen.add(row.Code); for (const key of ["Name", "TargetCode", "TargetKey", "formula"]) if (row[key] != null && typeof row[key] !== "string") throw Error(`수식표 ${key} 형식 오류`); const digits = row.digits ?? 2; if (!Number.isInteger(digits) || digits < 0 || digits > 6) throw Error("수식표 소수자리는 0~6입니다."); return { Code: row.Code, Name: row.Name ?? "", TargetCode: row.TargetCode ?? "", TargetKey: row.TargetKey ?? "", formula: row.formula ?? "0", digits, }; }); continue; } if (table === "Schedule") { next.schedule = rows.map((row) => ({ id: row.id, SourceKey: row.SourceKey ?? "", Code: row.Code ?? "", Name: row.Name ?? "", start: row.start ?? "", end: row.end ?? "", weight: row.weight ?? 0, progress: row.progress ?? 0, })); EstSchedule.validate(next); continue; } const old = next.tables[table], byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r])); const keys = rows[0] ? Object.keys(rows[0]) : []; if ( !keys.includes("PkID") && old.some((r) => Object.values(r).some((v) => v && typeof v === "object")) ) throw Error(`${table}: 바이너리를 보존하려면 PkID 열이 필요합니다.`); const seen = new Set(); next.tables[table] = rows.map((row) => { if (row.PkID != null) { if (seen.has(row.PkID)) throw Error(`${table}: PkID 중복`); seen.add(row.PkID); } const previous = byId.get(row.PkID); // Keep original blobs and fields deliberately omitted from the workbook. const merged = { ...previous, ...row }; if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) { if ( previous && row.TC !== previous.TC && (row.InputTC ?? previous.InputTC) === previous.InputTC ) { merged.InputTC = row.TC; merged.ValueIndex = 4; } for (const component of ["NC", "GC", "JC", "SC"]) if ( previous && row[component] !== previous[component] && (row["Input" + component] ?? previous["Input" + component]) === previous["Input" + component] ) merged["Input" + component] = row[component]; if ( !previous || Object.keys(row).some((key) => (row[key] ?? null) !== (previous[key] ?? null)) ) next.options.SourceRefresh = true; } for (const key of keys) if (previous?.[key] && typeof previous[key] === "object") merged[key] = previous[key]; if (Object.hasOwn(row, "Code") && (previous?.Code ?? "") !== (row.Code ?? "") && previous) throw Error("Excel에서 기존 코드를 바꿀 수 없습니다. 앱의 코드 편집을 사용하세요."); return merged; }); } if (Object.hasOwn(tables, "Escalation")) { const targets = new Map(); next.tables.EstMainDat.forEach((row, index) => { if ( Number(row.Level || 0) > 0 || [54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0)) ) return; const key = EstBusiness.rowKey(row, index), matches = targets.get(key) || []; matches.push(row); targets.set(key, matches); }); const seen = new Set(); const fields = [ "ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress", "EscAuto", "EscManualQuantity", "EscConstruction", "EscPS", "EscBefore", ]; for (const row of tables.Escalation) { const matches = targets.get(row.RowKey) || []; if (typeof row.RowKey !== "string" || seen.has(row.RowKey) || matches.length !== 1) throw Error(`물가변동 내역이 없거나 중복됩니다: ${row.RowKey}`); seen.add(row.RowKey); for (const field of fields) { if (!Object.hasOwn(row, field)) continue; if (row[field] == null) delete matches[0][field]; else { if ( field === "EscAuto" ? typeof row[field] !== "boolean" : typeof row[field] !== "number" || !Number.isFinite(row[field]) ) throw Error(`물가변동 ${field} 형식 오류`); matches[0][field] = row[field]; } } } } if (Object.hasOwn(tables, "ExcelQuantityLinks")) { next.excelQuantityLinks = tables.ExcelQuantityLinks.map((row) => ({ target: row.target, file: row.file, sheet: row.sheet, cell: row.cell, })); EstModel.validate(next); for (const link of next.excelQuantityLinks) { const rows = next.tables.EstMainDat.filter((row) => row.PkID === link.target); if (rows.length !== 1 || !EstQuantity.excelTarget(rows[0])) throw Error(`Excel 연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`); } } if (Object.hasOwn(tables, "EstMainDat")) for (const link of project.excelQuantityLinks || []) if ( (next.excelQuantityLinks || []).some((item) => item.target === link.target) && !project.tables.EstMainDat.some((row) => row.PkID === link.target) && next.tables.EstMainDat.some((row) => row.PkID === link.target) ) throw Error(`삭제된 Excel 연결 내역의 식별자를 다시 사용할 수 없습니다: ${link.target}`); if (Object.hasOwn(tables, "EscalationSettings")) { const rows = tables.EscalationSettings; if ( rows.length !== 1 || typeof rows[0].EscDesignTotal !== "number" || !Number.isFinite(rows[0].EscDesignTotal) || rows[0].EscDesignTotal < 0 ) throw Error("물가변동 조사 도급액 형식 오류"); next.options.EscDesignTotal = rows[0].EscDesignTotal; } if (Object.hasOwn(tables, "Review")) { const targets = new Map(); next.tables.EstMainDat.forEach((row, index) => { if (!EstReview.editable(row)) return; const key = EstReview.rowKey(row, index), matches = targets.get(key) || []; matches.push(row); targets.set(key, matches); }); for (const [key, edit] of Object.entries(next.contractReview.edits)) { const matches = targets.get(key) || []; if (matches.length !== 1) throw Error(`심사 내역이 없거나 중복됩니다: ${key}`); if ( Object.keys(edit).some( (field) => field !== "Reason" && !EstReview.allowed(matches[0]).includes(field), ) ) throw Error(`해당 내역에 사용할 수 없는 심사 입력: ${key}`); } } if (Object.hasOwn(tables, "ProgressDrafts")) { const counts = new Map(); next.tables.EstMainDat.forEach((row, index) => { const key = EstBusiness.rowKey(row, index); counts.set(key, (counts.get(key) || 0) + 1); }); for (const round of next.progress.filter((item) => !item.locked)) for (const key of Object.keys(round.inputs)) if (counts.get(key) !== 1) throw Error(`기성 입력 내역이 없거나 중복됩니다: ${key}`); } return EstModel.validate(next); } async function exportExcel() { document.activeElement?.blur(); const response = await fetch("/api/export-xlsx", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(EstApp.state.project), }); if (!response.ok) throw Error((await response.json()).error); EstApp.download( `${EstApp.state.project.name}.xlsx`, await response.blob(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); } async function exportNative() { document.activeElement?.blur(); const response = await fetch("/api/export-estx", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(EstApp.state.project), }); if (!response.ok) throw Error((await response.json()).error); EstApp.download( `${EstApp.state.project.name}_edited.estx`, await response.blob(), "application/octet-stream", ); } async function exportJdl() { document.activeElement?.blur(); const project = EstApp.state.project, out = await EstApp.api("/api/export-jdl", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(project), }); if (!confirm(out.warnings.slice(0, 15).join("\n") + "\nJDL 기본 표로 내보낼까요?")) return; EstApp.download( `${project.name}.jdl`, Uint8Array.from(atob(out.data), (c) => c.charCodeAt(0)), "application/octet-stream", ); } function install() { const jdlInput = document.createElement("input"); jdlInput.type = "file"; jdlInput.accept = ".jdl"; jdlInput.hidden = true; jdlInput.id = "jdl-file"; document.body.append(jdlInput); jdlInput.addEventListener("change", async () => { const file = jdlInput.files[0], key = EstApp.state.documentKey, revision = EstApp.state.revision; if (!file) return; try { const next = await EstApp.api("/api/import-jdl", { method: "POST", body: await file.arrayBuffer(), }); if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision) throw Error("작업이 변경되었습니다. 다시 불러오세요."); if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } finally { jdlInput.value = ""; } }); const xmlInput = document.createElement("input"); xmlInput.type = "file"; xmlInput.accept = ".xml"; xmlInput.hidden = true; xmlInput.id = "xml-file"; document.body.append(xmlInput); xmlInput.addEventListener("change", async () => { const file = xmlInput.files[0], key = EstApp.state.documentKey, revision = EstApp.state.revision; if (!file) return; try { const next = EstC3r.read(xmlText(await file.arrayBuffer())); if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision) throw Error("작업이 변경되었습니다. 다시 불러오세요."); if (confirm("현재 작업 대신 XML 내역서를 여시겠습니까?")) EstApp.setProject(next); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } finally { xmlInput.value = ""; } }); const input = document.createElement("input"); input.type = "file"; input.accept = ".xlsx"; input.hidden = true; input.id = "excel-file"; document.body.append(input); input.addEventListener("change", async () => { const file = input.files[0]; if (!file) return; const key = EstApp.state.documentKey, revision = EstApp.state.revision; try { const files = await EstApp.api("/api/import-xlsx", { method: "POST", body: await file.arrayBuffer(), }), tables = read(files); if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision) throw Error("작업이 변경되었습니다. Excel을 다시 불러오세요."); const next = apply(EstApp.state.project, tables); if ( !confirm( `${Object.entries(tables) .map(([name, rows]) => `${EstApp.titles[name] || name}: ${rows.length}행`) .join("\n")}\n현재 작업의 해당 표를 교체합니다.`, ) ) return; EstApp.change(() => (EstApp.state.project = next)); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } finally { input.value = ""; } }); } return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install }; })();