diff --git a/resources/tester/test_est_bid.py b/resources/tester/test_est_bid.py new file mode 100644 index 000000000..2893b3ce2 --- /dev/null +++ b/resources/tester/test_est_bid.py @@ -0,0 +1,88 @@ +"""EST 테스트 앱 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 전체·선택·공종별·적용된 줄 제외).""" + +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-bid.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))); + """ + out = subprocess.check_output(["node", "-e", runner], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +SETUP = """ + const p = EstNative.calculate(EstModel.validate(structuredClone(source))); + const rows = p.tables.EstMainDat; + const heading = rows.findIndex((r) => Number(r.Level) > 0 && EstBid.subtree(rows, rows.indexOf(r)).length >= 2); + const kids = EstBid.subtree(rows, heading); + const outside = rows.findIndex((r, i) => i > kids.at(-1) && !Number(r.Level) && Number(r.NU) > 0 && Number(r.LinkIndex) !== 54); + const unit = (q, i) => Number(q.tables.EstMainDat[i].NU); +""" + + +def test_group_scope_changes_only_rows_under_selected_heading(): + result = run_js( + "(() => {" + + SETUP + + """ + const only = EstBid.scope(p, 'EstMainDat', new Set([heading]), 'groups', false); + const q = EstBusiness.bid(p, EstBid.rates({method:'total', T:80}), only); + const inside = kids.find((i) => unit(p, i) > 0); + const base = Number(p.tables.EstMainDat[inside].ONU ?? unit(p, inside)); + return { inside: Math.abs(unit(q, inside) - base * 0.8) < 1 && q.tables.EstMainDat[inside].ApplyNacChal === 1, + outside: unit(q, outside) === unit(p, outside), size: only.EstMainDat.size === kids.length }; + })()""" + ) + assert result == {"inside": True, "outside": True, "size": True} + + +def test_selected_rows_skip_applied_and_errors(): + result = run_js( + "(() => {" + + SETUP + + """ + const first = kids.find((i) => unit(p, i) > 0); + const once = EstBusiness.bid(p, [90, 90, 90, 90], { EstMainDat: new Set([first]) }); + const errors = []; + const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 6)); } }; + t(() => EstBid.scope(once, 'EstMainDat', new Set([first]), 'rows', true)); + t(() => EstBid.scope(once, 'EstMainDat', new Set(), 'rows', false)); + t(() => EstBid.scope(once, 'EstMainDat', new Set([first]), 'groups', false)); + t(() => EstBid.rates({method:'bimog', N:80, G:-1, J:1, S:1})); + const kept = EstBid.scope(once, 'EstMainDat', new Set([first, outside]), 'rows', true); + return { errors, kept: [...kept.EstMainDat], all: EstBid.scope(once, 'EstMainDat', new Set(), 'all', false) }; + })()""" + ) + assert [e[:4] for e in result["errors"]] == ["적용할 ", "낙찰률을", "낙찰률을", "낙찰률은"] + assert len(result["kept"]) == 1 + assert result["all"] is None diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 4dc94f846..51e613391 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -449,11 +449,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 두 공사 나란히 보기. - [x] 프로젝트 추가(원본 mnuFileLoadProjectAs) — 다른 공사 파일(ESTX·JSON)의 순공사비 위 내역 전체를 하위 자료와 함께 이 공사 순공사비 위에 합침. 같은 코드 자료 유지. - 검증완료: `test_est_transfer.py` 3개 통과(합친 뒤 순공사비 2배·계산 오류 0·빈 공사 거부). ORCA 브라우저에서 메뉴 확인, 샘플에 샘플 합치기 → 35행·같은 코드 46건 유지·순공사비 45,293,066 → 90,586,132, 샘플 복구. 파일 고르기 창 자체는 자동 조작 불가로 기존 가져오기 경로(`/api/import`)를 그대로 씀. + - [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원. + - 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상. + - 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. - 6. 낙찰률 전체·범위·공종별 적용. 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리. 8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집. 9. 묶음 인쇄(설계서 한 벌). diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 1cccddaf5..6bc535cfc 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다. + `파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다. `편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 99d20fea9..8d914406d 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -43,6 +43,7 @@ + @@ -111,6 +112,8 @@ +
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index fa70740ff..5bc51a560 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -403,24 +403,6 @@ const EstActions = (() => { project.options.OldRecalcMethod = Number(values.method); }), ); - else if (name === "bid-rates") - form( - "비목별 낙찰률", - [ - { name: "N", label: "노무 %", type: "number", value: 100 }, - { name: "G", label: "재료 %", type: "number", value: 100 }, - { name: "J", label: "경비 %", type: "number", value: 100 }, - { name: "S", label: "실적 %", type: "number", value: 100 }, - ], - (values) => { - if (Object.values(values).some((n) => !Number.isFinite(n) || n < 0)) - throw Error("낙찰률은 0 이상의 수치입니다."); - EstApp.change( - () => - (state.project = EstBusiness.bid(project, [values.N, values.G, values.J, values.S])), - ); - }, - ); else if (name === "restore-bid") { if (confirm("당초단가(100%)로 복원하시겠습니까?")) EstApp.change(() => { @@ -663,16 +645,19 @@ const EstActions = (() => { EstApp.render(); }); EstApp.byId("previous-page").addEventListener("click", () => { - EstApp.state.page--; EstApp.render(); + EstApp.state.page--; + EstApp.render(); }); EstApp.byId("next-page").addEventListener("click", () => { - EstApp.state.page++; EstApp.render(); + EstApp.state.page++; + EstApp.render(); }); document.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); EstApp.save().catch((error) => { - EstApp.state.notice = error.message; EstApp.render(); + EstApp.state.notice = error.message; + EstApp.render(); }); } }); diff --git a/tools/est-test/native-bid.js b/tools/est-test/native-bid.js new file mode 100644 index 000000000..aa5e4e955 --- /dev/null +++ b/tools/est-test/native-bid.js @@ -0,0 +1,102 @@ +// 원본 낙찰율(TFRMBIMOGBIUL) — 전체낙찰율·비목별낙찰율, 적용방법(현재/선택한/전체 라인), 공종별(선택한 공종 하위), +// 「낙찰율 적용된 공종 제외」. 계산은 EstBusiness.bid(행 묶음 지정)를 그대로 쓴다. +const EstBid = (() => { + const num = (value) => Number(value) || 0; + + // 내역서 공종(Level>0) 행의 하위 상세 행 번호 — 원본 내역 집계와 같은 범위(같거나 높은 Level 전까지). + function subtree(rows, heading) { + const level = num(rows[heading]?.Level), + out = []; + if (level <= 0) return out; + for (let i = heading + 1; i < rows.length; i++) { + if (num(rows[i].Level) >= level || [54, 58, 59].includes(num(rows[i].LinkIndex))) break; + if (!num(rows[i].Level)) out.push(i); + } + return out; + } + + function scope(project, view, selection, range, skipApplied) { + const table = view === "HeadMainDat" ? "HeadMainDat" : "EstMainDat", + rows = project.tables[table] || [], + picked = [...selection].map(Number).sort((a, b) => a - b); + let indices; + if (range === "all") return null; + if (range === "rows") { + if (!picked.length) throw Error("낙찰률을 적용할 줄을 선택하세요."); + indices = picked; + } else { + if (table !== "EstMainDat") throw Error("공종별 적용은 내역서 화면에서 하세요."); + const headings = picked.filter((i) => num(rows[i]?.Level) > 0); + if (!headings.length) throw Error("낙찰률을 적용할 공종(제목 줄)을 선택하세요."); + indices = [...new Set(headings.flatMap((i) => subtree(rows, i)))]; + } + if (skipApplied) indices = indices.filter((i) => !rows[i].ApplyNacChal); + if (!indices.length) throw Error("적용할 줄이 없습니다."); + return { [table]: new Set(indices) }; + } + + function rates(values) { + const list = + values.method === "total" + ? [values.T, values.T, values.T, values.T] + : [values.N, values.G, values.J, values.S]; + if (list.some((n) => !Number.isFinite(n) || n < 0)) + throw Error("낙찰률은 0 이상의 수치입니다."); + return list; + } + + function open(state) { + const view = state.view; + EstActions.form( + "낙찰률 적용", + [ + { + name: "method", + label: "방식", + value: "bimog", + choices: [ + ["total", "전체낙찰율(한 비율)"], + ["bimog", "비목별낙찰율"], + ], + }, + { name: "T", label: "전체 %", type: "number", value: 100 }, + { name: "N", label: "노무 %", type: "number", value: 100 }, + { name: "G", label: "재료 %", type: "number", value: 100 }, + { name: "J", label: "경비 %", type: "number", value: 100 }, + { name: "S", label: "실적 %", type: "number", value: 100 }, + { + name: "range", + label: "적용방법", + value: "all", + choices: [ + ["all", "전체 라인에 적용"], + ["rows", "선택한 라인에 적용"], + ["groups", "선택한 공종(하위 줄)에 적용"], + ], + }, + { + name: "skip", + label: "낙찰 적용된 줄", + value: "keep", + choices: [ + ["keep", "다시 적용"], + ["skip", "제외"], + ], + }, + ], + (values) => { + const list = rates(values), + only = scope(state.project, view, state.selection, values.range, values.skip === "skip"); + EstApp.change(() => (state.project = EstBusiness.bid(state.project, list, only))); + }, + ); + } + + function handle(name, state) { + if (name !== "bid-rates") return false; + open(state); + return true; + } + + return { subtree, scope, rates, handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index f30499af4..16cb25921 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -604,7 +604,8 @@ const EstBusinessActions = (() => { (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) || (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) || (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) || - (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) + (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) || + (typeof EstBid !== "undefined" && EstBid.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index a4df4a97f..86a3f0bda 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -413,14 +413,16 @@ const EstBusiness = (() => { }); return rows; } - function bid(project, rates) { + // only: 적용할 행 묶음 { EstMainDat: Set(index), HeadMainDat: Set(index) } — 없으면 전체(원본 낙찰율 적용방법). + function bid(project, rates, only = null) { const next = copy(project); if (project.options && "SourceRefresh" in project.options) { next.options._preBidSourceRefresh ??= project.options.SourceRefresh; } next.options = { ...next.options, SourceRefresh: false }; for (const key of ["HeadMainDat", "EstMainDat"]) - for (const row of next.tables[key]) { + for (const [index, row] of next.tables[key].entries()) { + if (only && !only[key]?.has(index)) continue; if ( num(row.Level) > 0 || num(row.Index) > 0 || diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 571301a67..a82541fe8 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -63,6 +63,7 @@ const allowed = new Set([ "native-quantity-sheets.js", "native-excel-map.js", "native-transfer.js", + "native-bid.js", "native-issues.js", "native-attachments.js", "native-standard.js",