diff --git a/resources/tester/test_est_cleanup.py b/resources/tester/test_est_cleanup.py new file mode 100644 index 000000000..11b77cfc0 --- /dev/null +++ b/resources/tester/test_est_cleanup.py @@ -0,0 +1,102 @@ +"""EST 테스트 앱 무관자료 삭제(원본 mnuNotLinkDelete·DelSort·MulDelete).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-cleanup.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_unused_rows_removed_and_totals_unchanged(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + p.tables.StuffcDat.push({ Code: 'MZZZ', Name: '안 쓰는 자재', Index: 999, Cost1: 1, ValueIndex: 0 }); + p.tables.HeadMainDat.push({ Index: 900, Code: 'UZZZ', Name: '안 쓰는 일위', TitleCode: 'UZZZ' }, + { Code: 'MZZZ', LinkIndex: 5, GN: 1 }); + const before = calc(p), total = before.calculation.total, direct = before.calculation.direct.TC; + const counts = Object.fromEntries(['StuffcDat','LaborcDat','HeadMainDat','CostMainDat','MachMainDat'].map((t) => [t, p.tables[t].length])); + const removed = EstCleanup.clean(p, false); + const after = calc(p); + return { total: after.calculation.total === total, direct: after.calculation.direct.TC === direct, + errors: after.calculation.errors.length, + gone: !p.tables.StuffcDat.some((r) => r.Code === 'MZZZ') && !EstNative.groups(p.tables.HeadMainDat).has('UZZZ'), + removedStuff: removed.StuffcDat >= 1, removedHead: removed.HeadMainDat >= 1, + estimate: p.tables.EstMainDat.length === before.tables.EstMainDat.length }; + })()""" + ) + assert result == { + "total": True, + "direct": True, + "errors": 0, + "gone": True, + "removedStuff": True, + "removedHead": True, + "estimate": True, + } + + +def test_sorted_mode_follows_first_use_and_keeps_codes(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + const order = EstCleanup.used(p); + const total = p.calculation.total; + const codes = new Set(p.tables.HeadTitleDat.map((r) => r.Code)); + EstCleanup.clean(p, true); + const after = calc(p); + return { heads: [...EstNative.groups(p.tables.HeadMainDat).keys()].join() === order.get('HeadMainDat').join(), + stuff: p.tables.StuffcDat.map((r) => r.Code).join() === order.get('StuffcDat').join(), + codes: p.tables.HeadTitleDat.every((r) => codes.has(r.Code)), + total: after.calculation.total === total, errors: after.calculation.errors.length }; + })()""" + ) + assert result == {"heads": True, "stuff": True, "codes": True, "total": True, "errors": 0} + + +def test_blank_rows_before_heads_and_quantity_sheets(): + result = run_js( + """(() => { + const rows = [{ Index: 1, Code: 'A' }, { Name: 'x', GN: 1 }, {}, {}, { Index: 2, Code: 'B' }, {}]; + const trimmed = EstCleanup.trimBeforeHeads(rows, 'HeadMainDat').map((r) => r.Code || r.Name || '-'); + const p = { tables: { EstMainDat: [{ PkID: 7, Code: 'D1' }] }, quantitySheets: [ + { Code: 'Q1', items: [{ TargetKey: 'id:7' }] }, { Code: 'Q2', items: [{ TargetCode: 'NONE' }] }, { Code: 'Q3', items: [] }] }; + const gone = EstCleanup.cleanQuantity(p); + return { trimmed, gone, left: p.quantitySheets.map((s) => s.Code) }; + })()""" + ) + assert result == {"trimmed": ["A", "x", "B", "-"], "gone": 2, "left": ["Q1"]} diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 6d36c88d8..5f59b317d 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -460,6 +460,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 정산원가계산서(원본 popJunSangCalc 0xd0eb28→0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8 — 디스어셈블 근거) — 당초금액 초기화(SC := Cost, 55 → 0, JungSangInit), 작성/해제 토글(JungSangShow), 차액 행(LinkIndex 66, (감)/(증)) 재계산마다 갱신, 원가 화면 당초금액·정산금액 열. 엔진은 66 행 수식을 계산하지 않음. - 검증완료: `test_est_jungsang.py` 2개(차액 글자·빈 행 재사용, 당초 복사·55 해제·재계산 차액·해제 시 66 비움·SC 유지·열 순서), 대조·업무 시험 포함 통과. ORCA 브라우저에서 작성 → 열 「당초금액, 정산금액」·차액 (증)324(도급액 고정 해제분)·오류 0, 해제 → 「원가금액」 복귀, 샘플 복구. - 남음: 당초분·정산분 색상 옵션. + - [x] 무관자료 삭제(원본 mnuNotLinkDelete·DelSort → f039f0, MulDelete → 1116db0 — 디스어셈블 근거) — 내역서에서 LinkIndex 5·6·21·101·103·104·105·Code(설계변경 Code_ 포함)로 하위까지 따라가 쓰는 자료만 남김, 묶음 머리 앞 빈 줄 정리, 내역서순 재배열(코드 유지), 내역 미연결 수량산출서 삭제. 원본의 한 번 훑기 누락(기계경비 안에서만 쓰인 일위대가 하위)은 웹에서 끝까지 따라가 막음. LinkIndex 211–218 코드 비우기는 뜻 미확인으로 하지 않음. + - 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 76c10931a..7e7eb7693 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다. + `원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다. `원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index d8fabacfe..378f79f3a 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -46,6 +46,7 @@ + @@ -116,6 +117,10 @@ +