feat(est-test): 기타경비 계산과 산업안전보건관리비 비교

- 원본 처리 근거대로 두 기초금액·두 대상액 식 중 작은 쪽 선택, 비교 줄 표시
- 샘플에 원본이 적어 둔 안전관리비 식을 그대로 재현

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 20:16:04 +09:00
co-authored by Claude Opus 5.5
parent ea50105ce5
commit d88eff4886
7 changed files with 293 additions and 1 deletions
+89
View File
@@ -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]
+3
View File
@@ -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방향 합계, 제출용 전체 인쇄.
+2
View File
@@ -32,6 +32,8 @@
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
`원가계산서 → 기타경비 계산`은 원본처럼 `(직접공사비 × 재료비율) + 직접노무비 + 간접노무비`와 `직접공사비 × 기초금액상한율` 중 작은 기초금액에 요율을 곱한 식을 기타경비 행에 넣습니다. `산업안전보건관리비 비교`는 `대상액 × 요율 × 1.2`와 `(대상액 + 관급자재/1.1) × 요율`(기초금액 더하기 선택) 중 작은 식을 고릅니다. 요율·비율·기초금액은 사용자가 넣으며, 고르지 않은 식은 아래 계산 제외 줄로 보일 수 있습니다.
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
+3
View File
@@ -52,6 +52,7 @@
<script defer src="native-tidy.js"></script>
<script defer src="native-multi-search.js"></script>
<script defer src="native-docs.js"></script>
<script defer src="native-wonga-tools.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -179,6 +180,8 @@
<button data-action="wonga-forms">원가계산서 양식 목록·등록</button>
<button data-action="jungsang-toggle">정산원가계산서 작성/해제</button
><button data-action="jungsang-init">당초금액 초기화</button>
<button data-action="wonga-gita">기타경비 계산</button
><button data-action="wonga-anjun">산업안전보건관리비 비교</button>
</div>
</details>
<details>
+2 -1
View File
@@ -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") {
+193
View File
@@ -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 };
})();
+1
View File
@@ -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",