From 78d5e9cacee4979b50cde24da9f1ce27088fe54a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 18:11:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(stmate-test):=20=EC=B0=A8=EC=88=98?= =?UTF-8?q?=EB=B3=84=20=EC=9B=90=EA=B0=80=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=80=EA=B2=BD=EC=84=A4=EA=B3=84=20=EB=8B=B9?= =?UTF-8?q?=EC=B4=88=C2=B7=EB=B3=80=EA=B2=BD=201=EC=9B=90=20=C2=B7=20?= =?UTF-8?q?=EB=B6=80=EA=B0=80=EC=84=B8=205=ED=83=9D=20=C2=B7=20=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EA=B3=84=EC=82=B0=20=EB=AA=A8=EB=93=88=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 차수(1~4)별 내역 수량·단가와 [n_KANJUB] 설정으로 원가계산서 계산(calculateAll.stages) - D_ 표시 항목 = 저장 금액 고정(변경 시 건강·장기요양·연금·퇴직공제 당초액 유지) - 변경 안전관리비(I_ANJUNAUTO=2) = 당초액 + 당초액 × 밑수 증감율 - 부가세 I_BUGA 0~4(공급가액·재료비·없음·산림조합·산림조합 면세품) · B_BUGA_UP +1원 · 추가금액 차수 칸 - kanjub_calc.js 로 원가계산서 분리(calc.js 700줄 한도) - 지킴 시험: 원가계산서 7건 차수별(변경 2건 당초·변경) 대조 · 장부 33.5% → 34.1% Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_stmate_calc.cjs | 17 +- resources/tester/test_stmate_kanjub_stc.py | 45 ++-- tools/stmate-test/PLAN.md | 20 +- tools/stmate-test/calc.js | 274 ++++----------------- tools/stmate-test/index.html | 1 + tools/stmate-test/kanjub_calc.js | 264 ++++++++++++++++++++ tools/stmate-test/kanjub_cfg.py | 64 +++-- tools/stmate-test/server.py | 11 +- 8 files changed, 413 insertions(+), 283 deletions(-) create mode 100644 tools/stmate-test/kanjub_calc.js diff --git a/resources/tester/helper_stmate_calc.cjs b/resources/tester/helper_stmate_calc.cjs index cd2291fcb..2efc2eec5 100644 --- a/resources/tester/helper_stmate_calc.cjs +++ b/resources/tester/helper_stmate_calc.cjs @@ -1,12 +1,17 @@ -// STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 원가계산서 금액을 냄 +// STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 차수별 원가계산서 금액을 냄 const C = require("../../tools/stmate-test/calc.js"); let buf = ""; process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { - const k = C.calculateAll(JSON.parse(buf)).kanjubOrig; - const out = { dM: k.dM, dL: k.dL, dS: k.dS, dT: k.dT }; - for (const it of k.items) out[it.code] = it.amount; - Object.assign(out, { ILBAN: k.ilbanAmt, IYUN_M: k.pretaxProfitAdj, GOV: k.govFinal }); - out.GRAND = k.grandTotal; + const res = C.calculateAll(JSON.parse(buf)); + const out = {}; + for (const [n, st] of Object.entries(res.stages)) { + const k = st.kanjub; + const o = { dM: k.dM, dL: k.dL, dS: k.dS, dT: k.dT }; + for (const it of k.items) o[it.code] = it.amount; + Object.assign(o, { ILBAN: k.ilbanAmt, IYUN_M: k.pretaxProfitAdj, GOV: k.govFinal }); + o.GRAND = k.grandTotal; + out[n] = o; + } process.stdout.write(JSON.stringify(out)); }); diff --git a/resources/tester/test_stmate_kanjub_stc.py b/resources/tester/test_stmate_kanjub_stc.py index 144269008..9543fc6cc 100644 --- a/resources/tester/test_stmate_kanjub_stc.py +++ b/resources/tester/test_stmate_kanjub_stc.py @@ -1,6 +1,7 @@ -"""STMATE 재현 앱 원가계산서 — 실무 STC 4건의 저장값(PRJLST.DBF · PRJLST.SMT)과 1원 대조. +"""STMATE 재현 앱 원가계산서 — 실무 STC 7건의 차수별 저장값(PRJLST.DBF n칸 · PRJLST.SMT [n_KANJUB])과 1원 대조. 정답은 STC 안 원본 저장값에서 읽음(코드에 박지 않음). 직접비는 내역 저장 단가 기준. +변경설계 2건은 당초(1)·변경(2) 차수 모두 대조 — D_ 당초액 유지 · 안전관리비 비례 · 산림조합 부가세 포함. """ import json @@ -21,23 +22,26 @@ STCS = { "울진2025": "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", "영월2024": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", "대흥2024": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", + "소광원설계": "25년 산불진화임도(기번8)(울진소광)/1. 설계원본/03-내역/22-2025년 산불진화임도사업(울진 금강송 소광).STC", + "영덕변경": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", + "소광변경1차": "25년 산불진화임도(기번8)(울진소광)/변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", } SMT_KEYS = { - "KANNO": "1_C_KANNO", - "SANBO": "1_C_SANBO", - "KOBO": "1_C_KOBO", - "KUNBO": "1_C_KUNBO", - "SLVBO": "1_C_SLVBO", - "YUNBO": "1_C_YUNBO", - "ANJUN": "1_C_ANJUN", - "KITA": "1_C_T_KITA", - "HWAN": "1_C_HWAN", - "EQIP": "1_C_EQIPSU", - "ILBAN": "1_C_ILBAN", - "IYUN_M": "1_C_IYUN_M", - "GOV": "1_C_UPDNKWN", + "KANNO": "C_KANNO", + "SANBO": "C_SANBO", + "KOBO": "C_KOBO", + "KUNBO": "C_KUNBO", + "SLVBO": "C_SLVBO", + "YUNBO": "C_YUNBO", + "ANJUN": "C_ANJUN", + "KITA": "C_T_KITA", + "HWAN": "C_HWAN", + "EQIP": "C_EQIPSU", + "ILBAN": "C_ILBAN", + "IYUN_M": "C_IYUN_M", + "GOV": "C_UPDNKWN", } -DBF_KEYS = {"dM": "MCOST1", "dL": "LCOST1", "dS": "SCOST1", "dT": "TCOST1", "GRAND": "COSTW1"} +DBF_KEYS = {"dM": "MCOST", "dL": "LCOST", "dS": "SCOST", "dT": "TCOST", "GRAND": "COSTW"} @pytest.mark.parametrize("name", list(STCS)) @@ -51,7 +55,12 @@ def test_kanjub_matches_stc_to_the_won(name): encoding="utf-8", ) ) - expected = {k: loaded["prjlst_smt"][v] for k, v in SMT_KEYS.items()} - expected |= {k: loaded["prjlst"][v] for k, v in DBF_KEYS.items()} - diffs = {k: (out[k], float(v)) for k, v in expected.items() if abs(out[k] - float(v)) >= 0.5} + assert out, f"{name} 원가계산서 없음" + diffs = {} + for n, got in out.items(): + expected = {k: loaded["prjlst_smt"][f"{n}_{v}"] for k, v in SMT_KEYS.items()} + expected |= {k: loaded["prjlst"][f"{v}{n}"] for k, v in DBF_KEYS.items()} + for k, v in expected.items(): + if abs(got[k] - float(v)) >= 0.5: + diffs[f"{n}:{k}"] = (got[k], float(v)) assert not diffs, f"{name} 불일치 {diffs}" diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index 70284fbe7..7eef69f23 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -17,11 +17,11 @@ - `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로 - `test_stmate_smt_eval.py` — 단가산출근거(D코드) 재계산: 원설계 5건(위 4건 + 소광 원설계) 282개 1원 · 낙찰률 단가 7건 127개 -- `test_stmate_kanjub_stc.py` — 실무 STC 4건(봉화 2024 · 울진 2025 · 영월 2024 · 울진 대흥 2024) 원가계산서 18항목(직접비 4 · 간접비 11 · 이윤감액 · 관급 · 총공사비) 1원 대조 · 직접비는 내역 저장 단가 기준 +- `test_stmate_kanjub_stc.py` — 실무 STC 7건 차수별(변경 2건은 당초·변경) 원가계산서 18항목(직접비 4 · 간접비 11 · 이윤감액 · 관급 · 총공사비) 1원 대조 · 직접비는 내역 저장 단가 기준 ## 진행률 -- 합계: 전체 246 · 구현 42 · 부분 81 · 없음 123 → 진행률 33.5%((42 + 81×0.5) / 246) · 구현만 세면 17.1% +- 합계: 전체 246 · 구현 44 · 부분 80 · 없음 122 → 진행률 34.1%((44 + 80×0.5) / 246) · 구현만 세면 17.9% ### G1 화면·메뉴 (구현 5 · 부분 21 · 없음 28) — 28.7% @@ -80,7 +80,7 @@ - [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 없음 - [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음 -### G2 계산 엔진 (구현 19 · 부분 23 · 없음 19) — 50.0% +### G2 계산 엔진 (구현 20 · 부분 22 · 없음 19) — 50.8% - [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84) - [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 없음 @@ -125,13 +125,13 @@ - [ ] F2-41 이윤/수수료 택일·이윤 직접입력 — 12번 §10.1-2 pn02 · 현재: 없음 - [x] F2-42 끝전조정(차액 세전환산 이윤 차감)·1원 보정 — 16번 §4.4 · REA 17 §5 · 현재: 구현(총공사비 기준 절사 · 절사 없음 = 저장 이윤감액 · 4건 1원) - [ ] F2-43 공급가액 절사 9택 — 12번 §10.2 · 현재: 없음 -- [ ] F2-44 부가세 설계 5택(산림조합 형식·면세품 포함) — 12번 §10.1 · 현재: 부분(10%·재료 10%·없음만, 산림조합 2택 없음) +- [x] F2-44 부가세 설계 5택(산림조합 형식·면세품 포함) — 12번 §10.1 · REA 20 · 현재: 구현(I_BUGA 0~4 · 올림 +1 · 영덕 면세품 대조 · 안전품질 F 항 미포함) - [ ] F2-45 공사손해보험·작업설 감액·비과세자재·매입세 — 12번 §3.1 · 현재: 없음 - [ ] F2-46 관급/사급 내용·사급자재/1.1·사급비 위치 4택 — 12번 §10.1 · 25번 §2.6 · 현재: 없음 - [ ] F2-47 도급공사비/도급예정액·관급 2종 절사·절상·반올림 — 12번 §10.2 · 현재: 부분(관급 한 갈래만) - [ ] F2-48 총공사비 절사 자리 — 12번 §10.2 · 현재: 부분(I_TOT_JEOL 읽음, 기본값 버그 calc.js 468) - [x] F2-49 간접비 일괄 절사 7택 — wM_KanJub cBx_K_JeolSa · 현재: 구현 -- [ ] F2-50 추가금액 1~9단계 삽입 — 11번 §9 · 25번 §1.3 · 현재: 부분(저장 단계 1~4 계산 반영 · 화면 입력·5칸·부가세 여부 없음) +- [ ] F2-50 추가금액 1~9단계 삽입 — 11번 §9 · 25번 §1.3 · 현재: 부분(저장 단계 1~4 · 차수 칸 · 부가세 여부 계산 반영 · 화면 입력 없음) - [ ] F2-51 간접비 순서 코드표(KAN_ORD_1/2) — 20번 §2.1 · 현재: 없음(순서가 코드에 고정) - [ ] F2-52 원가 일반형식 T — 12번 §3 · 현재: 부분(이행·하도급·석면·임금채권·고용개선·품질 빠짐) - [ ] F2-53 원가 수공형식 S — 12번 §4 · 현재: 부분(세부경비·일반관리비 밑수 다름) @@ -248,19 +248,19 @@ - [ ] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 없음 - [ ] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 부분(양식표 표시만, 출력에 미적용) -### G6 설계변경·계약·실행·기성 (구현 2 · 부분 9 · 없음 25) — 18.1% +### G6 설계변경·계약·실행·기성 (구현 3 · 부분 10 · 없음 23) — 22.2% -- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(2·3 칸을 변경·계약 값으로 덮어씀 view_prj_boq 560) +- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(계산 = 차수별 수량·단가·원가계산서 · 저장은 2·3 칸 덮어씀 view_prj_boq 560) - [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 없음(늘 BOQ11 server.py 465) - [ ] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 없음 - [ ] F6-04 변경 수량 입력 — 설계변경 · 현재: 부분(공종별 입력, 1 단위 칸 app.js 536) - [ ] F6-05 변경 단가·신규 공종·삭제 항목(0 유지) — 08번 §2 · 현재: 없음 - [x] F6-06 변경 직접비 당초·변경·증감 — 19번 §6.5 · 현재: 구현(calc.js 661~670) - [x] F6-07 간접비 변경 모드: 재계산 — 08번 §2 · 현재: 구현(calc.js 357) -- [ ] F6-08 간접비 변경 모드: 당초액 유지(건강·장기요양·연금·퇴직공제) — 19번 §6 · 35번 §4 · 현재: 부분(세 항목 고정, 퇴직공제·선택 없음) -- [ ] F6-09 간접비 변경 방법 A(비율)·방법 B(증감×율) — 08번 §2 · 현재: 부분(식만, 고를 화면 없음) +- [x] F6-08 간접비 변경 모드: 당초액 유지(건강·장기요양·연금·퇴직공제) — 19번 §6 · 35번 §4 · 현재: 구현(D_ 표시 항목 = 저장 금액 고정 · 변경 2건 대조) +- [ ] F6-09 간접비 변경 방법 A(비율)·방법 B(증감×율) — 08번 §2 · 현재: 부분(안전관리비 I_ANJUNAUTO=2 방법 A 대조 · 다른 항목·고를 화면 없음) - [ ] F6-10 보험료 변경 계산 3모드 — wM_KanJub cBx_CH_BO · 현재: 없음 -- [ ] F6-11 2차·3차 변경 계산 — wM_KanJub cBx_Chasu · 현재: 없음(차수 칸 처리 없음) +- [ ] F6-11 2차·3차 변경 계산 — wM_KanJub cBx_Chasu · 현재: 부분(차수 1~4 계산 길 · 2차수 2건 대조 · 3·4차 표본 없음) - [ ] F6-12 원가계산서 당초/변경 대비·변경분만 인쇄 — PM_Won_A_C · Cmbx_PORD1_H · 현재: 부분(화면 증감 표, 인쇄 양식 없음) - [ ] F6-13 설계변경내역서(0643·0644) — CHANGE_STR · 현재: 없음(0603과 같은 표 view_kanjub_prt 531) - [ ] F6-14 변경내역서(변경율)(0645) — CHANGE_AMT · 현재: 없음 diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js index 6d214af88..712990db2 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -61,6 +61,15 @@ const StmateCalc = (() => { : roundDown(val, digits); } + // 원가계산서(제잡비) 계산 — kanjub_calc.js + const calcKanjub = (globalThis.STMATE_KANJUB_FACTORY || require("./kanjub_calc.js"))({ + num, + fmt, + roundDown, + roundHalfUp, + applyRoundMode, + }); + function evalFormula(exprStr, vars = {}) { let s = String(exprStr || "") .replace(/'[^']*'/g, "") @@ -247,6 +256,7 @@ const StmateCalc = (() => { } function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) { + const slot = typeof useChangedQty === "number" ? useChangedQty : useChangedQty ? 2 : 1; const sys = data.sysConfig || {}, chnMode = sys.chnMode || "bimok", optOrder = (sys.optOrder || "SML").toUpperCase(); @@ -274,8 +284,19 @@ const StmateCalc = (() => { govRawTotal = 0, govFeeTotal = 0; const rows = (data.boqItems || []).map((row) => { - const u = lookupUnit(row.refId), - qty = Number(useChangedQty && row.changedQty !== undefined ? row.changedQty : row.qty) || 0; + const su = slot > 1 && row.units && row.units[slot]; + const u = su + ? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] }) + : lookupUnit(row.refId); + u.T = u.M + u.L + u.S; + const qty = + Number( + slot > 1 && row.qtys + ? row.qtys[slot - 1] + : slot > 1 && row.changedQty !== undefined + ? row.changedQty + : row.qty, + ) || 0; const rawM = u.M * qty, rawL = u.L * qty, rawS = u.S * qty, @@ -334,235 +355,6 @@ const StmateCalc = (() => { return Object.assign({ rows, directTotal: directT, totals }, totals); } - function calcKanjub(data, boqRes, origKanjubRes = null) { - const cfg = data.kanjubConfig || {}, - scheme = cfg.scheme || "T", - rates = cfg.rates || {}, - revModes = cfg.revModes || {}; - const indTruncDigits = Number(cfg.indirectTruncDigits) || 0; - const dM = boqRes.directM, - dL = boqRes.directL, - dS = boqRes.directS, - dT = boqRes.directTotal, - govMat = cfg.govMat ?? boqRes.govCombinedRaw, - govFee = cfg.govFee ?? boqRes.govFeeTotal, - govPureMat = govMat - govFee; - const adds = cfg.addAmounts || []; - const addSum = (stage) => - adds.filter((a) => a.stage === stage).reduce((acc, a) => acc + num(a.amount), 0); - const truncInd = (v) => roundDown(v, indTruncDigits); - - function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) { - const truncAmt = truncInd(rawCalcAmt); - const base = Object.assign( - { key: code, code, label, formula: baseDesc }, - { baseDesc, baseAmt, base: baseAmt, rate: ratePct, ratePct }, - ); - if (!origKanjubRes) return Object.assign(base, { amount: truncAmt, modeUsed: "원설계" }); - const origRow = origKanjubRes.itemMap[code], - origAmt = origRow ? origRow.amount : 0, - origBase = origRow ? origRow.baseAmt : 0; - const mode = revModes[code] || "recalculated"; - const finalAmt = - mode === "carry_original" - ? origAmt - : mode === "proportional" - ? origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0)) - : mode === "delta_rate" - ? origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100) - : truncAmt; - return Object.assign(base, { - amount: finalAmt, - origAmt, - deltaAmt: finalAmt - origAmt, - modeUsed: mode, - }); - } - - const items = [], - itemMap = {}; - const pushItem = (row) => (items.push(row), (itemMap[row.code] = row), row.amount); - const pushRate = (code, label, desc, base, defR) => { - const r = num(rates[code], defR); - return pushItem(resolveRow(code, label, desc, base, r, (base * r) / 100)); - }; - - const kanno = pushRate("KANNO", "간접노무비", "직접노무비", dL, 14.5); - const totalLabor = dL + kanno; - pushRate("SANBO", "산재보험료", "노무비(직노+간노)", totalLabor, 3.56); - pushRate("KOBO", "고용보험료", "노무비(직노+간노)", totalLabor, 1.01); - const kunbo = pushRate("KUNBO", "건강보험료", "직접노무비", dL, 3.545); - pushRate("SLVBO", "노인장기요양보험료", "건강보험료(확정액)", kunbo, 12.95); - pushRate("YUNBO", "연금보험료", "직접노무비", dL, 4.5); - pushRate("TBUGUM", "퇴직공제부금비", "직접노무비", dL, 0); - - const rAnjun = num(rates.ANJUN, 1.86), - anjunBaseAdd = num(cfg.anjunBaseAdd, 0); - const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat; - const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL, - anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv; - const anjunNormal = truncInd((anjunBaseWithGov * rAnjun) / 100) + anjunBaseAdd; - const anjunCap12 = truncInd(((anjunBaseNoGov * rAnjun) / 100 + anjunBaseAdd) * 1.2); - const anjunDesc = - scheme === "TA" || scheme === "SA" - ? "직접공사비+관급원자재/1.1 (+기초액)" - : "직재+직노+관급원자재/1.1 (+기초액)"; - pushItem( - resolveRow( - "ANJUN", - "산업안전보건관리비", - anjunDesc, - roundHalfUp(anjunBaseWithGov, 0), - rAnjun, - cfg.anjunCap12 !== false ? Math.min(anjunNormal, anjunCap12) : anjunNormal, - ), - ); - - if (scheme === "S" || scheme === "SA") { - const suBase = dM + totalLabor; - [ - ["S_SUDO", "수도광열비", 0.45], - ["S_BOKRI", "복리후생비", 1.15], - ["S_SOMO", "소모품·사무용품비", 0.65], - ["S_TONGSIN", "여비·교통·통신비", 0.85], - ["S_SEGUM", "세금·공과금", 0.55], - ["S_PEGI", "폐기물처리비", 0.35], - ["S_DOSEO", "도서인쇄비", 0.25], - ["S_JIGUP", "지급수수료", 0.45], - ].forEach(([sc, sl, defR]) => - pushRate(sc, `[수공] ${sl}`, "재료비+노무비(직노+간노)", suBase, defR), - ); - } else { - pushRate( - "KITA", - "기타경비", - scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)", - scheme === "TA" ? dT : dM + totalLabor, - 6.0, - ); - } - - const hwanBase = scheme === "TA" ? dT : dM + dL + dS, - hwanDesc = scheme === "TA" ? "직접공사비(실적일반)" : "재료비+직접노무비+산출경비"; - pushRate("HWAN", "환경보전비", hwanDesc, hwanBase, 0.8); - pushRate("EQIP", "건설기계대여대금 지급보증", hwanDesc, hwanBase, 0.4); - - const indirectExpenseSum = items - .filter((x) => x.code !== "KANNO") - .reduce((acc, x) => acc + x.amount, 0); - const add1 = addSum(1), - totalExpense = dS + indirectExpenseSum + add1, - pureConstructionCost = dM + totalLabor + totalExpense; - - const rIlban = num(rates.ILBAN, 6.0), - ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost; - const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)", - ilbanAmt = truncInd((ilbanBase * rIlban) / 100); - - const rIyun = num(rates.IYUN, 15.0); - const iyunBase = - scheme === "TA" || scheme === "SA" - ? pureConstructionCost + ilbanAmt - : totalLabor + totalExpense + ilbanAmt; - const iyunDesc = - scheme === "TA" || scheme === "SA" - ? "직접비+간접비+일반관리비(실적형식)" - : "노무비+경비+일반관리비(재료비 제외)"; - const iyunRaw = truncInd((iyunBase * rIyun) / 100); - - const vatMode = cfg.vatMode || "supply_10", - totJeolDigits = num(cfg.totJeolDigits, -3), - add2 = addSum(2), - add3 = addSum(3), - add4 = addSum(4); - const calcVat = (sVal) => - vatMode === "zero" - ? 0 - : vatMode === "mat_10" - ? roundDown(dM * 0.1, 0) - : vatMode === "mat_exp_10" - ? roundDown((dM + dS) * 0.1, 0) - : roundDown(sVal * 0.1, 0); - - const govFinal = - govMat > 0 - ? applyRoundMode(govMat, cfg.govRoundMode || "up", num(cfg.govRoundDigits, -3)) - : 0; - const iyunManual = num(cfg.iyunManualAdj, 0), - supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw - iyunManual + add2, - vatBeforeAdj = calcVat(supplyBeforeAdj), - contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj + add3; - let contractTarget = contractBeforeAdj, - diffTotal = 0, - pretaxProfitAdj = iyunManual, - iyunFinal = iyunRaw - iyunManual, - supplyFinal = supplyBeforeAdj, - vatFinal = vatBeforeAdj; - - if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) { - // 총액 절사 = 총공사비(도급 + 관급 + 단계6 추가) 기준 · 차액은 도급에서 뺌 - contractTarget = - roundDown(contractBeforeAdj + govFinal + add4, totJeolDigits) - govFinal - add4; - diffTotal = contractBeforeAdj - contractTarget; - pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal; - iyunFinal = iyunRaw - pretaxProfitAdj; - supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal + add2; - vatFinal = calcVat(supplyFinal); - if (supplyFinal + vatFinal + add3 !== contractTarget) - vatFinal = contractTarget - supplyFinal - add3; - } - const contractFinal = supplyFinal + vatFinal + add3; - const grandTotal = contractFinal + govFinal + add4; - const mkLine = (key, label, formula, baseAmt, rate, amount) => - Object.fromEntries([ - ["key", key], - ["label", label], - ["formula", formula], - ["baseAmt", baseAmt], - ["base", baseAmt], - ["rate", rate], - ["amount", amount], - ]); - const totStr = "재료비 + 노무비 + 경비", - iyunStr = `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`, - supStr = "순공사원가 + 일반관리비 + 이윤", - finStr = "공급가액 + 부가가치세 (천원절사)", - govStr = `관급원자재(${fmt(govPureMat)}) + 수수료(${fmt(govFee)})`; - const pCost = pureConstructionCost; - const lines = [ - mkLine("DIRECT_M", "직접재료비", "내역서 재료비 합계", dM, null, dM), - mkLine("DIRECT_L", "직접노무비", "내역서 직접노무비 합계", dL, null, dL), - mkLine("DIRECT_S", "산출경비", "내역서 산출경비 합계", dS, null, dS), - mkLine("DIRECT_COST", "직접공사비 계", "직재 + 직노 + 산출경비", dT, null, dT), - ...items.map((it) => mkLine(it.key, it.label, it.formula, it.baseAmt, it.rate, it.amount)), - mkLine("TOTAL_COST", "순공사원가 계", totStr, pCost, null, pCost), - mkLine("ILBAN", "일반관리비", ilbanDesc, ilbanBase, rIlban, ilbanAmt), - mkLine("IYUN", "이윤 (끝전보정 후)", iyunStr, iyunBase, rIyun, iyunFinal), - mkLine("SUPPLY_VAL", "총원가 (공급가액)", supStr, supplyFinal, null, supplyFinal), - mkLine("VAT", "부가가치세", "공급가액 × 10%", supplyFinal, 10, vatFinal), - mkLine("FINAL_SUPPLY", "도급공사비", finStr, contractBeforeAdj, null, contractFinal), - mkLine("GOV_MAT", "관급자재대", govStr, govMat, null, govFinal), - mkLine("GRAND_TOTAL", "총공사비 (COSTW1)", "도급 + 관급", grandTotal, null, grandTotal), - ]; - - const out = { scheme, dM, dL, dS, dT, kanno, totalLabor }; - Object.assign(out, { indirectExpenseSum, totalExpense, pureConstructionCost: pCost }); - Object.assign(out, { ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw }); - Object.assign(out, { pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj }); - Object.assign(out, { contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal }); - Object.assign(out, { govRawTotal: govPureMat, govFeeTotal: govFee, adds }); - Object.assign(out, { govFinal, grandTotal, items, itemMap, lines }); - Object.assign(out, { DIRECT_COST: dT, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum }); - Object.assign(out, { TOTAL_COST: pCost, ILBAN: ilbanAmt, IYUN_INIT: iyunRaw }); - Object.assign(out, { PROFIT_ADJ: -pretaxProfitAdj, IYUN: iyunFinal, SUPPLY_VAL: supplyFinal }); - return Object.assign(out, { - VAT: vatFinal, - FINAL_SUPPLY: contractFinal, - GOV_MAT: govFinal, - GRAND_TOTAL: grandTotal, - }); - } - function evalKOrdTransition(pclass, regMode, flag, currentK) { const K = Number(currentK) || 1; const nextK = @@ -630,8 +422,25 @@ const StmateCalc = (() => { const ilwi = calcIlwi(data, resources.map, machines.map, sanCal.map); const boqOrig = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, false), kanjubOrig = calcKanjub(data, boqOrig, null); + const st = data.kanjubStages || {}; const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true), - kanjubRev = calcKanjub(data, boqRev, kanjubOrig); + kanjubRev = st[2] + ? calcKanjub(data, boqRev, kanjubOrig, st[2]) + : calcKanjub(data, boqRev, kanjubOrig); + const stages = {}; + for (const n of Object.keys(st).map(Number)) { + const boq = + n === 1 + ? boqOrig + : n === 2 + ? boqRev + : calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, n); + stages[n] = { + boq, + kanjub: + n === 1 ? kanjubOrig : n === 2 ? kanjubRev : calcKanjub(data, boq, kanjubOrig, st[n]), + }; + } const revMap = Object.fromEntries((boqRev.rows || []).map((r) => [r.id, r])); (boqOrig.rows || []).forEach((r) => { const rv = revMap[r.id]; @@ -644,6 +453,7 @@ const StmateCalc = (() => { kanjubRev, resList: resources.list, revComputed: { revBoq: boqRev, revKj: kanjubRev }, + stages, }); return Object.assign(base, { boqComputed: boqOrig, kanjubComputed: kanjubOrig }); } diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index 9a917a1ca..fc1ac8815 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -170,6 +170,7 @@ + diff --git a/tools/stmate-test/kanjub_calc.js b/tools/stmate-test/kanjub_calc.js new file mode 100644 index 000000000..778e01e22 --- /dev/null +++ b/tools/stmate-test/kanjub_calc.js @@ -0,0 +1,264 @@ +/* -*- coding: utf-8 -*- */ +/** + * 원가계산서(제잡비) 계산 (kanjub_calc.js) — 직접비 → 간접비 → 일반관리비 → 이윤 → 부가세 → 끝전 → 관급 → 총공사비 + * 근거: 지식DB STmate/old/20_분석 06 · 16 · 25 · REA 17 §5 · REA 20(부가세·관급·추가금액 순서) + * 정답 대조: resources/tester/test_stmate_kanjub_stc.py + */ +const StmateKanjubFactory = ({ num, fmt, roundDown, roundHalfUp, applyRoundMode }) => { + function calcKanjub(data, boqRes, origKanjubRes = null, stageCfg = null) { + const cfg = stageCfg || data.kanjubConfig || {}, + fixed = cfg.fixed || {}, + scheme = cfg.scheme || "T", + rates = cfg.rates || {}, + revModes = cfg.revModes || {}; + const indTruncDigits = Number(cfg.indirectTruncDigits) || 0; + const dM = boqRes.directM, + dL = boqRes.directL, + dS = boqRes.directS, + dT = boqRes.directTotal, + govMat = cfg.govMat ?? boqRes.govCombinedRaw, + govFee = cfg.govFee ?? boqRes.govFeeTotal, + govPureMat = govMat - govFee; + const adds = cfg.addAmounts || []; + const addSum = (stage) => + adds.filter((a) => a.stage === stage).reduce((acc, a) => acc + num(a.amount), 0); + const truncInd = (v) => roundDown(v, indTruncDigits); + + function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) { + // D_ 표시 항목 = 저장 금액 고정(변경 차수의 당초액 유지) + const truncAmt = fixed[code] != null ? fixed[code] : truncInd(rawCalcAmt); + const base = Object.assign( + { key: code, code, label, formula: baseDesc }, + { baseDesc, baseAmt, base: baseAmt, rate: ratePct, ratePct }, + ); + if (!origKanjubRes) return Object.assign(base, { amount: truncAmt, modeUsed: "원설계" }); + const origRow = origKanjubRes.itemMap[code], + origAmt = origRow ? origRow.amount : 0, + origBase = origRow ? origRow.baseAmt : 0; + const mode = revModes[code] || "recalculated"; + const finalAmt = + mode === "carry_original" + ? origAmt + : mode === "proportional" + ? origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0)) + : mode === "delta_rate" + ? origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100) + : truncAmt; + return Object.assign(base, { + amount: finalAmt, + origAmt, + deltaAmt: finalAmt - origAmt, + modeUsed: mode, + }); + } + + const items = [], + itemMap = {}; + const pushItem = (row) => (items.push(row), (itemMap[row.code] = row), row.amount); + const pushRate = (code, label, desc, base, defR) => { + const r = num(rates[code], defR); + return pushItem(resolveRow(code, label, desc, base, r, (base * r) / 100)); + }; + + const kanno = pushRate("KANNO", "간접노무비", "직접노무비", dL, 14.5); + const totalLabor = dL + kanno; + pushRate("SANBO", "산재보험료", "노무비(직노+간노)", totalLabor, 3.56); + pushRate("KOBO", "고용보험료", "노무비(직노+간노)", totalLabor, 1.01); + const kunbo = pushRate("KUNBO", "건강보험료", "직접노무비", dL, 3.545); + pushRate("SLVBO", "노인장기요양보험료", "건강보험료(확정액)", kunbo, 12.95); + pushRate("YUNBO", "연금보험료", "직접노무비", dL, 4.5); + pushRate("TBUGUM", "퇴직공제부금비", "직접노무비", dL, 0); + + const rAnjun = num(rates.ANJUN, 1.86), + anjunBaseAdd = num(cfg.anjunBaseAdd, 0); + const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat; + const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL, + anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv; + const anjunNormal = truncInd((anjunBaseWithGov * rAnjun) / 100) + anjunBaseAdd; + const anjunCap12 = truncInd(((anjunBaseNoGov * rAnjun) / 100 + anjunBaseAdd) * 1.2); + // 변경 차수 자동(I_ANJUNAUTO=2) = 당초액 + 당초액 × 밑수 증감율(지식DB 11 §10 방법 A) + const o = origKanjubRes; + const anjunChange = + cfg.anjunAuto === 2 && o && o.anjunBaseRaw > 0 + ? o.itemMap.ANJUN.amount + + truncInd((o.itemMap.ANJUN.amount * (anjunBaseWithGov - o.anjunBaseRaw)) / o.anjunBaseRaw) + : null; + const anjunDesc = + scheme === "TA" || scheme === "SA" + ? "직접공사비+관급원자재/1.1 (+기초액)" + : "직재+직노+관급원자재/1.1 (+기초액)"; + pushItem( + resolveRow( + "ANJUN", + "산업안전보건관리비", + anjunDesc, + roundHalfUp(anjunBaseWithGov, 0), + rAnjun, + fixed.ANJUN != null + ? fixed.ANJUN + : anjunChange != null + ? anjunChange + : cfg.anjunCap12 !== false + ? Math.min(anjunNormal, anjunCap12) + : anjunNormal, + ), + ); + + if (scheme === "S" || scheme === "SA") { + const suBase = dM + totalLabor; + [ + ["S_SUDO", "수도광열비", 0.45], + ["S_BOKRI", "복리후생비", 1.15], + ["S_SOMO", "소모품·사무용품비", 0.65], + ["S_TONGSIN", "여비·교통·통신비", 0.85], + ["S_SEGUM", "세금·공과금", 0.55], + ["S_PEGI", "폐기물처리비", 0.35], + ["S_DOSEO", "도서인쇄비", 0.25], + ["S_JIGUP", "지급수수료", 0.45], + ].forEach(([sc, sl, defR]) => + pushRate(sc, `[수공] ${sl}`, "재료비+노무비(직노+간노)", suBase, defR), + ); + } else { + pushRate( + "KITA", + "기타경비", + scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)", + scheme === "TA" ? dT : dM + totalLabor, + 6.0, + ); + } + + const hwanBase = scheme === "TA" ? dT : dM + dL + dS, + hwanDesc = scheme === "TA" ? "직접공사비(실적일반)" : "재료비+직접노무비+산출경비"; + pushRate("HWAN", "환경보전비", hwanDesc, hwanBase, 0.8); + pushRate("EQIP", "건설기계대여대금 지급보증", hwanDesc, hwanBase, 0.4); + + const indirectExpenseSum = items + .filter((x) => x.code !== "KANNO") + .reduce((acc, x) => acc + x.amount, 0); + const add1 = addSum(1), + totalExpense = dS + indirectExpenseSum + add1, + pureConstructionCost = dM + totalLabor + totalExpense; + + const rIlban = num(rates.ILBAN, 6.0), + ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost; + const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)", + ilbanAmt = fixed.ILBAN != null ? fixed.ILBAN : truncInd((ilbanBase * rIlban) / 100); + + const rIyun = num(rates.IYUN, 15.0); + const iyunBase = + scheme === "TA" || scheme === "SA" + ? pureConstructionCost + ilbanAmt + : totalLabor + totalExpense + ilbanAmt; + const iyunDesc = + scheme === "TA" || scheme === "SA" + ? "직접비+간접비+일반관리비(실적형식)" + : "노무비+경비+일반관리비(재료비 제외)"; + const iyunRaw = truncInd((iyunBase * rIyun) / 100); + + const vatMode = cfg.vatMode || "supply_10", + totJeolDigits = num(cfg.totJeolDigits, -3), + add2 = addSum(2), + add3 = addSum(3), + add4 = addSum(4); + // 부가세(I_BUGA · REA 20) — 0 공급가액 · 1 재료비 · 2 없음 · 3 산림조합(직접비−노무비+부가세 붙는 추가금액) + // · 4 산림조합 면세품(3 에서 면세품 C_BUGA_M 뺌) · 올림 표시(B_BUGA_UP)면 +1원 + const vatUp = cfg.vatRoundUp ? 1 : 0, + addVat = adds + .filter((a) => a.stage === 2 && a.vat) + .reduce((acc, a) => acc + num(a.amount), 0), + forestBase = dT - dL + addVat - (vatMode === "forest_exempt" ? num(cfg.vatExempt) : 0); + const calcVat = (sVal) => + vatMode === "zero" + ? 0 + : vatMode === "mat_10" + ? roundDown(dM * 0.1, 0) + vatUp + : vatMode === "forest" || vatMode === "forest_exempt" + ? roundDown(forestBase * 0.1, 0) + vatUp + : roundDown(sVal * 0.1, 0) + vatUp; + + const govFinal = + govMat > 0 + ? applyRoundMode(govMat, cfg.govRoundMode || "up", num(cfg.govRoundDigits, -3)) + : 0; + const iyunManual = num(cfg.iyunManualAdj, 0), + supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw - iyunManual + add2, + vatBeforeAdj = calcVat(supplyBeforeAdj), + contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj + add3; + let contractTarget = contractBeforeAdj, + diffTotal = 0, + pretaxProfitAdj = iyunManual, + iyunFinal = iyunRaw - iyunManual, + supplyFinal = supplyBeforeAdj, + vatFinal = vatBeforeAdj; + + if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) { + // 총액 절사 = 총공사비(도급 + 관급 + 단계6 추가) 기준 · 차액은 도급에서 뺌 + contractTarget = + roundDown(contractBeforeAdj + govFinal + add4, totJeolDigits) - govFinal - add4; + diffTotal = contractBeforeAdj - contractTarget; + pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal; + iyunFinal = iyunRaw - pretaxProfitAdj; + supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal + add2; + vatFinal = calcVat(supplyFinal); + if (supplyFinal + vatFinal + add3 !== contractTarget) + vatFinal = contractTarget - supplyFinal - add3; + } + const contractFinal = supplyFinal + vatFinal + add3; + const grandTotal = contractFinal + govFinal + add4; + const mkLine = (key, label, formula, baseAmt, rate, amount) => + Object.fromEntries([ + ["key", key], + ["label", label], + ["formula", formula], + ["baseAmt", baseAmt], + ["base", baseAmt], + ["rate", rate], + ["amount", amount], + ]); + const totStr = "재료비 + 노무비 + 경비", + iyunStr = `${iyunDesc} (보정 -${fmt(pretaxProfitAdj)})`, + supStr = "순공사원가 + 일반관리비 + 이윤", + finStr = "공급가액 + 부가가치세 (천원절사)", + govStr = `관급원자재(${fmt(govPureMat)}) + 수수료(${fmt(govFee)})`; + const pCost = pureConstructionCost; + const lines = [ + mkLine("DIRECT_M", "직접재료비", "내역서 재료비 합계", dM, null, dM), + mkLine("DIRECT_L", "직접노무비", "내역서 직접노무비 합계", dL, null, dL), + mkLine("DIRECT_S", "산출경비", "내역서 산출경비 합계", dS, null, dS), + mkLine("DIRECT_COST", "직접공사비 계", "직재 + 직노 + 산출경비", dT, null, dT), + ...items.map((it) => mkLine(it.key, it.label, it.formula, it.baseAmt, it.rate, it.amount)), + mkLine("TOTAL_COST", "순공사원가 계", totStr, pCost, null, pCost), + mkLine("ILBAN", "일반관리비", ilbanDesc, ilbanBase, rIlban, ilbanAmt), + mkLine("IYUN", "이윤 (끝전보정 후)", iyunStr, iyunBase, rIyun, iyunFinal), + mkLine("SUPPLY_VAL", "총원가 (공급가액)", supStr, supplyFinal, null, supplyFinal), + mkLine("VAT", "부가가치세", "공급가액 × 10%", supplyFinal, 10, vatFinal), + mkLine("FINAL_SUPPLY", "도급공사비", finStr, contractBeforeAdj, null, contractFinal), + mkLine("GOV_MAT", "관급자재대", govStr, govMat, null, govFinal), + mkLine("GRAND_TOTAL", "총공사비 (COSTW1)", "도급 + 관급", grandTotal, null, grandTotal), + ]; + + const out = { scheme, dM, dL, dS, dT, kanno, totalLabor }; + Object.assign(out, { indirectExpenseSum, totalExpense, pureConstructionCost: pCost }); + Object.assign(out, { ilbanBase, ilbanDesc, ilbanAmt, iyunBase, iyunDesc, iyunRaw }); + Object.assign(out, { pretaxProfitAdj, iyunFinal, supplyBeforeAdj, vatBeforeAdj }); + Object.assign(out, { contractBeforeAdj, diffTotal, supplyFinal, vatFinal, contractFinal }); + Object.assign(out, { govRawTotal: govPureMat, govFeeTotal: govFee, adds }); + out.anjunBaseRaw = anjunBaseWithGov; + Object.assign(out, { govFinal, grandTotal, items, itemMap, lines }); + Object.assign(out, { DIRECT_COST: dT, KANNO: kanno, IND_EXP: kanno + indirectExpenseSum }); + Object.assign(out, { TOTAL_COST: pCost, ILBAN: ilbanAmt, IYUN_INIT: iyunRaw }); + Object.assign(out, { PROFIT_ADJ: -pretaxProfitAdj, IYUN: iyunFinal, SUPPLY_VAL: supplyFinal }); + return Object.assign(out, { + VAT: vatFinal, + FINAL_SUPPLY: contractFinal, + GOV_MAT: govFinal, + GRAND_TOTAL: grandTotal, + }); + } + + return calcKanjub; +}; + +if (typeof window !== "undefined") window.STMATE_KANJUB_FACTORY = StmateKanjubFactory; +if (typeof module !== "undefined" && module.exports) module.exports = StmateKanjubFactory; diff --git a/tools/stmate-test/kanjub_cfg.py b/tools/stmate-test/kanjub_cfg.py index e69f4dcf2..5fb57e175 100644 --- a/tools/stmate-test/kanjub_cfg.py +++ b/tools/stmate-test/kanjub_cfg.py @@ -52,8 +52,25 @@ def _num(kv: dict, key: str, default: float = 0.0) -> float: return default -def add_amounts(kv: dict) -> list[dict]: - """ADD_AMT_<단계>_<행>_<칸> — 저장 단계 1~4 = 화면 단계 3~6(일반관리비 앞 · 이윤 뒤 · 도급 앞 · 총공사비 앞).""" +# D_ 표시(=1) 항목은 저장 금액(C_) 고정 — 변경 차수에서 당초액 유지(건강·장기요양·연금·퇴직공제 등) +FIXED_KEYS = { + "KANNO": "KANNO", + "SANBO": "SANBO", + "KOBO": "KOBO", + "KUNBO": "KUNBO", + "SLVBO": "SLVBO", + "YUNBO": "YUNBO", + "TBUGUM": "TBUGUM", + "ANJUN": "ANJUN", + "KITA": "T_KITA", + "HWAN": "HWAN", + "EQIP": "EQIPSU", + "ILBAN": "ILBAN", +} + + +def add_amounts(kv: dict, slot: int = 1) -> list[dict]: + """ADD_AMT_<단계>_<행>_<차수> — 단계 1~4 = 화면 단계 3~6(일반관리비 앞 · 이윤 뒤 · 도급 앞 · 총공사비 앞).""" out = [] for stage in range(1, 5): for i in range(1, int(_num(kv, f"ADD_CNT_{stage}")) + 1): @@ -62,36 +79,51 @@ def add_amounts(kv: dict) -> list[dict]: { "stage": stage, "name": kv.get(f"ADD_NAME_{row}", ""), - "amount": _num(kv, f"ADD_AMT_{row}_1"), + "amount": _num(kv, f"ADD_AMT_{row}_{slot}"), "vat": _num(kv, f"ADD_BUGA_{row}") == 1, } ) return out -def build_kanjub_config(kv: dict) -> dict: +VAT_MODES = {0: "supply_10", 1: "mat_10", 2: "zero", 3: "forest", 4: "forest_exempt"} + + +def stage_slots(kv: dict) -> list[int]: + """원가계산서가 저장된 차수 — [n_KANJUB] 묶음이 있는 차수.""" + return [n for n in range(1, 5) if any(k.startswith(f"{n}_C_") for k in kv)] or [1] + + +def build_kanjub_config(kv: dict, slot: int = 1) -> dict: jab_type = int(_num(kv, "JAB_TYPE")) cal_actl = int(_num(kv, "CAL_ACTL")) scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T") - tot_jeol = int(_num(kv, "1_I_TOT_JEOL")) + p = f"{slot}_" + tot_jeol = int(_num(kv, p + "I_TOT_JEOL")) return { + "slot": slot, "scheme": scheme, "indirectTruncDigits": 0, - "anjunBaseAdd": _num(kv, "1_C_ANJUN_G"), + "anjunBaseAdd": _num(kv, p + "C_ANJUN_G"), "anjunGovVatExcluded": True, "anjunCap12": True, - "vatMode": "supply_10", + "vatMode": VAT_MODES.get(int(_num(kv, p + "I_BUGA")), "zero"), + "vatExempt": _num(kv, p + "C_BUGA_M"), + "vatRoundUp": _num(kv, p + "B_BUGA_UP") == 1, + "anjunAuto": int(_num(kv, p + "I_ANJUNAUTO")), "totJeolDigits": -tot_jeol, "enableProfitEndAdj": tot_jeol > 0, - "iyunManualAdj": 0 if tot_jeol > 0 else _num(kv, "1_C_IYUN_M"), - "govMat": _num(kv, "1_C_KWANGUP") if "1_C_KWANGUP" in kv else None, - "govFee": _num(kv, "1_C_T_JODAL") if "1_C_T_JODAL" in kv else None, + "iyunManualAdj": 0 if tot_jeol > 0 else _num(kv, p + "C_IYUN_M"), + "govMat": _num(kv, p + "C_KWANGUP") if p + "C_KWANGUP" in kv else None, + "govFee": _num(kv, p + "C_T_JODAL") if p + "C_T_JODAL" in kv else None, "govRoundMode": "up", - "govRoundDigits": -int(_num(kv, "1_I_KWN_JEOLSA", 3)), - "addAmounts": add_amounts(kv), - "rates": {k: _num(kv, v) for k, v in RATE_KEYS.items()}, - "revModes": { - k: ("carry_original" if k in ("KUNBO", "SLVBO", "YUNBO") else "recalculated") - for k in REV_KEYS + "govRoundDigits": -int(_num(kv, p + "I_KWN_JEOLSA", 3)), + "addAmounts": add_amounts(kv, slot), + "rates": {k: _num(kv, v.replace("1_", p, 1)) for k, v in RATE_KEYS.items()}, + "fixed": { + code: _num(kv, p + "C_" + key) + for code, key in FIXED_KEYS.items() + if _num(kv, p + "D_" + key) == 1 }, + "revModes": {k: "recalculated" for k in REV_KEYS}, } diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index 47581836e..2a7406229 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -15,7 +15,7 @@ from pydantic import BaseModel from bdqty import decode_bdqty from excel_builder import build_stmate_full_xlsx -from kanjub_cfg import build_kanjub_config +from kanjub_cfg import build_kanjub_config, stage_slots from smt_parser import ( build_dbf_bytes, extract_costn_memos, @@ -329,6 +329,14 @@ def convert_dbf_to_sim_state( "rowType": row_type, "qty": q1, "changedQty": q2 if q2 > 0 else q1, + "qtys": [1.0] * 4 if is_pct_fee else [fnum(r.get(f"QTY{n}")) for n in range(1, 5)], + "units": { + n: [fnum(r.get(f"{c}_AMT{n}" if is_pct_fee else f"{c}AMT{n}")) for c in "MLS"] + for n in range(2, 5) + if any( + fnum(r.get(f"{c}_AMT{n}" if is_pct_fee else f"{c}AMT{n}")) for c in "MLS" + ) + }, "_recno": r.get("_recno", i), } ) @@ -365,6 +373,7 @@ def convert_dbf_to_sim_state( "ilwiItems": ilwi_items, "boqItems": boq_items, "kanjubConfig": build_kanjub_config(smt_kv), + "kanjubStages": {n: build_kanjub_config(smt_kv, n) for n in stage_slots(smt_kv)}, }