Files
Aislo/tools/est-test/legacy-estplus.cjs
T
eomsangdonandClaude Opus 5.5 ae3ff96983 fix(est-test): 구버전 EST Plus 저장 — cp949 밖 글자·기계경비 Index·% 수량, 경쟁사 실물 시험
- cp949 에 없는 글자는 「?」 로 바꿔 씀(22MB 공사 저장 중 멈춤)
- 기계경비 본문 Index(−3·−2·−1) 유지
- % 단위 수량은 소수로 써서 음수·큰 값도 되돌아옴
- 경쟁사 예제 estx 2건 저장값 대조·구버전 왕복 시험 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:35:13 +09:00

602 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 구버전 EST Plus 자료(.est/.ebk = ZIP, 표마다 Delphi 고정 레코드 파일 — tmp 역분석 spec_estplus.md) 읽기·쓰기.
// 레코드 394 바이트(기본) · 565 바이트(.ini [공통] data_version = 3). 문자 = ShortString(cp949), 금액 = Extended(10바이트).
// 읽기(mnuFileEstPlusLoad → 0x112ee48 · 표별 변환 0xd91504): .dtm 내역 · .hdt 일위 · .cct 단산 · .mdt 기계 · .ldt 노임 · .sdt 자재 ·
// .sil 표준 · .sti 견적(565 만) · .wdt/.swdt 원가 · c* = 설계변경(「_」 칸). 원본처럼 대부분 행의 코드를 비우므로, 웹은 호표 머리에
// 코드를 매기고 본문 연결 행은 같은 품명·규격의 원천 코드를 찾아 붙인다(웹 보충). 메모(.MEM)·.mot(기계 기준)는 읽지 않는다.
// 쓰기(ActionConvertToEstPlus → 0xd96354/0xd960b8): 394 판으로 위 표 파일 + .ini 를 ZIP 으로.
// 단가비교(TfrmCompare 0xf8998c): 고른 종류 파일의 Index > 0 레코드 노무·재료·경비 금액을 차례로.
const { pack, unpack } = require("./archive.cjs");
const num = (v) => Number(v) || 0;
const {
HEAD,
AMOUNTS,
BODY,
KIND,
LINK_IN,
LINK_OUT,
readX,
writeX,
decoder,
writeS,
records,
decode,
cp949,
} = require("./legacy-estplus-layout.cjs");
const link = (v) => LINK_IN[v] ?? v;
const percent = (r, li, skip) =>
["%", "%"].includes(r.Unite) && !skip.includes(li) && r.GN < 1 ? r.GN * 100 : r.GN;
const clean = (t) =>
String(t ?? "")
.replace(/\n/g, "")
.trim();
const pad = (p, n) => p + String(n).padStart(10, "0");
// 연결 코드 줄이기(0xd86924): A 로 시작하면 끝의 0 을 1자 남을 때까지
const trimA = (code) => (/^A/.test(code) ? code.replace(/0+$/, "") || "A" : code);
function convert(files, name) {
const warnings = new Set();
const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1];
const iniText = ini ? decoder.decode(ini) : "";
const iniValue = (key) =>
new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "mi").exec(iniText)?.[1]?.trim();
const size = Number(iniValue("data_version")) === 3 ? 565 : 394;
const file = (ext) => [...files].find(([k]) => k.endsWith("." + ext))?.[1];
const parse = (ext, type = ext) => {
const buf = file(ext);
if (!buf) return [];
if (buf.length % size) warnings.add(`${ext}: 레코드 크기(${size})로 나누어지지 않습니다.`);
return records(buf, size).map((r) => decode(r, size, type));
};
const t = {};
const projectName = iniValue("project_title_name") || name;
// 내역서
const est = (rows) =>
rows.map((r) => {
let li = link(r.link),
calc = 0;
if (r.link === 64) ((li = 0), (calc = 1));
const row = {
Index: li === 57 && r.check ? r.check : r.Index,
LinkIndex: li,
Code: r.link === 21 ? r.code : "",
Name: r.link === 56 ? projectName : clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
Bigo: r.Bigo,
Level: r.Level,
Pr: r.Pr,
...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])),
SKind: r.SKind,
OCheck: r.OCheck,
SCheck: r.check > 0 ? 3 : r.SCheck,
Gangub: r.Gangub,
Biul: r.Biul,
CalcIndex: calc,
NewItem: r.NewItem,
PageSkip: r.PageSkip,
MullangIndex: r.MullangIndex,
ExcelSheetIndex: r.ExcelSheetIndex,
};
row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]);
if ([51, 62].includes(li) && row.Code) row.Code = trimA(row.Code);
return row;
});
t.EstMainDat = est(parse("dtm"));
// 일위대가
const heads = parse("hdt");
t.HeadMainDat = heads.map((r) => {
const li = r.link === 64 ? 0 : link(r.link);
const row = {
Index: r.Index,
LinkIndex: li,
Code: r.Index > 0 ? pad("U", r.Index) : [21, 62].includes(r.link) ? r.code : "",
Name: clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
Bigo: r.Bigo,
Level: r.Level,
...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])),
SKind: r.SKind,
OCheck: r.OCheck,
SCheck: r.check > 0 ? 3 : r.SCheck,
Gangub: r.Gangub,
Biul: r.Biul,
CalcIndex: r.link === 64 ? 1 : 0,
MullangIndex: r.MullangIndex,
ExcelSheetIndex: r.ExcelSheetIndex,
};
row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]);
if ([51, 52, 58, 62].includes(li) && row.Code && r.Index <= 0) row.Code = trimA(row.Code);
return row;
});
t.HeadTitleDat = heads
.filter((r) => r.Index > 0)
.map((r) => ({
Index: r.Index,
Code: pad("U", r.Index),
Name: clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
TC: r.TC,
NC: r.NC,
GC: r.GC,
JC: r.JC,
OTC: r.TC,
ONC: r.NC,
OGC: r.GC,
OJC: r.JC,
Bigo: r.Bigo,
NewItem: r.NewItem,
UserInput: r.link === 64,
}));
// 단가산출: 한 줄 = Name+Size+Bigo+Unite 이어 붙임 · 머리는 꼬리표(#14 품명 · #19 규격 · #21 단위)로 나눔
const costs = parse("cct");
const tag = (line, c) => {
const at = line.indexOf(c);
return at < 0
? ""
: line
.slice(at + 1)
.split(/[\x00-\x1f]/)[0]
.trim();
};
t.CostMainDat = costs.map((r) => {
const line = r.Name + r.Size + r.Bigo + r.Unite;
let index = r.Index === -4 ? -5 : r.Index,
ocheck = r.OCheck,
scheck = r.SCheck;
if (index < 0 && index >= -5) {
scheck = index;
ocheck = index;
index = 0;
}
return {
Index: index,
LinkIndex: r.link === 64 ? 0 : link(r.link),
Code: r.Index > 0 ? pad("D", r.Index) : r.link === 21 ? r.code : "",
...(r.Index > 0
? {
Name: tag(line, "\x0e") || clean(r.Name),
Size: tag(line, "\x13"),
Unite: tag(line, "\x15"),
}
: { LineData: line }),
Level: r.Level,
Diget: r.Diget,
DigetIndex: r.DigetIndex,
TC: r.TC,
NC: r.NC,
GC: r.GC,
JC: r.JC,
SKind: r.SKind,
OCheck: ocheck,
SCheck: scheck,
Biul: r.Biul,
CalcIndex: r.link === 64 ? 1 : 0,
};
});
t.CostTitleDat = t.CostMainDat.filter((r) => r.Index > 0).map((r) => ({
Index: r.Index,
Code: r.Code,
Name: r.Name,
Size: r.Size,
Unite: r.Unite,
TC: r.TC,
NC: r.NC,
GC: r.GC,
JC: r.JC,
OTC: r.TC,
ONC: r.NC,
OGC: r.GC,
OJC: r.JC,
}));
// 기계경비: 머리 X220 TC · 210 NC · 200 GC · 190 JC, 본문 −3 손료 · −2 연료 · −1 노무
t.MachMainDat = [];
t.MachTitleDat = [];
for (const r of parse("mdt")) {
if (!r.code && !clean(r.Name) && !r.X220) continue;
const base = {
LinkIndex: link(r.link),
Code: "",
Name: clean(r.Name),
CalcIndex: r.link === 64 ? 1 : 0,
};
if (r.Index > 0) {
if (t.MachTitleDat.length) t.MachMainDat.push({ Name: " " });
const head = {
...base,
Index: r.Index,
Code: pad("E", r.Index),
Size: clean(r.Size),
Unite: "hr",
TC: r.X220,
NC: r.X210,
GC: r.X200,
JC: r.X190,
};
t.MachMainDat.push({ ...head, Selected: 0 });
t.MachTitleDat.push({ ...head, UserInput: r.link === 64 });
} else if (r.Index === -3)
t.MachMainDat.push({
...base,
Index: r.Index,
Selected: 1,
Size: "",
GN: r.X200,
Ratio: r.X210,
TU: r.X190,
JC: r.X220,
Unite: clean(r.Size).replace(/\\/g, "₩"),
});
else if (r.Index === -2)
t.MachMainDat.push({
...base,
Index: r.Index,
Selected: 6,
Size: clean(r.Size),
GN: r.X190,
TU: r.X200,
GC: r.X220,
Unite: /^(경|휘|중)/.test(clean(r.Unite))
? "ℓ"
: /잡/.test(base.Name)
? "%"
: clean(r.Unite),
});
else if (r.Index === -1)
t.MachMainDat.push({
...base,
Index: r.Index,
Selected: 11,
Size: clean(r.Size),
GN: r.X190,
TU: r.X200,
NC: r.X220,
NCGasu: Number(clean(r.Size).replace(/[^\d.]/g, "")) || 1,
Unite: clean(r.Unite) || "인",
});
}
// 본문 행 TC := NC + GC + JC
for (const r of t.MachMainDat)
if ([1, 6, 11].includes(r.Selected)) {
for (const f of ["NC", "GC", "JC"]) r[f] = num(r[f]);
r.TC = r.NC + r.GC + r.JC;
}
// 기초단가
const skip = (r) => !r.code && !clean(r.Name);
t.LaborcDat = parse("ldt")
.filter((r) => !skip(r))
.map((r, i) => ({
Index: i + 1,
Code: pad("L", i + 1),
Name: clean(r.Name),
Size: clean(r.Size),
Unite: "인",
Cost1: r.TC,
TC: r.TC,
OTC: r.TC,
ValueIndex: 0,
Gangub: 0,
Bigo: r.Bigo,
}));
t.StuffcDat = parse("sdt")
.filter((r) => !skip(r))
.map((r, i) => ({
Index: i + 1,
Code: pad("M", i + 1),
Name: clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
...Object.fromEntries(
[1, 2, 3, 4, 5, 6].flatMap((n) => [
[`Cost${n}`, r[`Cost${n}`]],
[`Page${n}`, r[`Page${n}`]],
]),
),
TC: r.TC,
OTC: r.TC,
Bigo: r.Bigo,
}));
t.SilJukDat = parse("sil")
.filter((r) => !skip(r))
.map((r, i) => {
const only = !r.TC;
const v = only
? { TC: r.JC, NC: 0, GC: 0, JC: r.JC }
: { TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC };
return {
Index: i + 1,
Code: r.code,
Name: clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
Cost1: v.TC,
...v,
OTC: v.TC,
ONC: v.NC,
OGC: v.GC,
OJC: v.JC,
NCRatio: r.NCRatio,
};
});
t.EstiMateDat =
size === 565
? parse("sti")
.filter((r) => !skip(r))
.map((r, i) => ({
Index: i + 1,
Code: r.code,
Name: clean(r.Name),
Size: clean(r.Size),
Unite: clean(r.Unite),
GN: r.GN,
TC: r.TC,
NC: r.NC,
GC: r.GC,
JC: r.JC,
OTC: r.TC,
ONC: r.NC,
OGC: r.GC,
OJC: r.JC,
Ratio: r.Ratio,
NachalRatio: r.NachalRatio,
Diget: r.Diget,
DigetIndex: r.DigetIndex,
}))
: [];
const wonga = file("wdt") ? parse("wdt") : parse("swdt", "wdt");
t.Wonga = wonga.map((r) => ({
Index: r.Index,
LinkIndex: r.link,
Name: clean(r.Name),
VSusic: clean(r.Size),
VGungu: clean(r.Size),
Biul: clean(r.Unite),
Youl: clean(r.Unite),
Checked: r.Checked,
Cost: r.Cost,
Diget: Math.round(r.Diget),
EndLineIndex: r.EndLineIndex,
Bimog1: r.Bimog1,
Bimog2: r.Bimog2,
Gubun: r.Gubun,
Pr: "",
GabgiInput: false,
CalcIndex: r.link === 64 ? 1 : 0,
}));
// 설계변경(c*) → 같은 표의 「_」 칸, 차례대로
const change = {
cdtm: ["EstMainDat", "dtm"],
chdt: ["HeadMainDat", "hdt"],
ccct: ["CostMainDat", "cct"],
};
let changed = false;
for (const [ext, [table, type]] of Object.entries(change)) {
const rows = parse(ext, type);
if (!rows.length) continue;
changed = true;
rows.forEach((r, i) => {
const target = (t[table][i] ||= {});
target.Index_ = r.Index;
for (const f of ["Name", "Size", "Unite"]) target[f + "_"] = clean(r[f]);
for (const f of AMOUNTS) if (r[f] != null) target[f + "_"] = r[f];
});
}
// 본문 연결 행: 같은 품명·규격의 원천 코드(웹 보충)
const source = {
5: t.StuffcDat,
6: t.LaborcDat,
21: t.SilJukDat,
101: t.EstiMateDat,
103: t.HeadTitleDat,
104: t.CostTitleDat,
105: t.MachTitleDat,
};
const key = (r) => `${clean(r.Name).replace(/\s/g, "")}|${clean(r.Size).replace(/\s/g, "")}`;
const maps = Object.fromEntries(
Object.entries(source).map(([k, rows]) => [k, new Map(rows.map((r) => [key(r), r.Code]))]),
);
let linked = 0,
missing = 0;
for (const rows of [t.EstMainDat, t.HeadMainDat])
for (const r of rows)
if (!(num(r.Index) > 0) && maps[r.LinkIndex] && !r.Code) {
const code = maps[r.LinkIndex].get(key(r));
if (code) ((r.Code = code), linked++);
else missing++;
}
if (missing)
warnings.add(
`연결 원천을 품명·규격으로 찾지 못한 행 ${missing}개(코드 없음 — 저장 금액 유지).`,
);
if (files.has(".mem") || [...files.keys()].some((k) => k.endsWith(".mem")))
warnings.add("근거 메모(.MEM)는 읽지 않았습니다.");
if (file("mot")) warnings.add("기계경비 기준표(.mot)는 읽지 않았습니다.");
const mode = Number(iniValue("last_work_mode")) || 1;
const standard = /^(1|true)$/i.test(iniValue("siljuk_est_mode") || "");
for (const k of ["MachGijunDat", "CadMainDat", "CadTitleDat"]) t[k] ||= [];
return {
schema: "est-web-2",
name: projectName,
tables: t,
options: {
SourceRefresh: false,
AutoCalc: false,
TotalFix: false,
VerFormat: true,
WorkMode: standard ? (mode === 2 ? 4 : 3) : mode,
...(changed && { Mode: "설계변경" }),
},
quantities: [],
quantitySheets: [],
progress: [],
annual: [],
schedule: [],
drawings: [],
importWarnings: [
`구버전 EST Plus 자료(레코드 ${size}바이트)를 변환했습니다. 저장된 금액을 그대로 두려고 자동계산을 껐습니다. 품명·규격으로 연결 ${linked}개.`,
...warnings,
],
};
}
const lower = (files) => new Map([...files].map(([k, v]) => [k.toLowerCase(), v]));
function read(buffer, name = "EST Plus 자료") {
return convert(lower(unpack(buffer)), name);
}
// 쓰기(394 판)
function record(type, row) {
const rec = Buffer.alloc(394),
h = HEAD[394];
rec.writeInt32LE(-1, 0);
rec.writeInt32LE(Math.trunc(num(row.Index)), 5);
rec[9] = row.UserInput ? 64 : (LINK_OUT[num(row.LinkIndex)] ?? num(row.LinkIndex)) & 0xff;
for (const [k, f] of [
["code", "Code"],
["name", "Name"],
["size", "Size"],
["unite", "Unite"],
["bigo", "Bigo"],
])
writeS(rec, h[k][0], h[k][1], row[f]);
rec.writeInt32LE(-1, h.memo1);
rec.writeInt32LE(Math.trunc(num(row.MullangIndex)), h.mul);
rec.writeInt32LE(-1, h.memo2);
rec[h.page] = num(row.PageSkip) & 0xff;
rec[171] = 1;
rec[h.checked] = row.Checked ? 1 : 0;
rec[h.newItem] = row.NewItem ? 1 : 0;
rec[h.sheet] = num(row.ExcelSheetIndex) & 0xff;
for (const at of [177, 181, 185]) rec.writeInt32LE(-1, at);
rec[h.kind] = KIND[type];
for (const [f, t, at, , n] of BODY[type === "swdt" ? "wdt" : type] || []) {
if (!at) continue;
const v = row[f];
if (t === "X") writeX(rec, at, v);
else if (t === "L") rec.writeInt32LE(Math.trunc(num(v)), at);
else if (t === "b") rec.writeInt8(Math.max(-128, Math.min(127, Math.trunc(num(v)))), at);
else if (t === "B") rec[at] = num(v) & 0xff;
else writeS(rec, at, n, v);
}
return rec;
}
// 단가산출 글줄을 Name(50)·Size(50)·Bigo(20)·Unite(8) 칸에 차례로 나눠 담음(읽을 때 이어 붙이면 같은 줄)
function splitLine(text) {
const parts = ["", "", "", ""],
caps = [50, 50, 20, 8];
let k = 0,
used = 0;
for (const ch of String(text ?? "")) {
const n = cp949(ch).length;
while (k < 4 && used + n > caps[k]) ((k += 1), (used = 0));
if (k >= 4) break;
parts[k] += ch;
used += n;
}
return { Name: parts[0], Size: parts[1], Bigo: parts[2], Unite: parts[3] };
}
function write(project) {
const t = project.tables,
out = [];
const add = (ext, type, rows) =>
rows.length && out.push([`data.${ext}`, Buffer.concat(rows.map((r) => record(type, r)))]);
// % 단위 수량은 소수로 씀(읽을 때 1 미만이면 ×100 — 음수·100 넘는 값도 되돌아오게)
const pct = (rows) =>
rows.map((r) =>
["%", "%"].includes(String(r.Unite ?? "").trim()) &&
![51, 52, 57, 58, 62, 64].includes(num(r.LinkIndex)) &&
!(num(r.Index) > 0)
? { ...r, GN: num(r.GN) / 100 }
: r,
);
add("dtm", "dtm", pct(t.EstMainDat || []));
add("hdt", "hdt", pct(t.HeadMainDat || []));
add(
"cct",
"cct",
(t.CostMainDat || []).map((r) => ({
...r,
...splitLine(
num(r.Index) > 0 && !r.LineData
? `\x0e${r.Name ?? ""}\x0e\x13${r.Size ?? ""}\x13\x15${r.Unite ?? ""}\x15`
: r.LineData,
),
Index:
num(r.Index) > 0
? r.Index
: num(r.SCheck) < 0
? num(r.SCheck) === -5
? -4
: r.SCheck
: r.Index,
})),
);
add(
"mdt",
"mdt",
(t.MachMainDat || [])
.filter((r) => String(r.Name ?? "").trim() || num(r.Index) > 0)
.map((r) => {
const s = num(r.Selected);
if (num(r.Index) > 0) return { ...r, X220: r.TC, X210: r.NC, X200: r.GC, X190: r.JC };
if (s === 1)
return {
...r,
Index: -3,
Size: r.Unite,
X200: r.GN,
X210: r.Ratio,
X190: r.TU,
X220: r.JC,
};
if (s === 6) return { ...r, Index: -2, X190: r.GN, X200: r.TU, X220: r.GC };
return { ...r, Index: -1, X190: r.GN, X200: r.TU, X220: r.NC };
}),
);
add("ldt", "ldt", t.LaborcDat || []);
add("sdt", "sdt", t.StuffcDat || []);
add("sil", "sil", t.SilJukDat || []);
add(
"wdt",
"wdt",
(t.Wonga || []).map((r) => ({
...r,
Size: r.VSusic,
Unite: r.Youl ?? r.Biul,
Bigo: String(Math.trunc(num(r.Cost))),
})),
);
const mode = num(project.options?.WorkMode) || 1;
out.push([
"data.ini",
cp949(
`[공통]\r\nlast_work_mode=${mode > 2 ? mode - 2 : mode}\r\nsiljuk_est_mode=${mode > 2 ? 1 : 0}\r\ndata_version=0\r\nproject_title_name=${project.name ?? ""}\r\n`,
),
]);
return pack(new Map(out));
}
// 단가비교: 종류(0 기계 · 1 단산 · 2 일위 · 3 내역) 파일의 Index > 0 레코드 노무·재료·경비
const COMPARE = [
["mdt", 210, 200, 190],
["cct", 203, 213, 223],
["hdt", 231, 251, 271],
["dtm", 258, 278, 298],
];
function compare(buffer, kind) {
const files = lower(unpack(buffer)),
[ext, n, g, j] = COMPARE[kind] || [];
if (!ext) throw Error("비교할 항목을 선택하세요.");
const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1];
if (ini && /data_version\s*=\s*3/i.test(decoder.decode(ini)))
throw Error("565 바이트 판 자료의 단가비교 자리는 확인되지 않았습니다.");
const buf = [...files].find(([k]) => k.endsWith("." + ext))?.[1];
if (!buf) throw Error(`비교 파일(.${ext})이 없습니다.`);
return records(buf, 394)
.filter((r) => r.readInt32LE(5) > 0)
.map((r) => ({ ONU: readX(r, n), OGU: readX(r, g), OJU: readX(r, j) }));
}
module.exports = { read, write, compare, readX, writeX, splitLine };