From fc3bab4ea7623fe0f13dac08d8b1c05f56a7e142 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:01:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EA=B3=B5=EB=82=B4=EC=97=AD?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 처리 근거대로 내역·일위대가 금액을 비우고 공람 방법에 따라 단가 또는 수량을 비움 - JSON 또는 원본 구조 ESTX 로 내려받기, 사본은 자동계산 꺼 빈칸 유지 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_blank.py | 77 ++++++++++++++++++++++ tools/est-test/PLAN.md | 3 +- tools/est-test/README.md | 2 + tools/est-test/index.html | 2 + tools/est-test/native-blank.js | 79 +++++++++++++++++++++++ tools/est-test/native-business-actions.js | 3 +- tools/est-test/server.cjs | 1 + 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 resources/tester/test_est_blank.py create mode 100644 tools/est-test/native-blank.js diff --git a/resources/tester/test_est_blank.py b/resources/tester/test_est_blank.py new file mode 100644 index 000000000..3a7ed48f5 --- /dev/null +++ b/resources/tester/test_est_blank.py @@ -0,0 +1,77 @@ +"""EST 테스트 앱 공내역 파일 저장(원본 mnuFileNullSave · PrintGongMethod 0/1/2).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx") +LOAD = """ +const fs = require('fs'), vm = require('vm'); +const ctx = vm.createContext({ structuredClone, console }); +vm.runInContext(fs.readFileSync('tools/est-test/native-blank.js', 'utf8'), ctx); +""" + + +def node(script: str, *args: str): + out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_methods_clear_amounts_and_chosen_values_only(): + result = node(""" + ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + const out = vm.runInContext(`(() => { + const pick = (p) => p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0); + const src = pick(project), at = project.tables.EstMainDat.indexOf(src); + const r = (m) => { const b = EstBlank.make(project, m), x = b.tables.EstMainDat[at]; + return [x.TC, x.NC, x.TU > 0, x.GN > 0, b.options.AutoCalc, b.name.endsWith('(공내역)'), 'id' in b]; }; + const head = EstBlank.make(project, 0).tables.HeadMainDat.every((x) => !Number(x.TC) && !Number(x.TU)); + const wonga = EstBlank.make(project, 2).tables.Wonga.every((x) => !Number(x.Cost)); + let bad = ''; try { EstBlank.make(project, 3); } catch (e) { bad = e.message; } + return { m0: r(0), m1: r(1), m2: r(2), head, wonga, bad, + untouched: Number(src.TC) > 0 && Number(src.TU) > 0 }; + })()`, ctx); + process.stdout.write(JSON.stringify(out)); + """) + assert result == { + "m0": [0, 0, False, True, False, True, False], + "m1": [0, 0, True, False, False, True, False], + "m2": [0, 0, True, True, False, True, False], + "head": True, + "wonga": True, + "bad": "공내역 방법을 고르세요.", + "untouched": True, + } + + +def test_blank_estx_round_trip_keeps_quantities_and_clears_prices(): + if not SAMPLE.exists(): + pytest.skip("installed EST sample unavailable") + result = node( + """ + const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs'); + const project = imp.importEstx(fs.readFileSync(process.argv[1])); + ctx.project = project; + const blank = vm.runInContext('EstBlank.make(project, 0)', ctx); + const back = imp.importEstx(ex.exportEstx(blank)); + const rows = (p) => p.tables.EstMainDat.filter((r) => Number(r.GN) > 0); + process.stdout.write(JSON.stringify({ + count: rows(back).length === rows(project).length, + quantities: rows(back).every((r, i) => Number(r.GN) === Number(rows(project)[i].GN)), + prices: back.tables.EstMainDat.every((r) => !Number(r.TU) && !Number(r.TC)), + heads: back.tables.HeadMainDat.every((r) => !Number(r.TC)), + sources: back.tables.LaborcDat.length === project.tables.LaborcDat.length, + })); + """, + str(SAMPLE), + ) + assert result == { + "count": True, + "quantities": True, + "prices": True, + "heads": True, + "sources": True, + } diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index eea4daade..fdec0543d 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -435,10 +435,11 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 찾기 창(원본 TFRMESTSERCH·TFRMCOSTWORDCHANGE) — 검색어·처음부터/현재위치부터·다음찾기로 행 이동(호표 자동 전환·쪽 이동), 단가산출 한 건씩 바꾸기 후 다음 위치, Ctrl+F. 이전 값(`*_`) 필드는 찾지 않음. - 검증완료: `test_est_find.py` 3개, 단추·단가 고르기·행 동작 포함 24개 통과. ORCA 브라우저에서 Ctrl+F로 창 열기, 내역 「합판거푸집」 8·15·22·26행 순서 이동, 단가산출 마지막 호표 행 찾기(호표 D0000000006 전환·선택 표시), 바꾸기 후 다음 위치(323행)·되돌리기 원상 확인. - 남음: 통합검색(TFRMMULTISERCH — 다른 공사·폴더 자료 검색). + - [x] 공내역 파일로 저장(원본 mnuFileNullSaveClick 0x10cdc44 → 0x113cf38) — 디스어셈블 근거: EstMainDat·HeadTitleDat·HeadMainDat의 TC·NC·GC·JC·SC 비움, PrintGongMethod 0 = TU~SU 비움·1 = GN 비움·2 = 금액만. 웹 사본은 자동계산 끔·원가 금액 비움(원본 WongaDat 처리는 미확인). JSON 또는 원본 구조 ESTX로 내려받기. + - 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. - 2. 공내역 파일로 저장(단가 비운 사본). 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). diff --git a/tools/est-test/README.md b/tools/est-test/README.md index b273285e3..dc4da4eb6 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다. + `편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. 수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9112f8474..a492dd035 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -39,6 +39,7 @@ + @@ -68,6 +69,7 @@ + diff --git a/tools/est-test/native-blank.js b/tools/est-test/native-blank.js new file mode 100644 index 000000000..bd5a0b96d --- /dev/null +++ b/tools/est-test/native-blank.js @@ -0,0 +1,79 @@ +// 원본 공내역 파일로저장(TfrmEstMain.mnuFileNullSaveClick 0x10cdc44 → 0x113cf38). +// 내역·일위대가 제목·일위대가 상세의 금액(TC·NC·GC·JC·SC)을 비우고, 공람 방법(PrintGongMethod)에 따라 +// 0 = 단가(TU·NU·GU·JU·SU)도 비움(수량만 남김) · 1 = 수량(GN)을 비움(단가만 남김) · 2 = 금액만 비움. +const EstBlank = (() => { + const tables = ["EstMainDat", "HeadTitleDat", "HeadMainDat"]; + const amounts = ["TC", "NC", "GC", "JC", "SC"]; + const units = ["TU", "NU", "GU", "JU", "SU"]; + const methods = [ + [0, "수량만 남김(단가·금액 비움)"], + [1, "단가만 남김(수량·금액 비움)"], + [2, "수량·단가 남김(금액만 비움)"], + ]; + + function make(project, method) { + if (![0, 1, 2].includes(method)) throw Error("공내역 방법을 고르세요."); + const blank = structuredClone(project); + const clear = + method === 0 ? [...amounts, ...units] : method === 1 ? [...amounts, "GN"] : amounts; + for (const table of tables) + for (const row of blank.tables[table] || []) + for (const key of clear) if (row[key] != null) row[key] = 0; + // 금액을 비운 원가계산서가 옛 합계를 보이지 않도록 원가 금액도 비운다(원본 WongaDat 처리 확인 전 웹 보조). + for (const row of blank.tables.Wonga || []) if (row.Cost != null) row.Cost = 0; + delete blank.id; + blank.name = `${project.name || "내역서"} (공내역)`; + blank.options = { ...blank.options, AutoCalc: false, SourceRefresh: false }; + blank.calculation = { errors: [], warnings: [], direct: {}, total: 0 }; + blank.blankMethod = method; + return blank; + } + + function open(state) { + const project = state.project; + EstActions.form( + "공내역 파일로 저장", + [ + { + name: "method", + label: "남길 값", + value: Number(project.printSettings?.PrintGongMethod ?? 0), + choices: methods, + }, + { + name: "format", + label: "파일 형식", + value: project.originalArchive ? "estx" : "json", + choices: project.originalArchive + ? [ + ["estx", "원본 구조 ESTX"], + ["json", "웹 작업 JSON"], + ] + : [["json", "웹 작업 JSON"]], + }, + ], + async ({ method, format }) => { + const blank = make(project, Number(method)); + if (format === "estx") { + const response = await fetch("/api/export-estx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(blank), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download(`${blank.name}.estx`, await response.blob(), "application/octet-stream"); + } else EstApp.download(`${blank.name}.estweb.json`, JSON.stringify(blank, null, 2)); + state.notice = "공내역 파일을 내려받았습니다. 현재 작업은 바뀌지 않았습니다."; + EstApp.render(); + }, + ); + } + + function handle(name, state) { + if (name !== "blank-save") return false; + open(state); + return true; + } + + return { methods, make, handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 6be4570c1..2851273af 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -600,7 +600,8 @@ const EstBusinessActions = (() => { } else if ( EstBlockActions.handle(name, state) || (typeof EstPick !== "undefined" && EstPick.handle(name, state)) || - (typeof EstFind !== "undefined" && EstFind.handle(name, state)) + (typeof EstFind !== "undefined" && EstFind.handle(name, state)) || + (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index a206156f0..0c9c3eeaa 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -59,6 +59,7 @@ const allowed = new Set([ "native-block-actions.js", "native-pick.js", "native-find.js", + "native-blank.js", "native-issues.js", "native-attachments.js", "native-standard.js",