diff --git a/resources/tester/test_est_action_bindings.py b/resources/tester/test_est_action_bindings.py index 4b9f10277..044216b29 100644 --- a/resources/tester/test_est_action_bindings.py +++ b/resources/tester/test_est_action_bindings.py @@ -7,7 +7,10 @@ ROOT = Path(__file__).resolve().parents[2] / "tools" / "est-test" def test_every_handled_action_has_a_trigger(): - sources = {p.name: p.read_text(encoding="utf-8") for p in ROOT.glob("native-*.js")} + sources = { + p.name: p.read_text(encoding="utf-8") + for p in [*ROOT.glob("native-*.js"), *ROOT.glob("native-*.cjs")] + } sources["index.html"] = (ROOT / "index.html").read_text(encoding="utf-8") handled = set() for text in sources.values(): diff --git a/resources/tester/test_est_quantity_sheets.py b/resources/tester/test_est_quantity_sheets.py new file mode 100644 index 000000000..598364c9f --- /dev/null +++ b/resources/tester/test_est_quantity_sheets.py @@ -0,0 +1,74 @@ +"""EST 테스트 앱 수량산출서 복사·순서 이동과 ESTX 저장 거부.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") +LOAD = """ +const fs = require('fs'), vm = require('vm'); +const ctx = vm.createContext({ structuredClone, console }); +vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx); +const run = (code) => vm.runInContext(code, 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_copy_inserts_below_with_new_code_and_cleared_links(): + result = node(""" + ctx.project = { quantitySheets: [ + { Code: 'A', Name: '토공', items: [{ Code: 'r000000', TargetCode: 'D1', TargetKey: 'id:3', LinkCode: 'D1' }], rows: [{ LineData: 'x', markers: ['r000000'] }] }, + { Code: 'B', Name: '포장', items: [], rows: [] }, + ] }; + const first = run("EstQuantitySheets.copy(project, 'A', 'u1')"); + const second = run("EstQuantitySheets.copy(project, 'A', 'u1')"); + const p = ctx.project; + process.stdout.write(JSON.stringify({ + first, second, order: p.quantitySheets.map((s) => s.Code), name: p.quantitySheets[1].Name, + link: p.quantitySheets[1].items[0], original: p.quantitySheets[0].items[0].TargetCode, + rows: p.quantitySheets[1].rows[0].markers, + })); + """) + assert result == { + "first": "Qu1", + "second": "Qu1_2", + "order": ["A", "Qu1_2", "Qu1", "B"], + "name": "토공 복사본", + "link": {"Code": "r000000", "TargetCode": "", "TargetKey": ""}, + "original": "D1", + "rows": ["r000000"], + } + + +def test_move_swaps_and_stops_at_edges(): + result = node(""" + ctx.project = { quantitySheets: [{ Code: 'A' }, { Code: 'B' }, { Code: 'C' }] }; + const out = [run("EstQuantitySheets.move(project, 'B', -1)"), run("EstQuantitySheets.move(project, 'B', -1)"), + run("EstQuantitySheets.move(project, 'C', 1)")]; + process.stdout.write(JSON.stringify({ out, order: ctx.project.quantitySheets.map((s) => s.Code) })); + """) + assert result == {"out": [True, False, False], "order": ["B", "A", "C"]} + + +def test_estx_export_refuses_reordered_sheets(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + result = node( + """ + const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs'); + ctx.project = imp.importEstx(fs.readFileSync(process.argv[1])); + run("EstQuantitySheets.move(project, project.quantitySheets[0].Code, 1)"); + let message = ''; + try { ex.exportEstx(ctx.project); } catch (e) { message = e.message; } + process.stdout.write(JSON.stringify(message)); + """, + str(MAIN), + ) + assert result.startswith("수량산출서 순서 변경은 ESTX") diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index fdec0543d..cbee58afd 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -437,10 +437,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 통합검색(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 유지 확인 후 샘플 복구. + - [x] 수량산출서 복사·순서(원본 TFRMMULLANG 공종복사·붙여넣기) — 현재 산출서 아래에 복사(새 코드·내역 연결 비움), 위로·아래로. 순서 변경 ESTX 저장은 명확한 문구로 거부. 단추 지킴 시험이 `.cjs` 화면 파일도 보도록 넓힘. + - 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. - 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄. + 3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 제출용 전체 인쇄. 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). 6. 낙찰률 전체·범위·공종별 적용. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index dc4da4eb6..2e43e779a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -26,6 +26,8 @@ `편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. +수량산출 화면의 `산출서 복사`는 현재 산출서를 바로 아래에 복사하고, 내역에 두 번 반영되지 않도록 복사본의 내역 연결을 비웁니다. `산출서 위로`·`아래로`로 순서를 바꿉니다. 산출서 추가·순서 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. + 수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다. 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs index 0fe094451..744ef6ec2 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) { const sheets = project.quantitySheets || [], originals = base.quantitySheets || []; if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다."); + if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code)) + throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); const rows = project.tables.MulMainDat.map((r) => ({ ...r })); const titles = (project.tables.MulTitleDat || []).map((r) => ({ ...r })); const savedSheets = sheets.slice(); diff --git a/tools/est-test/index.html b/tools/est-test/index.html index a492dd035..23b895460 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -40,6 +40,7 @@ + diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 2851273af..fc38404c7 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -601,7 +601,8 @@ const EstBusinessActions = (() => { EstBlockActions.handle(name, state) || (typeof EstPick !== "undefined" && EstPick.handle(name, state)) || (typeof EstFind !== "undefined" && EstFind.handle(name, state)) || - (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) + (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) || + (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js new file mode 100644 index 000000000..1a4c89b55 --- /dev/null +++ b/tools/est-test/native-quantity-sheets.js @@ -0,0 +1,52 @@ +// 원본 수량산출(TFRMMULLANG) 공종 단위 조작 — 공종복사하기·붙여넣기(mnuCopy·mnuPaste)와 순서 이동. +// 복사본은 현재 산출서 바로 아래에 넣고, 내역 이중 반영을 막기 위해 내역 연결을 비운다. +const EstQuantitySheets = (() => { + function copy(project, code, uid) { + const sheets = project.quantitySheets || [], + index = sheets.findIndex((sheet) => sheet.Code === code); + if (index < 0) throw Error("복사할 수량산출서를 고르세요."); + const used = new Set(sheets.map((sheet) => sheet.Code)); + let next = `Q${uid}`; + for (let n = 2; used.has(next); n++) next = `Q${uid}_${n}`; + const sheet = structuredClone(sheets[index]); + sheet.Code = next; + sheet.Name = `${sheets[index].Name || sheets[index].Code} 복사본`; + for (const item of sheet.items || []) { + item.TargetCode = ""; + item.TargetKey = ""; + delete item.LinkCode; + delete item.LinkIndex; + } + sheets.splice(index + 1, 0, sheet); + return next; + } + + function move(project, code, step) { + const sheets = project.quantitySheets || [], + index = sheets.findIndex((sheet) => sheet.Code === code), + next = index + step; + if (index < 0 || next < 0 || next >= sheets.length) return false; + [sheets[index], sheets[next]] = [sheets[next], sheets[index]]; + return true; + } + + function handle(name, state) { + const code = state.quantitySheet; + if (name === "quantity-sheet-copy") { + EstApp.change((project) => { + state.quantitySheet = copy(project, code, EstApp.uid()); + }, false); + state.notice = "산출서를 복사했습니다. 복사본의 내역 연결은 비워 두었습니다."; + } else if (name === "quantity-sheet-up" || name === "quantity-sheet-down") { + const step = name === "quantity-sheet-up" ? -1 : 1, + sheets = state.project.quantitySheets || [], + index = sheets.findIndex((sheet) => sheet.Code === code); + if (index < 0 || index + step < 0 || index + step >= sheets.length) + throw Error("더 옮길 수 없습니다."); + EstApp.change((project) => move(project, code, step), false); + } else return false; + return true; + } + + return { copy, move, handle }; +})(); diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 499c7fb57..f9ec51cb3 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -315,7 +315,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 0c9c3eeaa..9458cdb04 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -60,6 +60,7 @@ const allowed = new Set([ "native-pick.js", "native-find.js", "native-blank.js", + "native-quantity-sheets.js", "native-issues.js", "native-attachments.js", "native-standard.js",