From d88eff48864411e64801402a41540978509686ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 20:16:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EA=B8=B0=ED=83=80=EA=B2=BD?= =?UTF-8?q?=EB=B9=84=20=EA=B3=84=EC=82=B0=EA=B3=BC=20=EC=82=B0=EC=97=85?= =?UTF-8?q?=EC=95=88=EC=A0=84=EB=B3=B4=EA=B1=B4=EA=B4=80=EB=A6=AC=EB=B9=84?= =?UTF-8?q?=20=EB=B9=84=EA=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 처리 근거대로 두 기초금액·두 대상액 식 중 작은 쪽 선택, 비교 줄 표시 - 샘플에 원본이 적어 둔 안전관리비 식을 그대로 재현 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_wonga_tools.py | 89 ++++++++++ tools/est-test/PLAN.md | 3 + tools/est-test/README.md | 2 + tools/est-test/index.html | 3 + tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-wonga-tools.js | 193 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_wonga_tools.py create mode 100644 tools/est-test/native-wonga-tools.js diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py new file mode 100644 index 000000000..39f133e46 --- /dev/null +++ b/resources/tester/test_est_wonga_tools.py @@ -0,0 +1,89 @@ +"""EST 테스트 앱 원가 보조 계산(원본 TfrmWongaDiget 기타경비 계산·산업안전보건관리비 비교).""" + +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-wonga-tools.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_anjun_reproduces_original_formula_pair_in_sample(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + const plain = (t) => String(t).replace(/\s+/g, ''); + const row = p.tables.Wonga.find((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2); + const original = [row.VSusic, p.tables.Wonga.find((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) === 2).VSusic]; + const before = p.tables.Wonga.length; + const out = EstWongaTools.anjun(p, { youl: 3.15, base: 5349000, addBase: false, compare: true }); + const after = calc(p); + return { base: EstWongaTools.anjunBase(p.tables.Wonga), original, chosen: out.chosen, other: out.other, + rows: p.tables.Wonga.length === before, errors: after.calculation.errors.length }; + })()""" + ) + assert result["base"] == "(A + 4)" + assert result["chosen"] == "(A + 4) × 0.0315 × 1.2" + assert result["other"] == "((A + 4) + J/1.1) × 0.0315" + assert [x.replace(" ", "") for x in result["original"]] == [ + result["chosen"].replace(" ", ""), + result["other"].replace(" ", ""), + ] + assert result["rows"] is True and result["errors"] == 0 + + +def test_gita_picks_smaller_base_and_validates(): + result = run_js( + """(() => { + const p = { options: {}, tables: { Wonga: [ + { Gubun: 'A', Name: '직 접 공 사 비', Cost: 1000 }, { Gubun: 'B', Name: '직접노무비', Cost: 300 }, + { Gubun: 'C', Name: '간접노무비', Cost: 50 }, { Gubun: 'D', Name: '기 타 경 비', Youl: '5%' } ] } }; + const out = EstWongaTools.gita(p, { gcRatio: 0.4, baseRatio: 0.6, youl: 5.2, compare: true }); + const row = p.tables.Wonga[3], cmp = p.tables.Wonga[4]; + const errors = []; + for (const bad of [{ gcRatio: 2, baseRatio: 0.5, youl: 1 }, { gcRatio: 0.5, baseRatio: 0.5, youl: 11 }]) + try { EstWongaTools.gita(p, bad); } catch (e) { errors.push(e.message.slice(0, 4)); } + let none = ''; try { EstWongaTools.anjun({ options: {}, tables: { Wonga: [] } }, { youl: 1 }); } catch (e) { none = e.message; } + return { values: out.values, vs: row.VSusic, youl: row.Youl, cmp: [cmp.VSusic, cmp.CalcIndex], errors, none, + stored: [p.options.GitaGCRatio, p.options.GitaBaseRatio] }; + })()""" + ) + assert result["values"] == [750, 600] + assert result["vs"] == "(A × 0.6) × 0.052" + assert result["youl"] == "5.2%" + assert result["cmp"] == ["((A × 0.4) + B + C) × 0.052", 2] + assert result["errors"] == ["1 이하", "기타경비"] + assert result["none"] == "산업안전보건관리비 비목이 없습니다." + assert result["stored"] == [0.4, 0.6] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index e6414d262..d27004612 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -478,6 +478,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 갑지·표지·공사설명서·시방서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover/DatExplean/DatSiBang.opt — 디스어셈블 근거) — `midas-read.cjs` MIDAS 레코드·WPTools 글자 읽기 → `project.docs`, 편집 창, 공사명↔공사 이름 동기화, 공사금액 자동입력 두 방식·한글금액(일 생략 없음), 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 8·9·10·11. 문서 편집 작업은 ESTX 저장 거부. - 검증완료: `test_est_docs.py` 3개(한글금액·금액 줄·원가 도급액, 미리보기·모델 통과, 설치 sample.estx 갑지 「연습공사」·결재란·표지 2012년도·원본 재저장 동일·편집 시 ESTX 거부) 통과. ORCA 브라우저에서 갑지 편집 → 공사금액 「금 칠천구백육십만삼천원(₩79,603,000)」, 인쇄 미리보기에 결재란·위치·공사명·개요·금액·발주처 표시(단일 표 쪽 나누기에서 갑지 제외 수정), 샘플 복구. - 남음: 서식·여백·글꼴·표, 양식목록(문서), 공사금액 자동변경, 작업모드 2·4의 C 판. + - [x] 기타경비 계산·산업안전보건관리비 비교(원본 TfrmWongaDiget 모드 4·1, 0xd0bc80·0xd0b298 — 디스어셈블 근거, 내장 요율표 없음) — 작은 기초금액 식 선택·요율 입력·비교 줄(CalcIndex 2), 대상액(직접공사비 또는 재료비 소계+비과세+직접노무비)·관급/1.1·1.2배·기초금액(기본 5,349,000 원본 상수). + - 검증완료: `test_est_wonga_tools.py` 2개(샘플에서 원본이 적어 둔 식 `(A + 4) × 0.0315 × 1.2`·비교 `((A + 4) + J/1.1) × 0.0315` 그대로 재현·오류 0, 기타경비 작은 쪽·비교 줄·잘못된 입력 거부) 통과. ORCA 브라우저에서 안전관리비 비교 → 같은 식·도급액 유지, 기타경비는 직접공사비 행 없는 샘플에서 원본처럼 거부, 샘플 복구. + - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index ecac8d835..e4e23bebf 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -32,6 +32,8 @@ `편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다. +`원가계산서 → 기타경비 계산`은 원본처럼 `(직접공사비 × 재료비율) + 직접노무비 + 간접노무비`와 `직접공사비 × 기초금액상한율` 중 작은 기초금액에 요율을 곱한 식을 기타경비 행에 넣습니다. `산업안전보건관리비 비교`는 `대상액 × 요율 × 1.2`와 `(대상액 + 관급자재/1.1) × 요율`(기초금액 더하기 선택) 중 작은 식을 고릅니다. 요율·비율·기초금액은 사용자가 넣으며, 고르지 않은 식은 아래 계산 제외 줄로 보일 수 있습니다. + `원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다. `원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 9ca4bf4c5..c165f45ce 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -52,6 +52,7 @@ + @@ -179,6 +180,8 @@ +
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 810b98756..331ce0abc 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -612,7 +612,8 @@ const EstBusinessActions = (() => { (typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) || (typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) || (typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) || - (typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) + (typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) || + (typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-wonga-tools.js b/tools/est-test/native-wonga-tools.js new file mode 100644 index 000000000..65eefc12d --- /dev/null +++ b/tools/est-test/native-wonga-tools.js @@ -0,0 +1,193 @@ +// 원본 원가계산서 보조 계산(TfrmWongaDiget) — 기타경비 계산(popGita 모드 4, 0xd0bc80·확인 0xd09c89)과 +// 산업안전보건관리비 비교(popAnjun 모드 1, 식 0xd0b298·확인 0xd09973). 요율·기초금액은 사용자가 넣는다(내장 요율표 없음). +// 기타경비: 식1 = (직접공사비 × 재료비율) + 직접노무비 + 간접노무비, 식2 = 직접공사비 × 기초금액상한율 — 작은 기초금액 × 요율. +// 안전관리비: 대상액 T(직접공사비, 없으면 재료비 소계 + 비과세 + 직접노무비), 관급 = 「관급자재」 행. +// 기초금액 켬: Up (T × r + B) × 1.2 · Down (T + 관급/1.1) × r + B / 끔: Up T × r × 1.2 · Down (T + 관급/1.1) × r — 작은 쪽. +// 고르지 않은 식은 「비교 표시」를 켜면 바로 아래 계산 제외 행(CalcIndex 2)으로 남긴다. +const EstWongaTools = (() => { + const num = (value) => Number(value) || 0; + const plain = (text) => String(text ?? "").replace(/\s+/g, ""); + const key = (row) => row?.Gubun || row?.NumStr || ""; + const find = (rows, ...words) => + rows.find((r) => num(r.CalcIndex) !== 2 && words.every((w) => plain(r.Name).includes(w))); + const decimal = (value) => String(Number(value.toFixed(10))); + + function evaluate(rows, expression) { + const costs = new Map(rows.filter((r) => key(r)).map((r) => [String(key(r)), num(r.Cost)])); + return EstExpression.evaluate(expression, (name) => costs.get(String(name)), { + numericReferences: true, + }); + } + + function compareRow(rows, target, expression, show) { + const at = rows.indexOf(target), + existing = rows.findIndex( + (r, i) => i > at && num(r.CalcIndex) === 2 && plain(r.Name) === plain(target.Name), + ); + if (!show) { + if (existing >= 0) rows.splice(existing, 1); + return; + } + const row = + existing >= 0 ? rows[existing] : (rows.splice(at + 1, 0, { ...target }), rows[at + 1]); + Object.assign(row, { + Gubun: "", + NumStr: "", + Bimog1: "", + Bimog2: "", + VSusic: expression, + CalcIndex: 2, + Checked: false, + LinkIndex: 0, + }); + } + + function gita(project, { gcRatio, baseRatio, youl, compare }) { + const rows = project.tables.Wonga || []; + if (![gcRatio, baseRatio].every((v) => Number.isFinite(v) && v >= 0 && v <= 1)) + throw Error("1 이하의 수치값으로 입력 하세요"); + if (!(Number.isFinite(youl) && youl >= 0 && youl <= 10)) + throw Error("기타경비요율은 0~10% 입니다."); + const target = find(rows, "기타", "경비"), + a = find(rows, "직접공사비"), + b = find(rows, "직접노무비"), + c = find(rows, "간접노무비"); + if (!target) throw Error("기타경비 비목이 없습니다."); + if (!a) throw Error("직접공사비 비목이 없어 기타경비를 계산할 수 없습니다."); + const first = [`(${key(a)} × ${decimal(gcRatio)})`, key(b), key(c)].filter(Boolean).join(" + "), + second = `${key(a)} × ${decimal(baseRatio)}`; + const v1 = evaluate(rows, first), + v2 = evaluate(rows, second), + rate = decimal(youl / 100); + const up = `(${first}) × ${rate}`, + down = `(${second}) × ${rate}`, + [chosen, other] = v2 >= v1 ? [up, down] : [down, up]; + target.VSusic = chosen; + target.Youl = `${decimal(youl)}%`; + compareRow(rows, target, other, compare); + project.options.GitaGCRatio = gcRatio; + project.options.GitaBaseRatio = baseRatio; + return { chosen, other, values: [v1, v2] }; + } + + function anjunBase(rows) { + const direct = find(rows, "직접공사비"); + if (direct) return key(direct); + const dm = find(rows, "직접재료비"), + im = find(rows, "간접재료비"); + const subtotal = rows.find( + (r) => + dm && + String(r.VSusic ?? "").includes(key(dm)) && + (!im || String(r.VSusic ?? "").includes(key(im))), + ); + let t = subtotal ? key(subtotal) : [dm, im].filter(Boolean).map(key).join(" + "); + const free = find(rows, "비과세") || find(rows, "면세"); + if (free) t += ` + ${key(free)}`; + const labor = find(rows, "직접노무비"); + if (labor) t = `(${t} + ${key(labor)})`; + if (!t) throw Error("산업안전보건관리비 대상액 비목을 찾을 수 없습니다."); + return t; + } + + function anjun(project, { youl, base, addBase, compare }) { + const rows = project.tables.Wonga || []; + if (!(Number.isFinite(youl) && youl >= 0 && youl <= 15)) throw Error("요율은 0~15% 입니다."); + const target = find(rows, "산업안전", "보건"); + if (!target) throw Error("산업안전보건관리비 비목이 없습니다."); + const t = anjunBase(rows), + gov = find(rows, "관급자재"), + r = decimal(youl / 100), + b = addBase ? ` + ${Math.round(base)}` : ""; + const up = addBase ? `(${t} × ${r}${b}) × 1.2` : `${t} × ${r} × 1.2`; + const down = gov && num(gov.Cost) ? `(${t} + ${key(gov)}/1.1) × ${r}${b}` : `${t} × ${r}${b}`; + const vu = evaluate(rows, up), + vd = evaluate(rows, down), + [chosen, other] = vd > vu ? [up, down] : [down, up]; + target.VSusic = chosen; + target.Youl = `${decimal(youl)}%`; + compareRow(rows, target, other, compare); + project.options.AnjunBaseCost = base; + return { chosen, other, values: [vu, vd] }; + } + + function dialog(state, kind) { + const rows = state.project.tables.Wonga || [], + o = state.project.options, + rate = (row) => Number(String(row?.Youl ?? "").split("%")[0]) || 0; + const fields = + kind === "gita" + ? [ + { name: "gcRatio", label: "재료비율(0~1)", type: "number", value: o.GitaGCRatio ?? "" }, + { + name: "baseRatio", + label: "기초금액상한율(0~1)", + type: "number", + value: o.GitaBaseRatio ?? "", + }, + { + name: "youl", + label: "기타경비요율(%)", + type: "number", + value: rate(find(rows, "기타", "경비")), + }, + ] + : [ + { + name: "youl", + label: "안전관리비 요율(%)", + type: "number", + value: rate(find(rows, "산업안전", "보건")), + }, + { + name: "addBase", + label: "기초금액 더하기", + value: "no", + choices: [ + ["no", "더하지 않음"], + ["yes", "더함"], + ], + }, + { + name: "base", + label: "기초금액(원)", + type: "number", + value: o.AnjunBaseCost ?? 5349000, + }, + ]; + fields.push({ + name: "compare", + label: "비교 식 표시", + value: "yes", + choices: [ + ["yes", "아래 줄에 표시"], + ["no", "표시 안 함"], + ], + }); + EstActions.form( + kind === "gita" ? "기타경비 계산" : "산업안전보건관리비 비교", + fields, + (values) => { + const input = { + ...values, + compare: values.compare === "yes", + addBase: values.addBase === "yes", + }; + const run = (project) => (kind === "gita" ? gita(project, input) : anjun(project, input)); + run(structuredClone(state.project)); + let out; + EstApp.change((project) => (out = run(project))); + state.notice = `작은 쪽 식을 골랐습니다: ${out.chosen}`; + }, + ); + } + + function handle(name, state) { + if (name === "wonga-gita") dialog(state, "gita"); + else if (name === "wonga-anjun") dialog(state, "anjun"); + else return false; + return true; + } + + return { gita, anjun, anjunBase, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 162eacc06..3fee22390 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -74,6 +74,7 @@ const allowed = new Set([ "native-tidy.js", "native-multi-search.js", "native-docs.js", + "native-wonga-tools.js", "native-issues.js", "native-attachments.js", "native-standard.js",