From 708aeb5cc49c47441b7779a81908414c56b630b4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 20:04:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EA=B5=AC=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20EST=20Plus(.est)=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=C2=B7=EC=A0=80=EC=9E=A5=C2=B7=EB=8B=A8=EA=B0=80=EB=B9=84?= =?UTF-8?q?=EA=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 394/565 바이트 레코드(Extended·ShortString) 읽기·쓰기, 표별 변환과 설계변경 c* 파일 - 호표 코드 매기기·품명규격으로 연결 코드 붙이기, 저장 금액 유지 - 단가비교 창(4종, 차례 짝, 합계 다름 표시, 더블클릭 이동), Ctrl+Alt+E · Ctrl+Shift+F12 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_estplus.py | 53 ++ tools/est-test/PLAN.md | 3 + tools/est-test/index.html | 4 + tools/est-test/legacy-estplus-layout.cjs | 222 +++++++++ tools/est-test/legacy-estplus.cjs | 592 +++++++++++++++++++++++ tools/est-test/native-actions-more.js | 1 + tools/est-test/native-estplus.js | 143 ++++++ tools/est-test/server.cjs | 17 + 9 files changed, 1036 insertions(+) create mode 100644 resources/tester/test_est_estplus.py create mode 100644 tools/est-test/legacy-estplus-layout.cjs create mode 100644 tools/est-test/legacy-estplus.cjs create mode 100644 tools/est-test/native-estplus.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 3d1339ebf..3d613a44a 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -42,6 +42,7 @@ for (const file of [ "native-dongwon-check.js", "native-excel-files.js", "native-option-all.js", + "native-estplus.js", "native-business-actions.js", "native-forms.js", "native-excel-to-est.js", diff --git a/resources/tester/test_est_estplus.py b/resources/tester/test_est_estplus.py new file mode 100644 index 000000000..bceea96bd --- /dev/null +++ b/resources/tester/test_est_estplus.py @@ -0,0 +1,53 @@ +"""구버전 EST Plus(.est): 394 바이트 레코드 쓰기→읽기 왕복, 단가비교 자리, 짝짓기.""" + +import json +import subprocess +from pathlib import Path + +from test_est_rea_parity import run + +ROOT = Path(__file__).resolve().parents[2] +EST = ROOT / "tools/est-test" + + +def node(code): + out = subprocess.check_output( + ["node", "-e", code], cwd=EST, text=True, encoding="utf-8" + ) + return json.loads(out) + + +def test_extended_and_round_trip_of_sample(): + result = node( + """ +const L=require('./legacy-estplus.cjs'),s=require('./sample.json'); +const b=Buffer.alloc(10),xs=[0,1,-2.5,1234567.891,0.001,1e15].map(v=>(L.writeX(b,0,v),L.readX(b,0))); +const buf=L.write(s),p=L.read(buf,'t'); +const same=(t,keys)=>s.tables[t].every((r,i)=>keys.every(k=>Math.abs((Number(r[k])||0)-(Number(p.tables[t][i][k])||0))<1e-6)); +console.log(JSON.stringify({xs, + est:same('EstMainDat',['GN','TU','TC','NC','GC','JC','Level']), + head:same('HeadMainDat',['GN','TC','NC','GC','JC']), + cost:s.tables.CostMainDat.every((r,i)=>r.Index>0||(r.LineData||'')===(p.tables.CostMainDat[i].LineData||'')), + title:p.tables.CostTitleDat[0].Name,labor:p.tables.LaborcDat.length, + linked:p.tables.EstMainDat.find(r=>r.Name==='터파기').Code,auto:p.options.AutoCalc, + cmp:L.compare(buf,2)[0]})); +""") + assert result["xs"] == [0, 1, -2.5, 1234567.891, 0.001, 1e15] + assert result["est"] and result["head"] and result["cost"] + assert result["title"] == "터파기" + assert result["labor"] == 9 + assert result["linked"] == "D0000000001" + assert result["auto"] is False + assert result["cmp"] == {"ONU": 66841, "OGU": 1336, "OJU": 0} + + +def test_pair_flags_sum_mismatch_in_order(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.tables.HeadMainDat=[{Index:1,Code:'U1',Name:'a',NC:10,GC:5},{Index:0}, + {Index:2,Code:'U2',Name:'b',NC:3}]; + return EstEstPlus.pair(p,2,[{ONU:10,OGU:5,OJU:0},{ONU:4,OGU:0,OJU:0}]).map(r=>[r.Code,r.UsesCheck]); + })()""", + ) + assert result == [["U1", False], ["U2", True]] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 95815d716..16d60431d 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -512,6 +512,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 검증완료: 파일목록 시험 1개·수량산출 시험 6개 통과. ORCA 브라우저에서 목록 등록 → 목록 파일로 B2 셀 연결(수량 12.5)·연결 수 1 → 삭제 확인, 원복. - [x] 환경설정 전체 창(TfrmOption btnLoad · spec_pb2 §9) — 탭 다섯(공통적용·내역서·일위단산기계·표준자재원가·설계변경수량심사) 92칸. 웹 계산·표시에 이어진 칸은 그 옵션에(일위 자리수·단산 금액란·중간 자리수·외화 절하·재료비 처리·이윤 사사오입·관급 조달수수료·원가 방향·자동계산·재계산 범위·정산금액·비목순서·정렬·두줄·Enter·코드 열·0 표시·바탕색·레벨·발췌근거·집계 진하게), 나머지는 값만(Env), PC 설정은 이 브라우저. 바꾼 칸만 씀. 적용 때 원본 알림(자동계산 끔/켬·표준단가비목·조달청 계산방식·산식 표시·공종+품명 합치기)과 순공사비 명칭 바꾸면 순공사비 행 이름도 바꿈. 기성 화면에서는 기성 환경설정. 순공사비 상단/하단 이동·제잡비 행 넣기는 값만(웹 계산이 순공사비 위치를 아래로 가정). - 검증완료: 환경설정 시험 1개·표시·기성 시험 4개 통과. ORCA 브라우저에서 탭 5·칸 92, 코드표시 켬 → 자재 표에 코드 열, 순공사비 명칭 → 행 이름 바뀜, 원복. + - [x] 구버전 EST Plus 자료 불러오기·저장·단가비교(spec_misc §2 · 원본 역분석 spec_estplus — 레코드 394/565 바이트, Extended·ShortString) — 불러오기: .est/.ebk 를 풀어 내역·일위·단산·기계·노임·자재·표준·견적(565)·원가와 설계변경 c* 를 표로, 저장 금액 유지(자동계산 끔), 호표 머리에 코드를 매기고 본문 연결 행은 같은 품명·규격 원천 코드를 붙임(웹 보충). 저장(Ctrl+Shift+F12): 394 판 .est. 단가비교(Ctrl+Alt+E): 기계경비·단가산출·일위대가·내역서 중 골라 구형 파일 머리 레코드와 차례 짝, 합계 다르면 표시, 더블클릭 이동. 메모(.MEM)·기계 기준표(.mot)는 읽지 않음. + - 미결: 실제 구버전 .est 표본이 없어 웹이 만든 파일로만 왕복 확인 — 원본 파일 대조 필요(문자 코드 cp949 가정). + - 검증완료: 구버전 시험 2개 통과(Extended 왕복, 샘플 공사 쓰기→읽기 금액 일치, 단가산출 글줄·머리 꼬리표, 품명·규격 연결, 단가비교 자리). ORCA 브라우저에서 .est 저장 17,864 바이트 → 다시 불러오기 76행, 단가비교 창·일위 8건 일치, 원복. - [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구. - [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳. - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 828186127..a409d558b 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -30,6 +30,7 @@ + @@ -129,6 +130,9 @@ + + + diff --git a/tools/est-test/legacy-estplus-layout.cjs b/tools/est-test/legacy-estplus-layout.cjs new file mode 100644 index 000000000..c9cdaebff --- /dev/null +++ b/tools/est-test/legacy-estplus-layout.cjs @@ -0,0 +1,222 @@ +// 구버전 EST Plus 레코드 형식(spec_estplus.md): 머리·표별 가변부 자리, Extended·ShortString 읽기·쓰기, 레코드 풀기. +const { cp949 } = require("./export-estx.cjs"); + +const HEAD = { + 394: { + code: [10, 11], + name: [22, 50], + size: [73, 50], + unite: [124, 8], + bigo: [133, 20], + memo1: 154, + mul: 162, + memo2: 166, + page: 170, + checked: 172, + newItem: 173, + check: 174, + sheet: 175, + kind: 189, + }, + 565: { + code: [10, 17], + name: [28, 50], + size: [79, 100], + unite: [180, 10], + bigo: [191, 50], + memo1: 242, + mul: 250, + memo2: 254, + page: 258, + checked: 260, + newItem: 261, + check: 262, + sheet: 263, + kind: 287, + }, +}; +const AMOUNTS = ["GN", "TU", "TC", "NU", "NC", "GU", "GC", "JU", "JC"]; +// 표별 가변부: [필드, 형, 394 자리, 565 자리] — X 실수 · L 정수 · b 부호바이트 · B 바이트 · S 문자(길이 394/565) +const BODY = { + dtm: [ + ["Level", "b", 190, 288], + ["Pr", "S", 213, 311, 4, 24], + ...AMOUNTS.map((f, i) => [f, "X", 218 + 10 * i, 336 + 10 * i]), + ["SKind", "L", 368, 486], + ["OCheck", "L", 372, 490], + ["SCheck", "L", 376, 494], + ["Gangub", "L", 380, 498], + ["Biul", "X", 384, 508], + ], + hdt: [ + ["Level", "b", 190, 310], + ...AMOUNTS.map((f, i) => [f, "X", 191 + 10 * i, 311 + 10 * i]), + ["SKind", "L", 281, 441], + ["OCheck", "L", 285, 445], + ["SCheck", "L", 289, 449], + ["Gangub", "L", 293, 453], + ["Biul", "X", 297, 463], + ], + cct: [ + ["Level", "b", 190, 310], + ["Diget", "b", 191, 311], + ["DigetIndex", "b", 192, 312], + ["TC", "X", 193, 313], + ["NC", "X", 203, 323], + ["GC", "X", 213, 333], + ["JC", "X", 223, 343], + ["SKind", "L", 234, 364], + ["OCheck", "L", 238, 368], + ["SCheck", "L", 242, 372], + ["Biul", "X", 250, 380], + ], + mdt: [ + ["X190", "X", 190, 288], + ["X200", "X", 200, 298], + ["X210", "X", 210, 308], + ["X220", "X", 220, 318], + ], + ldt: [["TC", "X", 230, 328]], + sdt: [ + ...[1, 2, 3, 4, 5, 6].map((n) => [`Cost${n}`, "X", 180 + 10 * n, 278 + 10 * n]), + ["TC", "X", 250, 348], + ...[1, 2, 3, 4, 5, 6].map((n) => [`Page${n}`, "S", 270 + 11 * n, 386 + 25 * n, 10, 24]), + ], + sil: [ + ["TC", "X", 211, 288], + ["NC", "X", 231, 298], + ["GC", "X", 251, 308], + ["JC", "X", 271, 318], + ["NCRatio", "X", 297, 368], + ], + sti: [ + ["GN", "X", 0, 288], + ["TC", "X", 0, 298], + ["NC", "X", 0, 308], + ["GC", "X", 0, 318], + ["JC", "X", 0, 328], + ["Ratio", "X", 0, 378], + ["NachalRatio", "X", 0, 388], + ["Diget", "B", 0, 398], + ["DigetIndex", "B", 0, 399], + ], + wdt: [ + ["Cost", "X", 190, 288], + ["Diget", "X", 250, 348], + ["EndLineIndex", "B", 280, 408], + ["Bimog1", "S", 281, 411, 10, 24], + ["Bimog2", "S", 292, 436, 10, 24], + ["Gubun", "S", 303, 461, 10, 24], + ], +}; +const KIND = { + dtm: 1, + hdt: 2, + cct: 3, + mdt: 4, + sdt: 5, + ldt: 6, + wdt: 7, + sil: 21, + swdt: 22, + sti: 101, +}; +const LINK_IN = { 2: 103, 3: 104, 4: 105, 20: 220, 51: 68 }; +for (let i = 11; i <= 18; i++) LINK_IN[i] = 200 + i; +const LINK_OUT = Object.fromEntries(Object.entries(LINK_IN).map(([a, b]) => [b, Number(a)])); + +// Extended(80비트) ↔ 수 +function readX(buf, at) { + const lo = buf.readUInt32LE(at), + hi = buf.readUInt32LE(at + 4), + se = buf.readUInt16LE(at + 8), + exp = se & 0x7fff; + if (!exp && !hi && !lo) return 0; + const v = (hi * 2 ** 32 + lo) * 2 ** (exp - 16383 - 63); + return se & 0x8000 ? -v : v; +} +function writeX(buf, at, value) { + let v = Number(value) || 0; + if (!v || !Number.isFinite(v)) return buf.fill(0, at, at + 10); + const sign = v < 0 ? 0x8000 : 0; + v = Math.abs(v); + let exp = Math.floor(Math.log2(v)); + if (2 ** exp > v) exp--; + if (2 ** (exp + 1) <= v) exp++; + const mant = BigInt(Math.round(v * 2 ** (63 - exp))); // 53 비트 정밀도 안 + buf.writeUInt32LE(Number(mant & 0xffffffffn), at); + buf.writeUInt32LE(Number((mant >> 32n) & 0xffffffffn), at + 4); + buf.writeUInt16LE(sign | (exp + 16383), at + 8); +} +const decoder = new TextDecoder("euc-kr"); +const readS = (buf, at, max) => + decoder.decode(buf.subarray(at + 1, at + 1 + Math.min(buf[at], max))); +function writeS(buf, at, max, text) { + const out = []; + let used = 0; + for (const ch of String(text ?? "")) { + const bytes = cp949(ch); + if (used + bytes.length > max) break; + out.push(bytes); + used += bytes.length; + } + buf[at] = used; + Buffer.concat(out).copy(buf, at + 1); +} + +function records(buf, size) { + const out = []; + for (let at = 0; at + size <= buf.length; at += size) out.push(buf.subarray(at, at + size)); + return out; +} +// 레코드 → 머리 + 가변부 값 +function decode(rec, size, type) { + const h = HEAD[size], + s = (k) => readS(rec, h[k][0], h[k][1]); + const row = { + Index: rec.readInt32LE(5), + link: rec[9], + code: s("code").trim(), + Name: s("name"), + Size: s("size"), + Unite: s("unite"), + Bigo: s("bigo"), + MullangIndex: rec.readInt32LE(h.mul), + PageSkip: rec[h.page], + Checked: rec[h.checked] > 0, + NewItem: rec[h.newItem] > 0, + check: rec[h.check], + ExcelSheetIndex: rec[h.sheet], + }; + for (const [f, t, a394, a565, n394, n565] of BODY[type] || []) { + const at = size === 394 ? a394 : a565; + if (!at && size === 394) continue; + row[f] = + t === "X" + ? readX(rec, at) + : t === "L" + ? rec.readInt32LE(at) + : t === "b" + ? rec.readInt8(at) + : t === "B" + ? rec[at] + : readS(rec, at, size === 394 ? n394 : n565).trim(); + } + return row; +} + +module.exports = { + HEAD, + AMOUNTS, + BODY, + KIND, + LINK_IN, + LINK_OUT, + readX, + writeX, + decoder, + readS, + writeS, + records, + decode, +}; diff --git a/tools/est-test/legacy-estplus.cjs b/tools/est-test/legacy-estplus.cjs new file mode 100644 index 000000000..48879b53b --- /dev/null +++ b/tools/est-test/legacy-estplus.cjs @@ -0,0 +1,592 @@ +// 구버전 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 }; diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index d7e50de69..2fa4da968 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -15,6 +15,7 @@ const EstActionsMore = (() => { typeof EstDongwonCheck !== "undefined" && EstDongwonCheck, typeof EstExcelFiles !== "undefined" && EstExcelFiles, typeof EstOptionAll !== "undefined" && EstOptionAll, + typeof EstEstPlus !== "undefined" && EstEstPlus, ].filter(Boolean); function handle(name, state, node) { for (const module of modules()) if (module.handle(name, state, node)) return true; diff --git a/tools/est-test/native-estplus.js b/tools/est-test/native-estplus.js new file mode 100644 index 000000000..dadb9cffe --- /dev/null +++ b/tools/est-test/native-estplus.js @@ -0,0 +1,143 @@ +// 원본 구버전 EST Plus 자료 3종(spec_misc §2 · spec_estplus.md): 「EstPlus 데이타 불러오기」(mnuFileEstPlusLoad — .est 를 풀어 +// 표마다 변환해 새 작업으로), 「Est Plus와 단가비교」(ActionEstCompare Ctrl+Alt+E → TfrmCompare: 비교할 항목 4가지 중 고르고 +// 비교파일선택 → 이 공사 머리 행과 구형 파일 Index > 0 레코드를 차례로 짝지어 합계가 다르면 표시, 더블클릭 = 그 자료로 이동), +// 「EstPlus 데이타로 저장」(ActionConvertToEstPlus Ctrl+Shift+F12 → 394 바이트 판 .est). 읽기·쓰기는 서버 legacy-estplus.cjs. +const EstEstPlus = (() => { + const num = (v) => Number(v) || 0; + const KINDS = [ + ["기계경비", "MachMainDat"], + ["단가산출", "CostTitleDat"], + ["일위대가", "HeadMainDat"], + ["내역서", "EstMainDat"], + ]; + const pickFile = () => + new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".est,.ebk"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + + // 이 공사 쪽 행(Index > 0)과 구형 값을 차례로 짝지음 · 확인 = 합계 다름(정확 비교) + function pair(project, kind, legacy) { + const [, table] = KINDS[kind], + rows = (project.tables[table] || []) + .map((row, index) => ({ row, index })) + .filter(({ row }) => num(row.Index) > 0); + return rows.map(({ row, index }, i) => { + const o = legacy[i] || { ONU: 0, OGU: 0, OJU: 0 }, + mine = num(row.NC) + num(row.GC) + num(row.JC) + num(row.SC); + return { + index, + Index: row.Index, + Code: row.Code, + Name: row.Name || EstText.clean(row.LineData || ""), + Size: row.Size, + Unite: row.Unite, + NC: num(row.NC), + GC: num(row.GC), + JC: num(row.JC), + SC: num(row.SC), + ...o, + UsesCheck: mine !== o.ONU + o.OGU + o.OJU, + }; + }); + } + + let compared = { kind: 0, rows: [] }; + function draw() { + const { esc, number } = EstApp, + { kind, rows } = compared; + EstApp.showDialog( + "단가비교", + `
비교할항목 선택${KINDS.map(([label], i) => ``).join("")}${rows.filter((r) => r.UsesCheck).length}
${rows + .map( + (r) => + ``, + ) + .join("")}
확인No품명규격단위노무비재료비경비실적노무비재료비경비
${r.UsesCheck ? "✔" : ""}${r.Index}${esc(r.Name)}${esc(r.Size ?? "")}${esc(r.Unite ?? "")}${number(r.NC)}${number(r.GC)}${number(r.JC)}${number(r.SC)}${number(r.ONU)}${number(r.OGU)}${number(r.OJU)}
`, + ); + } + async function compareFile() { + compared.kind = Number(document.querySelector('[name="ep-kind"]:checked')?.value || 0); + const file = await pickFile(); + if (!file) return; + const legacy = await EstApp.api(`/api/estplus-compare?kind=${compared.kind}`, { + method: "POST", + body: await file.arrayBuffer(), + }); + compared.rows = pair(EstApp.state.project, compared.kind, legacy); + draw(); + } + + async function load(state) { + const file = await pickFile(); + if (!file) return; + const project = await EstApp.api( + `/api/estplus-read?name=${encodeURIComponent(file.name.replace(/\.(est|ebk)$/i, ""))}`, + { method: "POST", body: await file.arrayBuffer() }, + ); + if (!confirm("구버젼 데이타를 변환했습니다. 현재 작업을 변환한 자료로 바꾸시겠습니까?")) return; + EstApp.setProject(project); + state.notice = (project.importWarnings || []).join(" "); + EstApp.render(); + } + async function convert(state) { + const response = await fetch("/api/estplus-write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state.project), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "내역서"}.est`, + await response.blob(), + "application/octet-stream", + ); + state.notice = "변환을 마쳤습니다."; + EstApp.render(); + } + + function install() { + document.addEventListener("keydown", (event) => { + const k = event.key.toLowerCase(); + if (event.ctrlKey && event.altKey && k === "e") { + event.preventDefault(); + handle("estplus-compare", EstApp.state); + } else if (event.ctrlKey && event.shiftKey && event.key === "F12") { + event.preventDefault(); + handle("estplus-convert", EstApp.state); + } + }); + document.addEventListener("dblclick", (event) => { + const tr = event.target.closest?.("[data-ep-row]"); + if (!tr) return; + const [, table] = KINDS[compared.kind], + row = EstApp.state.project.tables[table][Number(tr.dataset.epRow)]; + if (!row?.Code && table !== "EstMainDat") return; + EstApp.byId("dialog").close(); + const view = { CostTitleDat: "CostMainDat" }[table] || table; + EstApp.navigate(view, table === "EstMainDat" ? "" : row.Code); + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + function handle(name, state) { + const fail = (error) => { + const note = EstApp.byId("estplus-error"); + if (note) note.textContent = error.message; + else { + state.notice = error.message; + EstApp.render(); + } + }; + if (name === "estplus-compare") draw(); + else if (name === "estplus-compare-file") compareFile().catch(fail); + else if (name === "estplus-load") load(state).catch(fail); + else if (name === "estplus-convert") convert(state).catch(fail); + else return false; + return true; + } + return { pair, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index d0796aff7..ec9595280 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -8,6 +8,7 @@ const { importEstx, table: dbisamTable } = require("./import-estx.cjs"); const { readXls } = require("./xls-read.cjs"); const bookStore = require("./book-store.cjs"); const wongaFormats = require("./wonga-formats.cjs"); +const legacyEstPlus = require("./legacy-estplus.cjs"); const printFormat = require("./print-format.cjs"); const pfotTemplate = process.env.EST_PFOT_TEMPLATE || "C:/Dasansoft/Est Plus 2/System/EstFormat/main.pfot"; @@ -114,6 +115,7 @@ const allowed = new Set([ "native-dongwon-check.js", "native-excel-files.js", "native-option-all.js", + "native-estplus.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", @@ -346,6 +348,21 @@ const server = http.createServer(async (req, res) => { reply(res, 200, readXls(await body(req))); return; } + if (req.method === "POST" && url.pathname === "/api/estplus-read") { + const name = String(url.searchParams.get("name") || "EST Plus 자료").slice(0, 200); + reply(res, 200, legacyEstPlus.read(await body(req), name)); + return; + } + if (req.method === "POST" && url.pathname === "/api/estplus-compare") { + reply(res, 200, legacyEstPlus.compare(await body(req), Number(url.searchParams.get("kind")))); + return; + } + if (req.method === "POST" && url.pathname === "/api/estplus-write") { + const project = JSON.parse((await body(req)).toString("utf8")); + if (!project?.tables) throw Error("공사 자료 형식 오류"); + reply(res, 200, legacyEstPlus.write(project), "application/octet-stream"); + return; + } if (req.method === "GET" && url.pathname === "/api/wonga-formats") { const index = url.searchParams.get("index"); reply(res, 200, wongaFormats.get(url.searchParams.get("std") === "1", index ?? undefined));