feat(est): save native options and expand blobs

This commit is contained in:
2026-11-06 17:48:44 +09:00
parent a5157dd3a9
commit bd702cf948
4 changed files with 162 additions and 24 deletions
@@ -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():
+1 -1
View File
@@ -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 실행 검증은 못했습니다.
- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
- **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다.
+77 -23
View File
@@ -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++)
+57
View File
@@ -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 };