// JDLCHK.dll field declarations: 4e926c..4fb010; EST writer e44f28/e4739c/e541ec. const { pack, unpack } = require("./archive.cjs"); const { createHash } = require("node:crypto"); const { cp949 } = require("./export-estx.cjs"); const schema = require("./jdl-schema.json"); const { exportMachines } = require("./jdl-machine-export.cjs"); const { orderUnits } = require("./jdl-unit-order.cjs"); const { isFixedPart, exportUnitPart } = require("./jdl-unit-part.cjs"); const { canPreserveSpecial, fixedSpecialPart } = require("./jdl-special-unit.cjs"); const { ordinaryMachinePart, machineQuantity } = require("./jdl-machine-parts.cjs"); const { matchesAmounts } = require("./jdl-mainpart.cjs"); const decoder = new TextDecoder("euc-kr", { fatal: true }); function fingerprint(project) { const { originalJdlState, id, ...content } = project; return createHash("sha256").update(JSON.stringify(content)).digest("hex"); } function markOriginal(project) { project.originalJdlState = fingerprint(project); return project; } const num = (v) => { const n = Number(v || 0); if (!Number.isFinite(n)) throw Error("JDL 수치 오류"); return n; }; function text(v, max = 40) { const s = String(v ?? ""); if (/[\x00-\x1f]/.test(s)) throw Error("JDL 문자열에 제어문자가 있습니다."); if (cp949(s).length > max) throw Error(`JDL 문자열 길이 ${max}바이트 초과: ${s}`); return s; } function encode(name, rows) { const fields = schema[name]; return cp949( rows .map( (row) => fields .map((f) => { const v = row[f.name]; return f.kind === 6 ? String(num(v)) : text(v, f.kind === 16 ? 100000 : f.size || 1); }) .join("\t") + "\x05\r\n", ) .join(""), ); } function parse(name, bytes) { const fields = schema[name]; if (!fields || !bytes) return []; return decoder .decode(bytes) .split(/\r?\n/) .filter((v) => v.trim()) .map((line, i) => { const values = line .replace(/\x05\s*$/, "") .split("\t") .map((v) => v.trim()); if (values.length !== fields.length) throw Error( `${name} ${i + 1}행: ${fields.length}개 필드가 필요합니다 (${values.length}개).`, ); return Object.fromEntries( fields.map((f, j) => [ f.name, f.kind === 6 ? num(values[j]) : text(values[j], f.kind === 16 ? 100000 : f.size || 1), ]), ); }); } function output(project) { if (project.originalJdl && project.originalJdlState === fingerprint(project)) return { bytes: Buffer.from(project.originalJdl, "base64"), warnings: [ "변경되지 않은 원본 JDL을 그대로 내보냅니다. 원본 검사 DLL 인증은 확인하지 않았습니다.", ], }; const tables = Object.fromEntries(Object.keys(schema).map((k) => [k, []])), warnings = [], map = new Map(), ambiguous = new Set(); let serial = 0; function source(kind, row) { const key = kind + ":" + row.Code; if (row.Code && map.has(key)) { map.delete(key); ambiguous.add(key); warnings.push(`${row.Code}: 중복 자원 코드는 독립 금액으로 전개합니다.`); } const prefix = kind === 5 ? "M" : kind === 6 ? "L" : "W", code = prefix + String(++serial).padStart(10, "0"); const price = kind === 5 ? { F_MPRICE: num(row.TC) } : kind === 6 ? { F_LPRICE: num(row.TC) } : { F_MPRICE: num(row.GC), F_LPRICE: num(row.NC), F_EPRICE: num(row.JC) + num(row.SC) }; const name = prefix === "M" ? "m.txt" : prefix === "L" ? "l.txt" : "w.txt"; tables[name].push({ F_CODE: code, F_DES: row.Name, F_SIZE: row.Size, F_UNIT: row.Unite, ...price, F_ORICODE: "00000000000", }); if (row.Code && !ambiguous.has(key)) map.set(key, code); return code; } for (const [kind, table] of [ [5, "StuffcDat"], [6, "LaborcDat"], [21, "SilJukDat"], [101, "EstiMateDat"], ]) for (const row of project.tables[table] || []) source(kind, row); for (const table of ["CostTitleDat", "MachTitleDat", "HeadTitleDat"]) for (const row of project.tables[table] || []) source({ CostTitleDat: 104, MachTitleDat: 105, HeadTitleDat: 103 }[table], row); for (const title of project.tables.CostTitleDat || []) { if (!title.Code || ambiguous.has("104:" + title.Code)) continue; const code = "D" + String(++serial).padStart(10, "0"); map.set("104:" + title.Code, code); tables["danga.txt"].push({ F_CODE: code, F_DES: title.Name, F_SIZE: title.Size, F_UNIT: title.Unite, F_MPRICE: num(title.GC), F_LPRICE: num(title.NC), F_EPRICE: num(title.JC) + num(title.SC), F_ORICODE: "00000000000", }); } exportMachines(project, tables, map, ambiguous, num, () => ++serial, source, warnings); const unitGroups = new Map(); let unitGroup; for (const row of project.tables.HeadMainDat || []) { if (num(row.Index) > 0) { unitGroup = []; if (unitGroups.has(row.Code)) { if (unitGroups.get(row.Code)) warnings.push(`${row.Code}: 중복 일위 상세는 고정 단가로 전개합니다.`); unitGroups.set(row.Code, null); } else unitGroups.set(row.Code, unitGroup); } else if (unitGroup) unitGroup.push(row); } const exportedUnits = new Set(); for (const title of orderUnits(project.tables.HeadTitleDat || [], unitGroups)) { const parts = unitGroups.get(title.Code); if ( !title.Code || ambiguous.has("103:" + title.Code) || num(title.SC) || !parts?.length || parts.some( (row) => (!isFixedPart(row, num) && (![5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) || num(row.CalcIndex) || (num(row.LinkIndex) === 103 && row.Code === title.Code) || !map.has(num(row.LinkIndex) + ":" + row.Code))) || ["SKind", "Gangub", "OCheck", "Biul", "SC", "SU"].some((key) => num(row[key])), ) ) continue; const code = "U" + String(++serial).padStart(10, "0"); map.set("103:" + title.Code, code); for (const row of parts) if (!isFixedPart(row, num) && num(row.LinkIndex) === 103 && !exportedUnits.has(row.Code)) warnings.push(`${title.Code}/${row.Code}: 하위 일위대가를 고정 단가로 전개합니다.`); if (parts.some((row) => isFixedPart(row, num))) warnings.push(`${title.Code}: 일위 상세의 고정 금액을 별도 단가로 내보냅니다.`); exportedUnits.add(title.Code); tables["unit.txt"].push({ F_CODE: code, F_DES: title.Name, F_SIZE: title.Size, F_UNIT: title.Unite, F_MPRICE: num(title.GC), F_LPRICE: num(title.NC), F_EPRICE: num(title.JC) + num(title.SC), F_ORICODE: "00000000000", }); for (const [index, row] of parts.entries()) tables["unitpart.txt"].push( exportUnitPart(row, code, index, map, source, num, () => ++serial), ); } let group = null, groupNumber = 0, sequence = 0; function addGroup(name) { if (++groupNumber > 99) throw Error("JDL 단일 레벨 변환은 공종 99개까지 가능합니다."); group = { F_CODE: String(groupNumber).padStart(2, "0"), F_DES: text(name), F_QQTY: 1, F_MPRICE: 0, F_LPRICE: 0, F_EPRICE: 0, F_ORICODE: "00000000000", }; tables["gongjong.txt"].push(group); sequence = 0; } for (const row of project.tables.EstMainDat) { if (num(row.LinkIndex) === 54) break; if (num(row.Level) > 0) { addGroup(row.Name); continue; } if (!group) addGroup(project.name); if (num(row.CalcIndex) >= 2) { warnings.push(`${row.Code || row.Name}: 합산 제외 행 생략`); continue; } let key = map.get(num(row.LinkIndex) + ":" + row.Code), gn = num(row.GN), m = num(row.GU), l = num(row.NU), e = num(row.JU) + num(row.SU); if ( !key || num(row.CalcIndex) === 1 || num(row.CalcIndex) === 3 || !matchesAmounts(row, gn, [m, l, e], num) ) { key = source(0, { Code: "LITERAL" + ++serial, Name: row.Name, Size: row.Size, Unite: row.Unite, GC: row.GC, NC: row.NC, JC: row.JC, SC: row.SC, }); gn = ["%", "%"].includes(row.Unite) ? 100 : 1; m = num(row.GC); l = num(row.NC); e = num(row.JC) + num(row.SC); warnings.push(`${row.Code || row.Name}: 저장 금액을 독립 항목으로 전개`); } tables["mainpart.txt"].push({ F_CODE: group.F_CODE, F_SEQ: String(++sequence), F_CODE1: key, F_DES: row.Name, F_SIZE: row.Size, F_UNIT: row.Unite, F_QQTY: gn, F_MPRICE: m, F_LPRICE: l, F_EPRICE: e, F_SPROPER: "0", F_LOADBM: "T", F_SAVEBM: "T", F_SRATE: 0, F_KWAN: "0", F_CLASS: "0", F_ORICODE: "00000000000", }); group.F_MPRICE += num(row.GC); group.F_LPRICE += num(row.NC); group.F_EPRICE += num(row.JC) + num(row.SC); } if (!tables["gongjong.txt"].length) addGroup(project.name); const direct = tables["gongjong.txt"].reduce( (a, r) => ({ m: a.m + r.F_MPRICE, l: a.l + r.F_LPRICE, e: a.e + r.F_EPRICE }), { m: 0, l: 0, e: 0 }, ); const global = `[BASIC]\r\nMAKER=ESTWEB\r\nVERSION=2.00\r\nCDATE=${new Date().toISOString().slice(0, 10).replaceAll("-", "")}\r\n[CPART]\r\nMQBIT=7C\r\nMABIT=0C\r\nMTBIT=0C\r\nMCMETHOD=0LME\r\nUABIT=0C\r\nUTBIT=0C\r\nUCMETHOD=0LME\r\nDCMETHOD=0LME\r\nJABIT=0C\r\nJTBIT=0C\r\nJCMETHOD=0LME\r\nDQBIT=2R\r\nDABIT=0C\r\nDTBIT=0C\r\n[JPART]\r\n[VPART]\r\n[COST]\r\nMCOST=${direct.m}\r\nLCOST=${direct.l}\r\nECOST=${direct.e}\r\nTCOST=${direct.m + direct.l + direct.e}\r\n`; const files = new Map([ ["Global.ini", cp949(global)], ["Main.txt", cp949(["0", text(project.name, 80), "02"].join("\t") + "\x05\r\n")], ]); for (const [name, rows] of Object.entries(tables)) files.set(name, encode(name, rows)); const fees = (project.tables.Wonga || []) .map( (r) => [ text(r.Gubun, 12), text(r.Name), text(r.Youl, 25), text(r.VSusic, 200), String(num(r.Cost)), ].join("\t") + "\x05\r\n", ) .join(""); files.set("CostAccount.txt", cp949(fees)); warnings.unshift( "JDL 기본 표를 출력합니다. 산근·기계와 특수 일위의 편집 산식, 관급/특수 업무 구조는 JSON/ESTX에 보관하세요. 원본 검사 DLL의 인증은 미확인입니다.", ); return { bytes: pack(files), warnings }; } function read(bytes, project) { const files = new Map(); for (const [name, data] of unpack(bytes)) { const key = name.toLowerCase(); if (files.has(key)) throw Error(`JDL 파일명 중복: ${name}`); files.set(key, data); } if (!files.has("global.ini") || !files.has("main.txt")) throw Error("Global.ini/Main.txt 없는 JDL입니다."); const main = decoder .decode(files.get("main.txt")) .replace(/\x05\s*$/, "") .split("\t"); if (main.length !== 3) throw Error("JDL Main.txt 필드 오류"); project.name = main[1].trim(); project.options.AutoCalc = false; project.options.SourceRefresh = false; const rows = Object.fromEntries( Object.keys(schema).map((name) => [name, parse(name, files.get(name))]), ); const byCode = new Map(); for (const [file, table, kind] of [ ["m.txt", "StuffcDat", 5], ["l.txt", "LaborcDat", 6], ["e.txt", "EstiMateDat", 101], ["w.txt", "EstiMateDat", 101], ["j.txt", "EstiMateDat", 101], ["unit.txt", "HeadTitleDat", 103], ["junit.txt", "MachTitleDat", 105], ["danga.txt", "CostTitleDat", 104], ]) for (const r of rows[file]) { if (byCode.has(r.F_CODE)) throw Error("JDL 자원 코드 중복: " + r.F_CODE); const row = { PkID: project.tables[table].length + 1, Code: r.F_CODE, Name: r.F_DES, Size: r.F_SIZE, Unite: r.F_UNIT, GC: num(r.F_MPRICE), NC: num(r.F_LPRICE), JC: num(r.F_EPRICE), SC: 0, }; row.TC = row.InputTC = row.GC + row.NC + row.JC; row.ValueIndex = 4; project.tables[table].push(row); byCode.set(row.Code, { row, kind }); } let unitPk = 0; const unitWarnings = []; const machineParts = new Map(); for (const part of rows["jpart.txt"]) { if (!machineParts.has(part.F_CODE)) machineParts.set(part.F_CODE, []); machineParts.get(part.F_CODE).push(part); } let machineDetailed = false, machineWeighted = false, machineSpecial = false, machineFallback = false; for (const title of rows["junit.txt"]) { const gc = num(title.F_MPRICE), nc = num(title.F_LPRICE), jc = num(title.F_EPRICE); const amounts = { GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc }; const parts = machineParts.get(title.F_CODE) || []; const uniqueSequences = new Set(parts.map((part) => part.F_SEQ)); const identifiable = parts.length > 0 && uniqueSequences.size === parts.length && parts.every((part) => Boolean(part.F_SEQ) && num(part.F_QQTY) > 0); const ordinary = identifiable && parts.every((part) => ordinaryMachinePart(part, num)); const matches = (weighted) => [ ["F_MPRICE", gc], ["F_LPRICE", nc], ["F_EPRICE", jc], ].every( ([field, value]) => Math.abs( parts.reduce( (sum, part) => sum + num(part[field]) * (weighted ? machineQuantity(part, num) : 1), 0, ) - value, ) < 1e-6, ); const direct = identifiable && matches(false); const weighted = identifiable && !direct && matches(true); const basic = (direct && parts.every((part) => machineQuantity(part, num) === 1)) || weighted; project.tables.MachMainDat.push({ PkID: project.tables.MachMainDat.length + 1, Index: 1, Code: title.F_CODE, JdlSummary: true, Name: title.F_DES, Size: title.F_SIZE, Unite: title.F_UNIT, ...amounts, }); if (basic) { machineDetailed = true; machineWeighted ||= weighted; machineSpecial ||= !ordinary; for (const part of parts) { const factor = weighted ? machineQuantity(part, num) : 1, GC = num(part.F_MPRICE) * factor, NC = num(part.F_LPRICE) * factor, JC = num(part.F_EPRICE) * factor; project.tables.MachMainDat.push({ PkID: project.tables.MachMainDat.length + 1, Index: 0, CalcIndex: 1, JdlSummary: true, JdlSourceCode: part.F_CODE1, JdlSourceQuantity: num(part.F_QQTY), Name: part.F_DES, Size: part.F_SIZE, Unite: part.F_UNIT, GN: num(part.F_QQTY), GC, NC, JC, SC: 0, TC: GC + NC + JC, }); } } else { machineFallback = true; project.tables.MachMainDat.push({ PkID: project.tables.MachMainDat.length + 1, Index: 0, CalcIndex: 1, JdlSummary: true, Name: "JDL 저장 금액 (상세 산식 미변환)", ...amounts, }); } } if (machineDetailed) unitWarnings.push( "기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.", ); if (machineWeighted) unitWarnings.push( "일부 기계 상세는 단위에 따른 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다.", ); if (machineSpecial) unitWarnings.push("특수 기계 상세는 저장 금액으로 고정하며 특수 산식은 원문에 보존합니다."); if (machineFallback) unitWarnings.push( "일부 기계경비는 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.", ); if (rows["jpart.txt"].some((r) => !byCode.has(r.F_CODE) || byCode.get(r.F_CODE).kind !== 105)) unitWarnings.push("부모 기계 단가가 없는 상세 행은 원본 JDL에만 보존됩니다."); for (const title of rows["danga.txt"]) { const gc = num(title.F_MPRICE), nc = num(title.F_LPRICE), jc = num(title.F_EPRICE); project.tables.CostMainDat.push({ PkID: project.tables.CostMainDat.length + 1, Index: 1, Code: title.F_CODE, JdlSummary: true, Name: title.F_DES, Size: title.F_SIZE, Unite: title.F_UNIT, GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc, }); // shortcut: JDL 단가 산출 상세는 원문에만 보존; 산식 형식 확인 후 상세 변환. for (const [key, value, check] of [ ["GC", gc, -2], ["NC", nc, -1], ["JC", jc, -3], ]) { const literal = value.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20, }); project.tables.CostMainDat.push({ PkID: project.tables.CostMainDat.length + 1, Index: 0, SCheck: check, JdlSummary: true, Name: "JDL 저장 금액", LineData: `${literal} = \x12${literal}\x12`, [key]: value, TC: value, }); } } if (rows["danga.txt"].length) unitWarnings.push("단가산출은 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다."); const unitCodes = new Set(rows["unit.txt"].map((r) => r.F_CODE)); const partsByUnit = new Map(); for (const part of rows["unitpart.txt"]) { if (!partsByUnit.has(part.F_CODE)) partsByUnit.set(part.F_CODE, []); partsByUnit.get(part.F_CODE).push(part); } for (const title of rows["unit.txt"]) { const gc = num(title.F_MPRICE), nc = num(title.F_LPRICE), jc = num(title.F_EPRICE); const parts = partsByUnit.get(title.F_CODE) || []; const preserveSpecial = canPreserveSpecial(parts, title, num); let incomplete = !parts.length; const included = { GC: 0, NC: 0, JC: 0 }; project.tables.HeadMainDat.push({ PkID: ++unitPk, Index: 1, Code: title.F_CODE, TitleCode: title.F_CODE, JdlSummary: true, Name: title.F_DES, Size: title.F_SIZE, Unite: title.F_UNIT, GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc, }); for (const part of parts) { const source = byCode.get(part.F_CODE1); const basic = source && ["", "0"].includes(part.F_SPROPER) && ["", "T"].includes(part.F_LOADBM) && ["", "T"].includes(part.F_SAVEBM) && !num(part.F_SRATE) && ["", "0"].includes(part.F_KWAN) && ["", "0"].includes(part.F_CLASS); if (!basic) { if (preserveSpecial) { const row = fixedSpecialPart(part, source, ++unitPk, num); project.tables.HeadMainDat.push(row); for (const key of ["GC", "NC", "JC"]) included[key] += row[key]; unitWarnings.push( `일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 저장 금액으로 고정하며 산식은 원문에 보존합니다.`, ); continue; } incomplete = true; unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 원문에만 보존`); continue; } const gc = num(part.F_MCOST), nc = num(part.F_LCOST), jc = num(part.F_ECOST); const quantity = num(part.F_QQTY) / (["%", "%"].includes(part.F_UNIT) ? 100 : 1); const fixedAmount = [ [gc, num(part.F_MPRICE)], [nc, num(part.F_LPRICE)], [jc, num(part.F_EPRICE)], ].some(([amount, price]) => Math.abs(amount - Math.trunc(quantity * price)) > 0.000001); if (fixedAmount) unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 상세의 저장 금액을 고정합니다.`); included.GC += gc; included.NC += nc; included.JC += jc; project.tables.HeadMainDat.push({ PkID: ++unitPk, Index: 0, Code: part.F_CODE1, Name: part.F_DES, Size: part.F_SIZE, Unite: part.F_UNIT, LinkIndex: source.kind, CalcIndex: fixedAmount ? 1 : 0, ...(fixedAmount ? { JdlSummary: true } : {}), GN: num(part.F_QQTY), GU: num(part.F_MPRICE), NU: num(part.F_LPRICE), JU: num(part.F_EPRICE), SU: 0, GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc, TU: num(part.F_MPRICE) + num(part.F_LPRICE) + num(part.F_EPRICE), }); } if ( incomplete || ["GC", "NC", "JC"].some((key, i) => Math.abs([gc, nc, jc][i] - included[key]) > 0.000001) ) { const gc = num(title.F_MPRICE) - included.GC, nc = num(title.F_LPRICE) - included.NC, jc = num(title.F_EPRICE) - included.JC; project.tables.HeadMainDat.push({ PkID: ++unitPk, Index: 0, CalcIndex: 1, JdlSummary: true, Name: "JDL 저장 금액 차액", GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc, }); unitWarnings.push(`일위 ${title.F_CODE}: 상세와 저장 합계의 차액을 보존합니다.`); } } if (rows["unitpart.txt"].some((r) => !unitCodes.has(r.F_CODE))) unitWarnings.push("부모 일위대가가 없는 상세 행은 원문에만 보존"); const headings = new Set(); const partsByGroup = new Map(); for (const part of rows["mainpart.txt"]) { if (!partsByGroup.has(part.F_CODE)) partsByGroup.set(part.F_CODE, []); partsByGroup.get(part.F_CODE).push(part); } let pk = 0; for (const group of rows["gongjong.txt"]) { if (headings.has(group.F_CODE)) throw Error("JDL 공종 코드 중복"); headings.add(group.F_CODE); project.tables.EstMainDat.push({ PkID: ++pk, Level: 1, Code: group.F_CODE, Name: group.F_DES, GC: group.F_MPRICE, NC: group.F_LPRICE, JC: group.F_EPRICE, SC: 0, TC: group.F_MPRICE + group.F_LPRICE + group.F_EPRICE, }); for (const r of partsByGroup.get(group.F_CODE) || []) { const source = byCode.get(r.F_CODE1); if (!source) throw Error("JDL 연결 자원 없음: " + r.F_CODE1); const row = { PkID: ++pk, Code: r.F_CODE1, Name: r.F_DES, Size: r.F_SIZE, Unite: r.F_UNIT, GN: r.F_QQTY, GU: r.F_MPRICE, NU: r.F_LPRICE, JU: r.F_EPRICE, SU: 0, LinkIndex: source.kind, CalcIndex: 0, }; const quantity = row.GN / (["%", "%"].includes(row.Unite) ? 100 : 1); row.GC = Math.trunc(quantity * row.GU); row.NC = Math.trunc(quantity * row.NU); row.JC = Math.trunc(quantity * row.JU); row.SC = 0; row.TC = row.GC + row.NC + row.JC; row.TU = row.GU + row.NU + row.JU; project.tables.EstMainDat.push(row); } } if (rows["mainpart.txt"].some((r) => !headings.has(r.F_CODE))) throw Error("JDL 부모 공종 없음"); const direct = project.tables.EstMainDat.filter((r) => !r.Level), sum = (k) => direct.reduce((a, r) => a + num(r[k]), 0); project.tables.EstMainDat.push({ PkID: ++pk, Name: "순공사비", LinkIndex: 54, GC: sum("GC"), NC: sum("NC"), JC: sum("JC"), SC: 0, TC: sum("TC"), }); if (files.has("costaccount.txt")) { const lines = decoder .decode(files.get("costaccount.txt")) .split(/\r?\n/) .filter((s) => s.trim()); for (const line of lines) { const f = line.replace(/\x05\s*$/, "").split("\t"); if (f.length < 5) throw Error("JDL 원가 필드 오류"); project.tables.Wonga.push({ Gubun: f[0].trim(), Name: f[1].trim(), Youl: f[2].trim(), VSusic: f[3].trim(), Cost: num(f[4]), }); } } project.originalJdl = bytes.toString("base64"); project.importWarnings = [ "JDL 저장 금액을 보존하기 위해 자동계산을 껐습니다. 복잡한 단가산출식·손료/기성 확장 파일은 원문에 보존되며 자동 변환하지 않습니다.", ...unitWarnings, ]; project.calculation = { errors: [], warnings: project.importWarnings, direct: project.tables.EstMainDat.at(-1), total: project.tables.Wonga.at(-1)?.Cost ?? sum("TC"), }; return project; } module.exports = { output, read, parse, markOriginal };