feat(est-test): 기타경비 계산과 산업안전보건관리비 비교
- 원본 처리 근거대로 두 기초금액·두 대상액 식 중 작은 쪽 선택, 비교 줄 표시 - 샘플에 원본이 적어 둔 안전관리비 식을 그대로 재현 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
@@ -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방향 합계, 제출용 전체 인쇄.
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
|
||||
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
|
||||
|
||||
`원가계산서 → 기타경비 계산`은 원본처럼 `(직접공사비 × 재료비율) + 직접노무비 + 간접노무비`와 `직접공사비 × 기초금액상한율` 중 작은 기초금액에 요율을 곱한 식을 기타경비 행에 넣습니다. `산업안전보건관리비 비교`는 `대상액 × 요율 × 1.2`와 `(대상액 + 관급자재/1.1) × 요율`(기초금액 더하기 선택) 중 작은 식을 고릅니다. 요율·비율·기초금액은 사용자가 넣으며, 고르지 않은 식은 아래 계산 제외 줄로 보일 수 있습니다.
|
||||
|
||||
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
|
||||
|
||||
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user