From 8013fd901b83bd52f7c5c2e2d9c66f6c9add01af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:12:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=B4=9D=EA=B4=84=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=EC=9B=90=EA=B0=80=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=ED=83=AD=20=C2=B7=20=EC=A0=9C=EB=B9=84=EC=9C=A8=20=EC=85=88=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=C2=B7=20TS=20=EC=A7=9D(145-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compute_total(sums, estimate) · computeTotal — 설계내역서 비목 합(bill.sums) + 사용자 요율 · 손입력 - 끝수 엑셀 그대로(줄마다 ROUNDDOWN 0 · 관급 ROUNDUP −3 · 이윤 − 보정 · 부가세 + 조정) · 안전관리비 A/B 작은 쪽 - 요율 빈칸 시작 · 빈칸이면 그 줄 · 소계 빈칸 · 후보 = M01 요율_조달청제비율 글(확정 아님) - 엑셀에 없는 현행 비목 다섯 = 줄만 · 원가계산서 = 총괄 거울 + 구성비 · 줄 누름 = 총괄 그 줄 - 엑셀 검산(순공사원가 868,874,829 · 총공사비 1,201,879,000) · 거울 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- B09_Estimation/B09_Estimation_Calc_Total.py | 161 +++++++++++++ B09_Estimation/B09_Estimation_Calc_Total.ts | 148 ++++++++++++ B09_Estimation/B09_Estimation_Rates.py | 47 ++++ B09_Estimation/B09_Estimation_Router.py | 8 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 8 +- .../B09_Estimation_UI_Tabs_Total.ts | 224 ++++++++++++++++++ resources/tester/test_145_4_b09_total_calc.py | 215 +++++++++++++++++ 7 files changed, 809 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Calc_Total.py create mode 100644 B09_Estimation/B09_Estimation_Calc_Total.ts create mode 100644 B09_Estimation/B09_Estimation_Rates.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Total.ts create mode 100644 resources/tester/test_145_4_b09_total_calc.py diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py new file mode 100644 index 000000000..ec0a8c6be --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -0,0 +1,161 @@ +"""B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. + +⚠ 짝 = `B09_Estimation_Calc_Total.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`. + 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. + +입력 + sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) + material 직접재료비 · labor 직접노무비 · expense 산출경비(관급 제외 순공사비계) · + supply_net 관급 순자재대(조달수수료 전) + estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 · + profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 · + scrap 작업설 · procurement_fee_pct 조달수수료율 +식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) · +산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 · +이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정. +빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · +손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total + +#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 +RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( + ("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"), + ("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"), + ("employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"), + ("health", "건강보험료", "RT000003", "직접노무비 × 율"), + ("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"), + ("pension", "연금보험료", "RT000004", "직접노무비 × 율"), + ("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"), + ("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"), + ("environment", "환경보전비", "RT000010", "순공사비 × 율"), + ("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"), +) +#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦) +CURRENT_ONLY: tuple[tuple[str, str, str], ...] = ( + ("retirement", "퇴직공제부금비", "RT000011"), + ("wage_claim", "임금채권보장분담금", "RT000012"), + ("asbestos", "석면피해구제분담금", "RT000013"), + ("subcontract", "하도급대금지급보증수수료", "RT000014"), + ("performance", "공사이행보증수수료", "RT000016"), +) +#: 소계 아래 줄 (키, 이름, 후보 키, 산식 글) +TAIL_LINES: tuple[tuple[str, str, str, str], ...] = ( + ("overhead", "일반관리비", "RT000017", "나.소계 × 율"), + ("profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"), + ("vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"), +) +_SAFETY_VAT_DIVISOR = 1.1 # 관급 순자재대가 부가세 포함 — 1.1 로 나눠 대상액에 +_SAFETY_B = 1.2 # B안 = (관급 빼고) × 1.2 + + +def _pct(base: float | None, rate: float | None) -> float | None: + """ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸.""" + return None if base is None or rate is None else rounddown(base * rate / 100) + + +def procurement_fee(net: float | None, pct: float | None) -> float | None: + """조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(엑셀 설계내역서 253 줄).""" + return _pct(net, pct) + + +def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]: + """총괄 제비율 줄 · 소계 · 관급 · 원가계산서 줄 · 구성비.""" + sums = sums if isinstance(sums, dict) else {} + est = estimate if isinstance(estimate, dict) else {} + rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {} + rate = {key: num(rates_in.get(key)) for key, *_ in (*RATE_LINES, *TAIL_LINES)} + + def hand(key: str) -> float: + return num(est.get(key)) or 0.0 + + material, labor = num(sums.get("material")), num(sums.get("labor")) + expense, supply_net = num(sums.get("expense")), num(sums.get("supply_net")) + direct = total([material, labor, expense]) # 가. 순공사비계 + + line: dict[str, float | None] = {} + line["indirect_labor"] = _pct(labor, rate["indirect_labor"]) + total_labor = total([labor, line["indirect_labor"]]) + line["accident"] = _pct(total_labor, rate["accident"]) + line["employment"] = _pct(total_labor, rate["employment"]) + line["health"] = _pct(labor, rate["health"]) + line["care"] = _pct(line["health"], rate["care"]) + line["pension"] = _pct(labor, rate["pension"]) + # 산업안전보건관리비 — A(관급 순자재대 ÷ 1.1 포함) · B(× 1.2) 중 작은 쪽(관급 없으면 A) + safety_a = safety_b = None + labor_material = total([labor, material]) + if labor_material is not None and rate["safety"] is not None: + base = hand("safety_base") + target = labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR + safety_a = rounddown(target * rate["safety"] / 100 + base) + safety_b = rounddown((labor_material * rate["safety"] / 100 + base) * _SAFETY_B) + line["safety"] = None if safety_a is None else min(safety_a, safety_b) + line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"]) + line["environment"] = _pct(direct, rate["environment"]) + line["machine"] = _pct(direct, rate["machine"]) + net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가 + line["overhead"] = _pct(net_cost, rate["overhead"]) + da = total([net_cost, line["overhead"]]) # 다 + profit_raw = _pct(None if da is None or material is None else da - material, rate["profit"]) + line["profit"] = None if profit_raw is None else profit_raw - hand("profit_adjust") + supply_price = total([da, line["profit"]]) # 라. 공급가액 + vat = _pct(supply_price, rate["vat"]) + line["vat"] = None if vat is None else vat + hand("vat_adjust") + contract = total([supply_price, line["vat"]]) # 마. 도급공사비 + # 관급자재대 = ROUNDUP(순자재대 + 조달수수료, −3) · 관급 0 이면 0 + fee = ( + 0.0 if supply_net == 0 else procurement_fee(supply_net, num(est.get("procurement_fee_pct"))) + ) + supply_raw = total([supply_net, fee]) + line["supply"] = None if supply_raw is None else roundup(supply_raw, -3) + grand = total([contract, line["supply"]]) # 바. 총공사비 + + indirect_material, scrap = hand("indirect_material"), hand("scrap") + cost = { + "direct_material": None if material is None else material - indirect_material + scrap, + "indirect_material": indirect_material, + "scrap": scrap, + "material": material, + "direct_labor": labor, + "indirect_labor": line["indirect_labor"], + "labor": total_labor, + "expense": expense, + **{key: line[key] for key in ("accident", "employment", "health", "care", "pension")}, + **{key: line[key] for key in ("safety", "other", "environment", "machine")}, + "expense_sum": total( + [expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")] + ), + "overhead": line["overhead"], + "profit": line["profit"], + "supply_price": supply_price, + "vat": line["vat"], + "contract": contract, + "supply": line["supply"], + "grand": grand, + } + share = { + key: (None if value is None or not grand else value / grand * 100) + for key, value in cost.items() + } + return { + "sums": {"material": material, "labor": labor, "expense": expense, "direct": direct}, + "lines": line, + "subtotals": { + "net_cost": net_cost, + "da": da, + "supply_price": supply_price, + "contract": contract, + "grand": grand, + }, + "safety": {"a": safety_a, "b": safety_b}, + "profit_raw": profit_raw, + "supply": {"net": supply_net, "fee": fee, "raw": supply_raw}, + "cost": cost, + "share": share, + } diff --git a/B09_Estimation/B09_Estimation_Calc_Total.ts b/B09_Estimation/B09_Estimation_Calc_Total.ts new file mode 100644 index 000000000..b42725d15 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Total.ts @@ -0,0 +1,148 @@ +/* ============================================================================= + * B09_Estimation_Calc_Total.ts + * B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 짝. + * + * ⚠ 짝 = `B09_Estimation_Calc_Total.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. + * + * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) + * 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로. + * ========================================================================== */ + +import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base"; + +type Num = number | null; + +/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */ +export const RATE_LINES: Array<[string, string, string, string]> = [ + ["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"], + ["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"], + ["employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"], + ["health", "건강보험료", "RT000003", "직접노무비 × 율"], + ["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"], + ["pension", "연금보험료", "RT000004", "직접노무비 × 율"], + ["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"], + ["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"], + ["environment", "환경보전비", "RT000010", "순공사비 × 율"], + ["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"], +]; +/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */ +export const CURRENT_ONLY: Array<[string, string, string]> = [ + ["retirement", "퇴직공제부금비", "RT000011"], + ["wage_claim", "임금채권보장분담금", "RT000012"], + ["asbestos", "석면피해구제분담금", "RT000013"], + ["subcontract", "하도급대금지급보증수수료", "RT000014"], + ["performance", "공사이행보증수수료", "RT000016"], +]; +/** 소계 아래 줄 */ +export const TAIL_LINES: Array<[string, string, string, string]> = [ + ["overhead", "일반관리비", "RT000017", "나.소계 × 율"], + ["profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"], + ["vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"], +]; +const SAFETY_VAT_DIVISOR = 1.1; +const SAFETY_B = 1.2; + +/** ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸 */ +const pct = (base: Num, rate: Num): Num => + base === null || rate === null ? null : rounddown((base * rate) / 100); + +/** 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0) */ +export const procurementFee = (net: Num, rate: Num): Num => pct(net, rate); + +export function computeTotal(sums: Row | null | undefined, estimate: Row | null | undefined): Row { + const s: Row = sums && typeof sums === "object" ? sums : {}; + const est: Row = estimate && typeof estimate === "object" ? estimate : {}; + const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {}; + const rate: Record = {}; + for (const [key] of [...RATE_LINES, ...TAIL_LINES]) rate[key] = num(ratesIn[key]); + const hand = (key: string): number => num(est[key]) || 0; + + const material = num(s.material); + const labor = num(s.labor); + const expense = num(s.expense); + const supplyNet = num(s.supply_net); + const direct = total([material, labor, expense]); + + const line: Record = {}; + line.indirect_labor = pct(labor, rate.indirect_labor); + const totalLabor = total([labor, line.indirect_labor]); + line.accident = pct(totalLabor, rate.accident); + line.employment = pct(totalLabor, rate.employment); + line.health = pct(labor, rate.health); + line.care = pct(line.health, rate.care); + line.pension = pct(labor, rate.pension); + let safetyA: Num = null; + let safetyB: Num = null; + const laborMaterial = total([labor, material]); + if (laborMaterial !== null && rate.safety !== null) { + const base = hand("safety_base"); + const target = laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR; + safetyA = rounddown((target * rate.safety) / 100 + base); + safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B); + } + line.safety = safetyA === null || safetyB === null ? null : Math.min(safetyA, safetyB); + line.other = pct(total([labor, line.indirect_labor, material]), rate.other); + line.environment = pct(direct, rate.environment); + line.machine = pct(direct, rate.machine); + const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]); + line.overhead = pct(netCost, rate.overhead); + const da = total([netCost, line.overhead]); + const profitRaw = pct(da === null || material === null ? null : da - material, rate.profit); + line.profit = profitRaw === null ? null : profitRaw - hand("profit_adjust"); + const supplyPrice = total([da, line.profit]); + const vat = pct(supplyPrice, rate.vat); + line.vat = vat === null ? null : vat + hand("vat_adjust"); + const contract = total([supplyPrice, line.vat]); + const fee = supplyNet === 0 ? 0 : procurementFee(supplyNet, num(est.procurement_fee_pct)); + const supplyRaw = total([supplyNet, fee]); + line.supply = supplyRaw === null ? null : roundup(supplyRaw, -3); + const grand = total([contract, line.supply]); + + const indirectMaterial = hand("indirect_material"); + const scrap = hand("scrap"); + const cost: Record = { + direct_material: material === null ? null : material - indirectMaterial + scrap, + indirect_material: indirectMaterial, + scrap, + material, + direct_labor: labor, + indirect_labor: line.indirect_labor, + labor: totalLabor, + expense, + accident: line.accident, + employment: line.employment, + health: line.health, + care: line.care, + pension: line.pension, + safety: line.safety, + other: line.other, + environment: line.environment, + machine: line.machine, + expense_sum: total([ + expense, + ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]), + ]), + overhead: line.overhead, + profit: line.profit, + supply_price: supplyPrice, + vat: line.vat, + contract, + supply: line.supply, + grand, + }; + const share: Record = {}; + for (const [key, value] of Object.entries(cost)) + share[key] = value === null || !grand ? null : (value / grand) * 100; + return { + sums: { material, labor, expense, direct }, + lines: line, + subtotals: { net_cost: netCost, da, supply_price: supplyPrice, contract, grand }, + safety: { a: safetyA, b: safetyB }, + profit_raw: profitRaw, + supply: { net: supplyNet, fee, raw: supplyRaw }, + cost, + share, + }; +} diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py new file mode 100644 index 000000000..73113b860 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -0,0 +1,47 @@ +"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄. + +후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음. +요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`). +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +FILE = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "요율_조달청제비율.json" + + +def _percents(value: Any) -> list[float]: + """값 안 모든 rate_percent — 구간 · 연도 · 공종 갈래 통째로.""" + if isinstance(value, dict): + own = value.get("rate_percent") + found = [float(own)] if isinstance(own, (int, float)) else [] + return found + [ + p for key, v in value.items() if key != "rate_percent" for p in _percents(v) + ] + if isinstance(value, list): + return [p for item in value for p in _percents(item)] + return [] + + +@lru_cache(maxsize=1) +def rate_candidates() -> dict[str, str]: + """{RT 키: 「M01 RT… 3.56%」 · 「… 1.01~1.57%(구간 · 고르는 값 사용자)」} — 파일 없으면 {}.""" + try: + rows = json.loads(FILE.read_text(encoding="utf-8")).get("줄") or [] + except (OSError, ValueError): + return {} + out: dict[str, str] = {} + for row in rows: + percents = sorted(set(_percents(row.get("값")))) + if not percents: + text = "구간 식(고르는 값 사용자)" + elif len(percents) == 1: + text = f"{percents[0]:g}%" + else: + text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)" + out[str(row.get("키"))] = f"M01 {row.get('키')} {text}" + return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 7ac339c94..aee09e9dd 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -26,7 +26,9 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Linked import linked_resource_keys +from B09_Estimation.B09_Estimation_Rates import rate_candidates router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -53,11 +55,15 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) user = await asyncio.to_thread(store.read_user, root) + sheets = compute_base(inputs, user) + # 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 + sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate")) return { "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, - "sheets": compute_base(inputs, user), + "sheets": sheets, + "rate_candidates": rate_candidates(), "notes": notes, } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c90e24ec7..cdda00fb7 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -25,6 +25,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; +import { computeTotal } from "./B09_Estimation_Calc_Total"; import { buildCheck, rowIndex, @@ -43,6 +44,7 @@ import { buildMachineBlocks, buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; +import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -64,6 +66,8 @@ const BUILDERS: Partial> = { labor: buildLabor, expense: buildExpense, compare: buildCompare, + cost: buildCost, + total: buildTotal, }; async function request(path: string, init: RequestInit = {}): Promise { @@ -89,7 +93,9 @@ function note(textValue: string): HTMLElement { /** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ export function computeSheets(payload: Row, user: Row): Row { - return computeBase(payload.inputs, user); + const sheets = computeBase(payload.inputs, user); + // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 + return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) }; } export function renderB09Estimation(root: HTMLElement): void { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts new file mode 100644 index 000000000..d24914c29 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Total.ts + * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. + * + * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) + * 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 · + * 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸) + * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total"; +import { + money, + parseNumber, + qty, + text, + type TabBuild, + type TabContext, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +type Num = number | null; + +const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US")); + +/** 손입력 칸 — 줄 키 → user.estimate 칸 */ +const HAND: Record = { + safety: ["safety_base", "기초액(원)"], + profit: ["profit_adjust", "이윤 보정(원 · 뺌)"], + vat: ["vat_adjust", "부가세 조정(원)"], + supply: ["procurement_fee_pct", "조달수수료율(%)"], + indirect_material: ["indirect_material", "간접재료비(원)"], + scrap: ["scrap", "작업설 부산물(원)"], +}; +const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); + +/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */ +function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void { + const v = parseNumber(value); + if (v === undefined) return; + const estimate: Row = { ...(ctx.user.estimate || {}) }; + if (column === "rate" && RATE_KEYS.has(rowKey)) { + const rates: Row = { ...(estimate.rates || {}) }; + if (v === null) delete rates[rowKey]; + else rates[rowKey] = v; + estimate.rates = rates; + } else if (HAND[rowKey]) { + const field = HAND[rowKey][0]; + if (v === null) delete estimate[field]; + else estimate[field] = v; + } else return; + ctx.setUser({ ...ctx.user, estimate }); +} + +function rateText(formula: string, rate: Num): string { + return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); +} + +export const buildTotal: TabBuild = (ctx): TabTable => { + const t: Row = ctx.sheets.total || {}; + const est: Row = ctx.user.estimate || {}; + const rates: Row = est.rates || {}; + const cand: Row = ctx.payload.rate_candidates || {}; + const line: Row = t.lines || {}; + const sub: Row = t.subtotals || {}; + const rate = (key: string): Num => (typeof rates[key] === "number" ? rates[key] : null); + const hand = (key: string): Num => { + const v = est[HAND[key][0]]; + return typeof v === "number" ? v : null; + }; + const row = ( + key: string, + part: string, + name: string, + amount: Num, + note = "", + extra: Partial = {}, + ): DataRow => ({ + key, + part, + name, + amount, + note, + rate: null, + hand: null, + candidate: "", + ...extra, + }); + const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) => + row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), { + rate: rate(key), + candidate: cand[rt] ?? rt, + ...(HAND[key] ? { hand: hand(key) } : {}), + }); + const safety: Row = t.safety || {}; + const supply: Row = t.supply || {}; + const sums: Row = t.sums || {}; + const rows: DataRow[] = [ + row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", { + labor: sums.labor ?? null, + material: sums.material ?? null, + expense: sums.expense ?? null, + }), + ...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)), + ...CURRENT_ONLY.map(([key, name, rt]) => + row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", { + candidate: cand[rt] ?? rt, + }), + ), + row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null), + rateRow(TAIL_LINES[0], RATE_LINES.length + 1), + row("da", "다.", "소계", sub.da ?? null), + rateRow(TAIL_LINES[1], RATE_LINES.length + 2), + row("supply_price", "라.", "공급가액", sub.supply_price ?? null), + rateRow(TAIL_LINES[2], RATE_LINES.length + 3), + row("contract", "마.", "도급공사비", sub.contract ?? null), + row( + "supply", + "", + `${RATE_LINES.length + 4}. 관급자재대`, + line.supply ?? null, + `순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`, + { hand: hand("supply") }, + ), + row("grand", "바.", "총공사비", sub.grand ?? null), + ]; + const safetyRow = rows.find((r) => r.key === "safety"); + if (safetyRow && safety.a !== null && safety.a !== undefined) + safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`; + const profitRow = rows.find((r) => r.key === "profit"); + if (profitRow && typeof t.profit_raw === "number") + profitRow.note = `${profitRow.note} · 보정 전 ${won(t.profit_raw)}`; + return { + columns: [ + text("part", "공종", 48), + text("name", "명칭", 210), + money("amount", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + qty("rate", "요율(%)", 5), + money("hand", "손입력", undefined, 2), + text("note", "산식", 320), + text("candidate", "요율 후보(확정 아님)", 260), + ], + rows, + frozen: 2, + hint: + "끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " + + "손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + editable: (i, key) => + (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || + (key === "hand" && String(rows[i]?.key) in HAND), + onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), + }; +}; + +/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ +const COST_ROWS: Array<[string, string, string, string, string?]> = [ + ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], + ["indirect_material", "순공사원가", "재료비", "간접재료비"], + ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], + ["material", "순공사원가", "재료비", "소계", "direct"], + ["direct_labor", "순공사원가", "노무비", "직접노무비", "direct"], + ["indirect_labor", "순공사원가", "노무비", "간접노무비", "indirect_labor"], + ["labor", "순공사원가", "노무비", "소계", "direct"], + ["expense", "순공사원가", "경비", "산출경비", "direct"], + ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map( + ([key, name]) => + [key, "순공사원가", "경비", name, key] as [string, string, string, string, string], + ), + ["expense_sum", "순공사원가", "경비", "소계", "net_cost"], + ["overhead", "일반관리비", "", "", "overhead"], + ["profit", "이윤", "", "", "profit"], + ["supply_price", "총원가", "", "", "supply_price"], + ["vat", "부가가치세", "", "", "vat"], + ["contract", "도급공사비", "", "", "contract"], + ["supply", "관급자재대", "", "", "supply"], + ["grand", "총공사비", "", "", "grand"], +]; + +export const buildCost: TabBuild = (ctx): TabTable => { + const t: Row = ctx.sheets.total || {}; + const cost: Row = t.cost || {}; + const share: Row = t.share || {}; + const rates: Row = (ctx.user.estimate || {}).rates || {}; + const formula: Record = Object.fromEntries( + [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ + key, + rateText(f, typeof rates[key] === "number" ? rates[key] : null), + ]), + ); + const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ + key, + group, + part, + name, + amount: cost[key] ?? null, + share: share[key] ?? null, + note: formula[key] ?? "", + link: link ?? "", + })); + return { + columns: [ + text("group", "구분", 96), + text("part", "비목", 72), + text("name", "세목", 180), + money("amount", "금액"), + qty("share", "구성비(%)", 2), + text("note", "비고", 320), + ], + rows, + frozen: 3, + hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", + editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, + onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), + link: (i) => { + const target = String(rows[i]?.link || ""); + return target ? { tab: "total", key: target } : null; + }, + }; +}; diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py new file mode 100644 index 000000000..a4a87e750 --- /dev/null +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""145-4 B09 총괄설계내역서 · 공사원가계산서 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`). + +견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 총괄 48 비목 합 · M49~M64 요율 · N55 기초액 · +I62 이윤 보정 · 설계내역서 J253 관급 순자재대 · D253 조달수수료율 → 총괄 D49~D67 · E55 A/B · 원가계산서 구성비. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} +RATES = { + "indirect_labor": 14.5, + "accident": 3.56, + "employment": 1.01, + "health": 3.545, + "care": 12.95, + "pension": 4.5, + "safety": 1.86, + "other": 6, + "environment": 0.8, + "machine": 0.4, + "overhead": 6, + "profit": 15, + "vat": 10, +} +ESTIMATE = { + "rates": RATES, + "safety_base": 5349000, + "profit_adjust": 905980, + "procurement_fee_pct": 0.54, +} + +EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66 + "indirect_labor": 50286080, + "accident": 14136284, + "employment": 4010575, + "health": 12294079, + "care": 1592083, + "pension": 15606024, + "safety": 16586996, + "other": 35479263, + "environment": 5682873, + "machine": 2841436, + "overhead": 52132489, + "profit": 108109955, + "vat": 102911727, + "supply": 69850000, +} +EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67 + "net_cost": 868874829, + "da": 921007318, + "supply_price": 1029117273, + "contract": 1132029000, + "grand": 1201879000, +} + + +def test_엑셀_검산_총괄(): + out = compute_total(SUMS, ESTIMATE) + assert out["sums"]["direct"] == 710359136 # 가. 순공사비계 D48 + assert out["lines"] == EXCEL_LINES + assert out["subtotals"] == EXCEL_SUBTOTALS + assert out["safety"] == {"a": 16586996, "b": 18494700} # E55 글 A · B + assert out["profit_raw"] == 109015935 # J62 보정 전 + assert out["supply"] == {"net": 69474220, "fee": 375160, "raw": 69849380} # I66 · O66 + + +def test_엑셀_검산_원가계산서_거울_구성비(): + cost = compute_total(SUMS, ESTIMATE)["cost"] + share = compute_total(SUMS, ESTIMATE)["share"] + assert cost["direct_material"] == 194234417 and cost["material"] == 194234417 # D5 · D8 + assert cost["labor"] == 397086634 # D11 + assert cost["expense_sum"] == 277553778 # D22 + assert cost["supply_price"] == 1029117273 and cost["grand"] == 1201879000 # D25 · D29 + assert share["direct_material"] == pytest.approx(16.160896146783497) # E5 + assert share["labor"] == pytest.approx(33.03881954839048) # E11 + assert share["supply"] == pytest.approx(5.8117331278772655) # E28 + assert share["grand"] == 100 + + +def test_간접재료비_작업설은_직접재료비만_바꿈(): + est = {**ESTIMATE, "indirect_material": 1000, "scrap": 300} + out = compute_total(SUMS, est) + assert out["cost"]["direct_material"] == 194234417 - 1000 + 300 # 엑셀 D5 = F48 − D6 + D7 + assert out["cost"]["material"] == 194234417 + assert out["subtotals"] == EXCEL_SUBTOTALS + + +def test_빈칸_규칙(): + blank = compute_total(SUMS, {}) + assert all(v is None for k, v in blank["lines"].items() if k != "supply") + assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None + assert blank["lines"]["supply"] is None # 조달수수료율 빈칸 + # 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈 + one = compute_total(SUMS, {**ESTIMATE, "rates": {**RATES, "care": None}}) + assert one["lines"]["care"] is None and one["lines"]["health"] == 12294079 + assert one["subtotals"]["net_cost"] is None and one["lines"]["supply"] == 69850000 + # 비목 합이 아직 없음(145-3 전) — 모두 빈칸 + none = compute_total(None, ESTIMATE) + assert none["subtotals"]["grand"] is None and none["lines"]["indirect_labor"] is None + # 관급 없음 — 수수료율 빈칸이어도 관급 0 + zero = compute_total({**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}) + assert zero["lines"]["supply"] == 0 and zero["subtotals"]["grand"] is not None + + +def test_안전관리비_B_가_작으면_B(): + big = compute_total({**SUMS, "supply_net": 2_000_000_000}, ESTIMATE) + assert big["safety"]["a"] > big["safety"]["b"] + assert big["lines"]["safety"] == big["safety"]["b"] + + +def test_요율_후보_글(): + cand = rate_candidates() + assert cand["RT000001"] == "M01 RT000001 3.56%" + assert "구간" in cand["RT000002"] and cand["RT000019"].endswith("10%") + + +def _run_ts(cases: list) -> list: + base = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts" + calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Total.ts" + body = ( + f"\nconst cases = {json.dumps(cases, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(cases.map(([s, e]) => computeTotal(s, e))));" + ) + script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_거울_TS_가_같은_값(): + cases = [ + [SUMS, ESTIMATE], + [SUMS, {**ESTIMATE, "indirect_material": 1000, "scrap": 300, "vat_adjust": -27}], + [SUMS, {}], + [None, ESTIMATE], + [{**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}], + [{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE], + [{**SUMS, "labor": None}, ESTIMATE], + [SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}], + ] + assert _run_ts(cases) == [compute_total(s, e) for s, e in cases] + + +def test_화면_탭_요율_손입력_칸(): + """총괄 탭 — 요율 · 손입력 칸만 고침 · 빈칸 = 지움 · 원가계산서 줄 누름 = 총괄 그 줄.""" + files = [ + ROOT / "B09_Estimation" / name + for name in ( + "B09_Estimation_Calc_Base.ts", + "B09_Estimation_Calc_Total.ts", + "B09_Estimation_UI_Tabs.ts", + "B09_Estimation_UI_Tabs_Total.ts", + ) + ] + body = f""" +let user = {{ estimate: {json.dumps(ESTIMATE)} }}; +const setUser = (u) => (user = u); +const sums = {json.dumps(SUMS)}; +const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate) }}, payload: {{}}, user, setUser }}); +let t = buildTotal(ctx()); +const at = (rows, key) => rows.findIndex((r) => r.key === key); +const out = {{}}; +out.editable = [ + t.editable(at(t.rows, "care"), "rate"), t.editable(at(t.rows, "care"), "hand"), + t.editable(at(t.rows, "safety"), "hand"), t.editable(at(t.rows, "retirement"), "rate"), + t.editable(at(t.rows, "net_cost"), "rate"), +]; +t.onEdit(at(t.rows, "care"), "rate", "13.14"); +out.care = user.estimate.rates.care; +t = buildTotal(ctx()); +t.onEdit(at(t.rows, "care"), "rate", ""); +out.careGone = "care" in user.estimate.rates; +t = buildTotal(ctx()); +out.grandBlank = t.rows[at(t.rows, "grand")].amount; +t.onEdit(at(t.rows, "safety"), "hand", "1,000"); +out.base = user.estimate.safety_base; +const c = buildCost(ctx()); +out.link = c.link(at(c.rows, "safety")); +c.onEdit(at(c.rows, "scrap"), "amount", "300"); +out.scrap = user.estimate.scrap; +console.log(JSON.stringify(out)); +""" + script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + out = json.loads(result.stdout) + assert out["editable"] == [True, False, True, False, False] + assert out["care"] == 13.14 and out["careGone"] is False + assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸 + assert out["base"] == 1000 and out["scrap"] == 300 + assert out["link"] == {"tab": "total", "key": "safety"}