diff --git a/resources/tester/test_est_mach_formula.py b/resources/tester/test_est_mach_formula.py new file mode 100644 index 000000000..05ac0b433 --- /dev/null +++ b/resources/tester/test_est_mach_formula.py @@ -0,0 +1,48 @@ +"""EST 테스트 앱 기계경비 공식 모음(원본 TfrmMachGongSic·TfrmMachText·구공식변환 0xd90c60).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_old_file_parse_and_insert(tmp_path): + script = r""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({ TextDecoder, DataView, Uint8Array }); + vm.runInContext(fs.readFileSync('tools/est-test/native-mach-formula.js', 'utf8'), ctx); + const rec = (head, a, b) => { + const r = Buffer.alloc(394); r.writeInt32LE(head, 5); + const ea = Buffer.from(a, 'latin1'), eb = Buffer.from(b, 'latin1'); + r[0x16] = ea.length; ea.copy(r, 0x17); r[0x49] = eb.length; eb.copy(r, 0x4a); return r; + }; + const file = Buffer.concat([rec(1, 'Back', 'hoe'), rec(0, 'Q = ', '1.0'), rec(0, 'E = 0.7', ''), rec(2, 'Dozer', ''), rec(0, 'f = 1', '')]); + ctx.bytes = new Uint8Array(file); + const parsed = vm.runInContext('EstMachFormula.parseOld(bytes)', ctx); + let bad = ''; try { vm.runInContext('EstMachFormula.parseOld(new Uint8Array(10))', ctx); } catch (e) { bad = e.message.slice(0, 6); } + ctx.p = { tables: { CostMainDat: [{ PkID: 3, Index: 1, Code: 'D1' }, { PkID: 4, LineData: '끝' }] } }; + ctx.body = 'Q = 1\rE = 0.7\r'; + const count = vm.runInContext('EstMachFormula.insertInto(p, 0, body)', ctx); + process.stdout.write(JSON.stringify({ parsed, bad, count, rows: ctx.p.tables.CostMainDat.map((r) => [r.PkID, r.LineData ?? null]) })); + """ + out = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + assert out["parsed"] == [ + {"name": "Backhoe", "body": "Q = 1.0\rE = 0.7\r", "index": 1}, + {"name": "Dozer", "body": "f = 1\r", "index": 2}, + ] + assert out["bad"] == "구공식 파일" + assert out["count"] == 2 + assert out["rows"] == [[3, None], [5, "Q = 1"], [6, "E = 0.7"], [4, "끝"]] + + +def test_store_accepts_machine_formula_kind(tmp_path): + script = """ + const f = require(process.cwd() + '/tools/est-test/forms-store.cjs'); + f.save(process.argv[1], { kind: 'machformula', name: '백호', data: { index: 1, body: 'Q = 1' } }); + process.stdout.write(JSON.stringify(f.read(process.argv[1]).map((x) => [x.kind, x.name]))); + """ + out = json.loads( + subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8") + ) + assert out == [["machformula", "백호"]] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 8726b4fa5..41c988bd8 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -500,6 +500,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE — 디스어셈블 근거) — 인쇄표 차례·Checked 서식, 간지(Name2 가운데·겹테두리), 한 문서 이어 인쇄, 간지 쪽 번호 숨김, 공사별 선택 저장(`project.printBatch`). 원본은 서식마다 따로 인쇄 작업·간지는 쪽 번호를 소모하지 않음 — 웹은 브라우저 쪽 번호라 간지도 쪽 수에 들어감(번호만 숨김). - 검증완료: `test_est_print_batch.py`(공통 행 제외·간지→서식 차례·간지 끔·선택 저장) 통과. ORCA 브라우저에서 인쇄표 40개 목록·4개 선택 → 간지 4·서식 4·표 3, 샘플 복구. - 남음: 설정값 일괄적용(간지 글꼴·여백 13항목), 번호자동매김·시작 쪽, 끌어서 차례 바꾸기. + - [x] 기계경비 공식 모음(원본 TfrmMachGongSic 0xf146f0·MachText.Dat, TfrmMachText 0xd3d58c, 구공식변환 0xd90c60 — 디스어셈블 근거) — 서버 양식 보관함 종류 machformula, 삽입·추가·삭제·번호 1..N·저장, 단가산출 현재 행 아래로 본문 줄 넣기, 옛 394바이트 레코드 변환. 원본이 기계경비 연결 때 덧붙이는 요약 4줄은 형식 확인 전이라 빼둠. + - 검증완료: `test_est_mach_formula.py` 2개(구공식 두 공식·본문 CR·크기 오류, 줄 넣기 PkID·위치, 보관함 종류) 통과. ORCA 브라우저에서 공식 추가·저장 → 보관함 등록 → 단가산출 호표 아래 2줄 넣기 → 보관함 정리·샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index e90421490..564e6e0e2 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 기계경비 공식`은 원본 기계경비 공식 창처럼 명칭과 공식 본문을 모아 두는 목록(공사와 무관하게 이 앱 서버에 저장)을 편집합니다. 단가산출 화면에서 행을 고르고 `단가산출에 불러오기`를 누르면 공식 본문을 줄마다 그 아래에 넣습니다(계산식으로 풀지 않음). `구공식변환`으로 옛 EST Plus `mach_Text.dat`(394바이트 레코드)를 읽어 덧붙입니다. + `편집 → 하단으로 집계`는 원본처럼 공종이 끝나는 자리(다음 공종 머리·순공사비 앞·마지막)에 「소 계」 줄을 넣고, 계산할 때 그 공종 상세 행의 합을 채웁니다. 겹쳐 넣지 않으며 `상단으로 집계`는 소계 줄을 지웁니다. `공종 다듬기`는 레벨마다 공종명을 들여 보입니다. `단가산출 들여쓰기`는 산출근거 줄 앞 빈칸을 같은 칸 수로 맞추고(분수 세 줄은 함께 옮김), `단가산출 레벨 확인`은 현재 호표의 레벨 줄과 비목 소계를 계층으로 보입니다. `편집 → 데이타 자동연결`은 원본처럼 내역서·일위대가·기계경비 행을 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표와 품명(·규격·단위)으로 맞춰 코드·단가·연결종류를 적습니다. 빈칸·|·괄호를 무시하고 단위의 「당」·[ ]를 지우며, 노임은 품명만 맞추고 못 찾으면 규격으로 찾아 품명·규격을 맞바꿉니다. `Over Write`를 끄면 이미 연결된 행은 건너뜁니다. `전체연결 끊기`는 원본처럼 모든 행의 연결종류를 0으로 합니다. diff --git a/tools/est-test/forms-store.cjs b/tools/est-test/forms-store.cjs index 3b8ddd43e..5d5a021a1 100644 --- a/tools/est-test/forms-store.cjs +++ b/tools/est-test/forms-store.cjs @@ -3,7 +3,7 @@ const fs = require("node:fs"); const path = require("node:path"); -const kinds = new Set(["wonga", "document"]); +const kinds = new Set(["wonga", "document", "machformula"]); const LIMIT = 2 * 1024 * 1024; function file(directory) { diff --git a/tools/est-test/index.html b/tools/est-test/index.html index fb0ad0cf8..ee52f8f67 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -59,6 +59,7 @@ + @@ -148,6 +149,7 @@ > + { (typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) || (typeof EstAutoLink !== "undefined" && EstAutoLink.handle(name, state)) || (typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) || - (typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) + (typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) || + (typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-mach-formula.js b/tools/est-test/native-mach-formula.js new file mode 100644 index 000000000..8a6235c96 --- /dev/null +++ b/tools/est-test/native-mach-formula.js @@ -0,0 +1,164 @@ +// 원본 기계경비 공식(TfrmMachGongSic 0xf146f0, 전역 파일 System\MainSys\MachText.Dat: Index·Name·BigoMemo)과 +// 기계경비공식불러오기(TfrmMachText 0xd3d58c: 고른 공식 본문을 줄마다 단가산출 LineData 행으로 현재 행 뒤에 넣음, 계산하지 않음). +// 웹은 공식 모음을 서버 양식 보관함(종류 machformula)에 둔다. 번호는 늘 1..N. +// 구공식변환(숨김 단추 0xda5c18 → 0xd90c60): 옛 EST Plus mach_Text.dat 394바이트 레코드 — +5 정수 > 0 이면 새 공식 머리 +// (이름 = +0x16 짧은 문자열 + +0x49 짧은 문자열), 아니면 본문 줄(앞글+뒤글+CR). +// 원본이 기계경비 연결 때 덧붙이는 요약 4줄(노무비·재료비·경비·소계)은 형식 확인 전이라 넣지 않는다. +const EstMachFormula = (() => { + function parseOld(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + if (!view.length || view.length % 394) + throw Error("구공식 파일 크기가 394바이트 단위가 아닙니다."); + const decoder = new TextDecoder("euc-kr"), + data = new DataView(view.buffer, view.byteOffset, view.byteLength); + const short = (at, max) => + decoder.decode(view.subarray(at + 1, at + 1 + Math.min(view[at], max))); + const out = []; + let current = null; + for (let at = 0; at < view.length; at += 394) { + const head = data.getInt32(at + 5, true), + text = short(at + 0x16, 50) + short(at + 0x49, 255); + if (head > 0) { + current = { name: text.slice(0, 100), body: "" }; + out.push(current); + } else if (current) current.body += `${text}\r`; + } + return out.map((f, i) => ({ ...f, index: i + 1 })); + } + + function lines(body) { + return String(body ?? "") + .split(/\r\n|\r|\n/) + .filter((line, i, all) => line !== "" || i < all.length - 1); + } + + function insertInto(project, at, body) { + const rows = project.tables.CostMainDat; + if (!rows?.[at]) throw Error("단가산출에서 넣을 위치(행)를 고르세요."); + const add = lines(body); + if (!add.length) throw Error("공식 본문이 비어 있습니다."); + let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0)); + rows.splice(at + 1, 0, ...add.map((LineData) => ({ PkID: ++pk, Index: 0, LineData }))); + return add.length; + } + + // 화면 + async function load() { + return (await EstApp.api("/api/forms")) + .filter((f) => f.kind === "machformula") + .sort((a, b) => (a.data.index ?? 0) - (b.data.index ?? 0)); + } + async function save(list) { + const old = await load(); + for (const f of old) + if (!list.some((n) => n.name === f.name)) + await EstApp.api("/api/forms/delete", post({ kind: "machformula", name: f.name })); + for (const [i, f] of list.entries()) + await EstApp.api( + "/api/forms", + post({ kind: "machformula", name: f.name, data: { index: i + 1, body: f.body } }), + ); + } + const post = (body) => ({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + async function editor(state) { + const list = (await load()).map((f) => ({ name: f.name, body: f.data.body })); + state.machFormulas = list; + draw(state); + } + function draw(state) { + const { esc } = EstApp, + list = state.machFormulas, + cost = state.view === "CostMainDat"; + EstApp.showDialog( + "기계경비 공식", + `
${list + .map( + (f, i) => + `
${cost ? ` ` : ""}
`, + ) + .join( + "
", + )}
`, + ); + } + function read(state) { + for (const node of document.querySelectorAll('#dialog-content [name="mf-name"]')) + state.machFormulas[Number(node.dataset.i)].name = node.value.trim(); + for (const node of document.querySelectorAll('#dialog-content [name="mf-body"]')) + state.machFormulas[Number(node.dataset.i)].body = node.value; + } + function unique(list) { + const seen = new Set(); + for (const f of list) { + if (!f.name) throw Error("공식 명칭을 입력하세요."); + if (seen.has(f.name)) throw Error(`같은 명칭이 있습니다: ${f.name}`); + seen.add(f.name); + } + } + + async function run(name, state, node) { + const i = Number(node?.dataset.i); + if (name === "mach-formula") return editor(state); + read(state); + const list = state.machFormulas; + if (name === "mach-formula-append") list.push({ name: `새 공식 ${list.length + 1}`, body: "" }); + else if (name === "mach-formula-insert") + list.splice(i, 0, { name: `새 공식 ${list.length + 1}`, body: "" }); + else if (name === "mach-formula-delete") list.splice(i, 1); + else if (name === "mach-formula-save") { + unique(list); + await save(list); + state.notice = `기계경비 공식 ${list.length}개를 저장했습니다.`; + } else if (name === "mach-formula-use") { + const at = [...state.selection].sort((a, b) => a - b).at(-1); + let count; + EstApp.change((project) => (count = insertInto(project, at, list[i].body))); + EstApp.byId("dialog").close(); + state.notice = `「${list[i].name}」 공식 ${count}줄을 넣었습니다.`; + return; + } else if (name === "mach-formula-old") { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".dat"; + picker.addEventListener("change", async () => { + try { + const file = picker.files[0]; + if (!file) return; + for (const f of parseOld(new Uint8Array(await file.arrayBuffer()))) + list.push({ name: f.name, body: f.body }); + draw(state); + } catch (error) { + state.notice = error.message; + EstApp.render(); + } + }); + picker.click(); + return; + } + draw(state); + } + const names = new Set([ + "mach-formula", + "mach-formula-append", + "mach-formula-insert", + "mach-formula-delete", + "mach-formula-save", + "mach-formula-use", + "mach-formula-old", + ]); + function handle(name, state, node) { + if (!names.has(name)) return false; + run(name, state, node).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + + return { parseOld, lines, insertInto, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 3621a66c6..69bfc13b0 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -81,6 +81,7 @@ const allowed = new Set([ "native-autolink.js", "native-est-tools.js", "native-print-batch.js", + "native-mach-formula.js", "native-issues.js", "native-attachments.js", "native-standard.js",