Files
Aislo/tools/est-test/legacy-estplus.cjs
T
eomsangdonandClaude Opus 5.5 708aeb5cc4 feat(est-test): 구버전 EST Plus(.est) 불러오기·저장·단가비교
- 394/565 바이트 레코드(Extended·ShortString) 읽기·쓰기, 표별 변환과 설계변경 c* 파일
- 호표 코드 매기기·품명규격으로 연결 코드 붙이기, 저장 금액 유지
- 단가비교 창(4종, 차례 짝, 합계 다름 표시, 더블클릭 이동), Ctrl+Alt+E · Ctrl+Shift+F12

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

593 lines
18 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 { cp949 } = require("./export-estx.cjs");
const num = (v) => Number(v) || 0;
const {
HEAD,
AMOUNTS,
BODY,
KIND,
LINK_IN,
LINK_OUT,
readX,
writeX,
decoder,
writeS,
records,
decode,
} = 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: 0,
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: 0,
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: 0,
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)))]);
add("dtm", "dtm", t.EstMainDat || []);
add("hdt", "hdt", 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 };