feat(est): save native options and expand blobs
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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 실행 검증은 못했습니다.
|
||||
- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
|
||||
- **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다.
|
||||
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user