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",