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 @@ +
+
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 4b91830c9..de8ed3fc8 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -607,7 +607,8 @@ const EstBusinessActions = (() => { (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) || (typeof EstBid !== "undefined" && EstBid.handle(name, state)) || (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) || - (typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) + (typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) || + (typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-cleanup.js b/tools/est-test/native-cleanup.js new file mode 100644 index 000000000..ac74d4b83 --- /dev/null +++ b/tools/est-test/native-cleanup.js @@ -0,0 +1,147 @@ +// 원본 무관자료 삭제(TfrmEstMain mnuNotLinkDelete 0x10cd2a8·mnuNotLinkDelSort 0x10cd2b8 → f039f0, +// mnuNotLinkMulDelete 0x10cd2c8 → 1116db0). 내역서가 쓰는 자료(LinkIndex 5·6·21·101·103·104·105, Code 일치)를 +// 일위대가·단가산출·기계경비 묶음 안까지 따라가 표시하고 나머지 기초·묶음 자료를 지운다. 내역서·원가·적용기준은 지우지 않는다. +// 내역서순: 내역서 첫 등장 순서(중첩은 깊이 우선)로 표를 다시 늘어놓고, 코드는 바꾸지 않는다. +// 설계변경 당초 코드(Code_)도 함께 따라간다. 원본은 내역서→일위→단가→기계 차례 한 번 훑기라 기계경비 안에서만 +// 쓰인 일위대가의 하위를 놓칠 수 있으나, 웹은 끝까지 따라가 쓰는 자료를 지우지 않는다. +const EstCleanup = (() => { + const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" }; + const grouped = { + 103: ["HeadTitleDat", "HeadMainDat"], + 104: ["CostTitleDat", "CostMainDat"], + 105: ["MachTitleDat", "MachMainDat"], + }; + const num = (value) => Number(value) || 0; + const text = (value) => String(value ?? "").trim(); + + function used(project) { + const order = new Map(Object.values(flat).map((t) => [t, []])), + seen = new Set(), + groups = {}; + for (const [, [, main]] of Object.entries(grouped)) { + order.set(main, []); + groups[main] = EstNative.groups(project.tables[main] || []); + } + function visit(rows) { + for (const row of rows) + for (const code of new Set([row.Code, row.Code_].filter(Boolean))) { + const kind = num(row.LinkIndex), + id = `${kind}:${code}`; + if (seen.has(id)) continue; + if (flat[kind]) { + if ((project.tables[flat[kind]] || []).some((r) => r.Code === code)) { + seen.add(id); + order.get(flat[kind]).push(code); + } + } else if (grouped[kind]) { + const [, main] = grouped[kind], + group = groups[main].get(code); + if (!group) continue; + seen.add(id); + order.get(main).push(code); + visit(group.rows.slice(1)); + } + } + } + visit(project.tables.EstMainDat || []); + return order; + } + + function blankRow(table, row) { + if (table === "CostMainDat") return !text(row.LineData); + return ( + !text(row.Name) && + !text(row.Size) && + !text(row.Unite) && + !(typeof row.Bigo === "string" && text(row.Bigo)) && + !num(row.GN) && + !num(row.TC) + ); + } + // 빈 줄 정리(f038ac): 묶음 머리 바로 앞의 빈 행을 지운다. + function trimBeforeHeads(rows, table) { + return rows.filter((row, i) => { + if (!blankRow(table, row) || num(row.Index) > 0) return true; + let next = i + 1; + while (next < rows.length && blankRow(table, rows[next]) && !(num(rows[next].Index) > 0)) + next++; + return !(next < rows.length && num(rows[next].Index) > 0); + }); + } + + function clean(project, sorted) { + const order = used(project), + removed = {}; + for (const table of Object.values(flat)) { + const rows = project.tables[table] || [], + keep = order.get(table), + byCode = new Map(rows.map((r) => [r.Code, r])); + const next = sorted + ? keep.map((code) => byCode.get(code)) + : rows.filter((r) => keep.includes(r.Code)); + removed[table] = rows.length - next.length; + project.tables[table] = next; + } + for (const [title, main] of Object.values(grouped)) { + const keep = order.get(main), + groups = EstNative.groups(project.tables[main] || []), + before = groups.size; + const codes = sorted ? keep : [...groups.keys()].filter((code) => keep.includes(code)); + project.tables[main] = trimBeforeHeads( + codes.flatMap((code) => groups.get(code)?.rows || []), + main, + ); + const titles = project.tables[title] || [], + byCode = new Map(titles.map((r) => [r.Code, r])); + project.tables[title] = sorted + ? codes.map((code) => byCode.get(code)).filter(Boolean) + : titles.filter((r) => keep.includes(r.Code)); + removed[main] = before - codes.length; + } + return removed; + } + + // 수량산출서: 내역 행이 쓰는(MullangIndex 9999·MulCode 앞 조각) 산출서와, 항목이 내역에 연결된 산출서만 남긴다. + function cleanQuantity(project) { + const sheets = project.quantitySheets || [], + estimate = project.tables.EstMainDat || [], + codes = new Set(estimate.map((r) => r.Code).filter(Boolean)), + keys = new Set(estimate.map((r) => r.PkID != null && `id:${r.PkID}`).filter(Boolean)), + named = new Set( + estimate + .filter((r) => num(r.MullangIndex) === 9999 && typeof r.MulCode === "string") + .map((r) => r.MulCode.split("\t")[0]), + ); + const keep = sheets.filter( + (sheet) => + named.has(sheet.Code) || + (sheet.items || []).some( + (item) => + (item.TargetKey && keys.has(item.TargetKey)) || + (item.TargetCode && codes.has(item.TargetCode)), + ), + ); + project.quantitySheets = keep; + return sheets.length - keep.length; + } + + const WARN = + "내역서상에 사용한 데이타(수량산출)만 남기고 나머지는 삭제합니다.\n삭제된 자료는 되살리기로만 복구할 수 있습니다."; + function handle(name, state) { + if (name === "notlink-delete" || name === "notlink-sort") { + if (!confirm(WARN)) return true; + let removed; + EstApp.change((project) => (removed = clean(project, name === "notlink-sort"))); + const total = Object.values(removed).reduce((a, b) => a + b, 0); + state.notice = `안 쓰는 자료 ${total}건을 지웠습니다${name === "notlink-sort" ? "(내역서 순서로 정리)" : ""}.`; + } else if (name === "notlink-quantity") { + if (!confirm(WARN)) return true; + let removed; + EstApp.change((project) => (removed = cleanQuantity(project)), false); + state.notice = `내역에 연결되지 않은 수량산출서 ${removed}개를 지웠습니다.`; + } else return false; + return true; + } + + return { used, clean, cleanQuantity, trimBeforeHeads, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index c1d3d73bf..b0cd4d7c9 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -67,6 +67,7 @@ const allowed = new Set([ "native-transfer.js", "native-bid.js", "native-forms-library.js", + "native-cleanup.js", "native-issues.js", "native-attachments.js", "native-standard.js",