diff --git a/resources/tester/test_est_native_export.py b/resources/tester/test_est_native_export.py index ad6f1e1b8..50bcc84dc 100644 --- a/resources/tester/test_est_native_export.py +++ b/resources/tester/test_est_native_export.py @@ -8,6 +8,33 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exists(): + sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") + if not sample.exists(): + pytest.skip("installed EST sample unavailable") + script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'); + const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p); + p.name='길이가 달라진 새 공사명';p.options.AutoCalc=!p.options.AutoCalc; + p.options.CostValueTrunc=!p.options.CostValueTrunc;p.options.MidCostDiget=3; + const row=p.tables.EstMainDat.find(r=>Object.values(r).some(v=>v&&v.binary)); + const field=Object.keys(row).find(k=>row[k]&&row[k].binary); + const content=Buffer.alloc(12000, 65);row[field]={binary:content.toString('base64')}; + const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out); + const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k))); + const other=p.tables.EstMainDat.every((r,i)=>Object.keys(r).every(k=>JSON.stringify(r[k])===JSON.stringify(q.tables.EstMainDat[i][k]))); + const options=before.originalOptions.every(x=>{const y=q.originalOptions.find(z=>z.className===x.className); + return Object.entries(x.properties).every(([k,v])=>['ProjectName','CalcAuto','CostValueTrunc','MidCostDiget'].includes(k)||JSON.stringify(v)===JSON.stringify(y.properties[k]));}); + process.stdout.write(JSON.stringify({unchanged,other,options,name:q.name,auto:q.options.AutoCalc,cost:q.options.CostValueTrunc,digits:q.options.MidCostDiget,expected:p.options,content:Buffer.from(q.tables.EstMainDat.find(r=>r.PkID===row.PkID)[field].binary,'base64').equals(content)}));""" + result = json.loads( + subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8") + ) + assert result["unchanged"] and result["other"] and result["options"] and result["content"] + assert result["name"] == "길이가 달라진 새 공사명" + assert result["auto"] == result["expected"]["AutoCalc"] + assert result["cost"] == result["expected"]["CostValueTrunc"] + assert result["digits"] == 3 + + def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists(): sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") if not sample.exists(): diff --git a/tools/est-test/README.md b/tools/est-test/README.md index a8fd56985..623ba922a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -13,7 +13,7 @@ - **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다. - **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다. - **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 복잡한 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 원본 JDL도 보존합니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다. -- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 행 수와 인덱스 키를 유지하는 단가·수량·문구·금액 및 할당 공간 안의 BLOB/원가 서식을 기록하고 재읽기로 검산합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 공사명/계산 옵션 변경, 행 추가·삭제·인덱스 키 변경, BLOB 공간 확장, 수정한 WPTools 수량 문서와 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. +- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 수정한 WPTools 수량 문서와 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다. - **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다. - **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다. diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs index d1b21f378..4863b0bf3 100644 --- a/tools/est-test/export-estx.cjs +++ b/tools/est-test/export-estx.cjs @@ -1,6 +1,7 @@ const zlib = require("node:zlib"); const { importEstx, unzip, Values, memoryRows } = require("./import-estx.cjs"); const { pack } = require("./archive.cjs"); +const { updateOptions } = require("./export-options.cjs"); const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"); const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null); let characters; @@ -100,11 +101,23 @@ function updateBlob(file, start, id, bytes) { blocks.push(block); block = file.readUInt32LE(block * 512 + 4); } - if (bytes.length > blocks.length * 494) - throw Error( - "변경 BLOB이 원본 할당 공간을 초과합니다. 원본 공간 확장은 아직 지원하지 않습니다.", - ); if (file.readUInt32LE(start * 512 + 10) !== id) throw Error("BLOB ID 오류"); + const extra = Math.max(0, Math.ceil(bytes.length / 494) - blocks.length); + if (extra) { + const oldCount = file.length / 512; + if (!Number.isInteger(oldCount) || file.readUInt32LE(29) !== oldCount) + throw Error("BLOB 블록 수 헤더가 원본 형식과 다릅니다."); + if ((oldCount + extra) * 512 > 256 * 1024 * 1024) throw Error("BLOB 크기 한도 초과"); + file = Buffer.concat([file, Buffer.alloc(extra * 512)]); + for (let i = 0; i < extra; i++) { + const previous = blocks.at(-1), + number = oldCount + i; + file.writeUInt32LE(number, previous * 512 + 4); + file.writeUInt32LE(previous, number * 512); + blocks.push(number); + } + file.writeUInt32LE(oldCount + extra, 29); + } let at = 0; for (const [i, number] of blocks.entries()) { const offset = number * 512, @@ -115,6 +128,7 @@ function updateBlob(file, start, id, bytes) { at += take; if (i === 0) file.writeUInt32LE(bytes.length, offset + 14); } + return file; } function exportEstx(project) { if (!project.originalArchive) @@ -122,14 +136,31 @@ function exportEstx(project) { const original = Buffer.from(project.originalArchive, "base64"), base = importEstx(original), files = unzip(original); - if (project.name !== base.name) - throw Error("공사명 변경의 원본 옵션 저장 구조가 미확인입니다. JSON으로 보관하세요."); + const optionFields = { + GCToWonga: ["TWongaOption", "GCToWonga"], + GangubExceptJodalRatio: ["TWongaOption", "GangubExceptJodalRatio"], + EYunUp: ["TWongaOption", "EYunUp"], + VerFormat: ["TWongaOption", "VerFormat"], + TotalFix: ["TWongaOption", "TotalFix"], + AutoCalc: ["TEstOption", "CalcAuto"], + CostValueTrunc: ["TCostOption", "CostValueTrunc"], + MidCostDiget: ["TCostOption", "MidCostDiget"], + HeadDigits: ["THeadOption", "SumValueDiget"], + TotalFit: ["THeadOption", "CalcTrunc"], + DollarCostCut: ["TMachOption", "DollarCostCut"], + JDLCalcMethod: ["TCommonOption", "JDLCalcMethod"], + OldRecalcMethod: ["TChangeOption", "OldRecalcMethod"], + }; + const optionChanges = {}; + const setOption = (cls, name, data) => { + (optionChanges[cls] ||= {})[name] = data; + }; + if (project.name !== base.name) setOption("TCommonOption", "ProjectName", project.name); for (const key of Object.keys(base.options)) - if ( - !["SourceRefresh", "AutoCalc"].includes(key) && - !same(project.options[key], base.options[key]) - ) - throw Error(`계산 옵션 ${key} 변경은 ESTX에 저장할 수 없습니다.`); + if (key !== "SourceRefresh" && !same(project.options[key], base.options[key])) { + if (!optionFields[key]) throw Error(`계산 옵션 ${key} 저장 구조 미확인`); + setOption(...optionFields[key], project.options[key]); + } if (project.progress?.length || project.annual?.length || project.baseline) throw Error( "웹 기성/연차/당초 사본은 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.", @@ -137,6 +168,12 @@ function exportEstx(project) { if (!same(project.quantitySheets || [], base.quantitySheets || [])) throw Error("편집한 수량산출서의 원본 WPTools 저장은 아직 지원하지 않습니다."); let changed = 0; + if (Object.keys(optionChanges).length) { + const options = files.get("datestoption.opt"); + if (!options) throw Error("ESTX 원본 옵션 파일 없음"); + files.set("datestoption.opt", updateOptions(options, optionChanges, value)); + changed++; + } const oldByLower = new Map(Object.keys(base.tables).map((key) => [key.toLowerCase(), key])); for (const [filename, data] of files) { if (!filename.endsWith(".dat") || !data.subarray(8, 24).equals(signature)) continue; @@ -149,7 +186,7 @@ function exportEstx(project) { const stride = data.readUInt16LE(45), count = data.readUInt16LE(47), start = 512 + 768 * count; - const blobFile = files.get(filename.replace(/\.dat$/, ".blb")); + const blobName = filename.replace(/\.dat$/, ".blb"); for (let i = 0; i < rows.length; i++) for (let f = 0; f < count; f++) { const desc = 512 + 768 * f, @@ -185,12 +222,22 @@ function exportEstx(project) { } else if (kind === 4 && width === 2) { if (!Number.isInteger(next)) throw Error("ESTX 정수 오류"); data.writeInt16LE(next, at + 1); - } else if (kind === 3 && width === 8 && next?.binary && old?.binary && blobFile && data[at]) - updateBlob( - blobFile, - data.readUInt32LE(at + 1), - data.readUInt32LE(at + 5), - Buffer.from(next.binary, "base64"), + } else if ( + kind === 3 && + width === 8 && + next?.binary && + old?.binary && + files.has(blobName) && + data[at] + ) + files.set( + blobName, + updateBlob( + files.get(blobName), + data.readUInt32LE(at + 1), + data.readUInt32LE(at + 5), + Buffer.from(next.binary, "base64"), + ), ); else throw Error(`${key}.${name}: 미확인 필드 저장형 ${kind}`); data[at] = 1; @@ -215,17 +262,24 @@ function exportEstx(project) { if (field == null) throw Error("원가 BLOB 필드 없음"); const originalBlob = Object.values(base.tables.wongadat[0]).find((v) => v?.binary); const bytes = memory(Buffer.from(originalBlob.binary, "base64"), project.tables.Wonga); - updateBlob( - blb, - dat.readUInt32LE(start + field + 1), - dat.readUInt32LE(start + field + 5), - bytes, + files.set( + "wongadat.blb", + updateBlob( + blb, + dat.readUInt32LE(start + field + 1), + dat.readUInt32LE(start + field + 5), + bytes, + ), ); changed++; } if (!changed) return original; const result = pack(files), check = importEstx(result); + if (check.name !== project.name) throw Error("공사명 저장 후 재읽기 검산 실패"); + for (const key of Object.keys(base.options)) + if (key !== "SourceRefresh" && !same(check.options[key], project.options[key])) + throw Error(`${key}: 옵션 저장 후 재읽기 검산 실패`); for (const [key, rows] of Object.entries(base.tables)) { if (key === "Wonga" || key === "wongadat") continue; for (let i = 0; i < rows.length; i++) diff --git a/tools/est-test/export-options.cjs b/tools/est-test/export-options.cjs new file mode 100644 index 000000000..52dd06141 --- /dev/null +++ b/tools/est-test/export-options.cjs @@ -0,0 +1,57 @@ +const { Values } = require("./import-estx.cjs"); + +// Preserve every unedited property byte and the surrounding memory-table records. +function updateOptions(buffer, changes, encode) { + const pieces = []; + let cursor = 0, + offset = 0; + const remaining = new Map(Object.entries(changes)); + while ((offset = buffer.indexOf("TPF0", offset)) >= 0) { + const length = offset >= 4 ? buffer.readUInt32LE(offset - 4) : 0; + if (length < 6 || offset + length > buffer.length) { + offset += 4; + continue; + } + const reader = new Values(buffer.subarray(offset, offset + length)); + const className = reader.short(); + reader.short(); + const changesForClass = remaining.get(className); + if (!changesForClass) { + offset += length; + continue; + } + const edits = []; + while (reader.buffer[reader.position] !== 0) { + const name = reader.short(), + start = reader.position; + reader.value(); + if (Object.hasOwn(changesForClass, name)) { + edits.push([start, reader.position, encode(changesForClass[name])]); + delete changesForClass[name]; + } + } + const end = reader.position; + for (const [name, value] of Object.entries(changesForClass)) { + const key = Buffer.from(name, "ascii"); + edits.push([end, end, Buffer.concat([Buffer.from([key.length]), key, encode(value)])]); + } + const parts = []; + let position = 0; + for (const [start, stop, data] of edits) { + parts.push(reader.buffer.subarray(position, start), data); + position = stop; + } + parts.push(reader.buffer.subarray(position)); + const object = Buffer.concat(parts), + size = Buffer.alloc(4); + size.writeUInt32LE(object.length); + pieces.push(buffer.subarray(cursor, offset - 4), size, object); + cursor = offset + length; + offset = cursor; + remaining.delete(className); + } + if (remaining.size) throw Error(`ESTX 옵션 객체 없음: ${[...remaining.keys()].join(", ")}`); + pieces.push(buffer.subarray(cursor)); + return Buffer.concat(pieces); +} +module.exports = { updateOptions };