diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py index ec0a8c6be..497b5692b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.py +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -11,15 +11,20 @@ estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 · profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 · scrap 작업설 · procurement_fee_pct 조달수수료율 + table = M01 요율 압축 표(`B09_Estimation_Rates.rate_table` · payload `rate_table`) · year = 연금 해 +요율 = 사용자 값(`rates`)이 앞섬 · 없으면 표에서 자동(166-1 `pick_rates` — 직접공사비 = 가.순공사비계 · + 공기 · 추정가격 = 사용자 칸 · 안전 대상액 = 재료비 + 직노 + 관급 ÷ 1.1 · 공종 = 토목 · 환경 = 그 밖 기본) · + 안전 기초액 = 사용자 값 ?? 자동 구간 기초액 식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) · 산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 · 이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정. -빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · +빈칸 규칙 = 요율(사용자 · 자동 둘 다 없음) · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · 손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸). """ from __future__ import annotations +from datetime import date from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total @@ -65,12 +70,71 @@ def procurement_fee(net: float | None, pct: float | None) -> float | None: return _pct(net, pct) -def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]: +def _step(bounds: list[float], value: float) -> int: + """구간 번호 — 위 끝(그 값 미만) 목록에서 value 가 넘은 개수.""" + return sum(1 for b in bounds if value >= b) + + +def _band(bounds: list[float], i: int, days: bool = False) -> str: + def fmt(v: float) -> str: + return f"{v:g}일" if days else f"{v / 1e8:g}억" + + if i == 0: + return f"{fmt(bounds[0])} 미만" + if i == len(bounds): + return f"{fmt(bounds[-1])} 이상" + return f"{fmt(bounds[i - 1])} 이상 {fmt(bounds[i])} 미만" + + +def _pick(spec: dict[str, Any], ctx: dict[str, Any]) -> tuple[float | None, float | None, str]: + """표 한 줄 → (자동 율, 기초액, 까닭 글) — 고르는 값이 빈칸이면 율 None.""" + if "pct" in spec: + return spec["pct"], None, "단일 요율" + if "price" in spec: + if ctx["price"] is None: + return None, None, "추정가격 빈칸" + i = _step(spec["price"], ctx["price"]) + return spec["pcts"][i], None, f"추정가격 {_band(spec['price'], i)}" + if "grid" in spec: + if ctx["direct"] is None or ctx["days"] is None: + return None, None, "직접공사비 · 공기 빈칸" + i, j = _step(spec["direct"], ctx["direct"]), _step(spec["days"], ctx["days"]) + why = f"직접공사비 {_band(spec['direct'], i)} · 공기 {_band(spec['days'], j, True)}" + return spec["grid"][i][j], None, why + if "target" in spec: + if ctx["target"] is None: + return None, None, "대상액 빈칸" + i = _step(spec["target"], ctx["target"]) + return spec["pcts"][i], spec["bases"][i], f"대상액 {_band(spec['target'], i)}" + if "years" in spec: + pct = spec["years"].get(str(ctx["year"])) + return pct, None, f"{ctx['year']}년" if pct is not None else f"{ctx['year']}년 요율 없음" + if "kinds" in spec: + kind = "road" if ctx["env"] == "road" else "other" + return spec["kinds"].get(kind), None, "도로" if kind == "road" else "그 밖의 토목" + return None, None, "" + + +def pick_rates(table: dict[str, Any] | None, ctx: dict[str, Any]) -> dict[str, dict[str, Any]]: + """{줄 키: {rate, base, why}} — 표 없으면 {}(자동 없음).""" + out: dict[str, dict[str, Any]] = {} + for key, spec in (table if isinstance(table, dict) else {}).items(): + rate, base, why = _pick(spec, ctx) + note = spec.get("note") or "" + out[key] = {"rate": rate, "base": base, "why": f"{why} · {note}" if note else why} + return out + + +def compute_total( + sums: dict[str, Any] | None, + estimate: dict[str, Any] | None, + table: dict[str, Any] | None = None, + year: int | None = 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 @@ -78,6 +142,38 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) 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]) # 가. 순공사비계 + labor_material = total([labor, material]) + target = ( + None + if labor_material is None + else labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR + ) + auto = pick_rates( + table, + { + "direct": direct, + "target": target, + "price": num(est.get("estimated_price")), + "days": num(est.get("duration_days")), + "year": year or date.today().year, + "env": est.get("environment_kind"), + }, + ) + rates: dict[str, dict[str, Any]] = {} + for key, *_ in (*RATE_LINES, *TAIL_LINES): + mine, got = num(rates_in.get(key)), auto.get(key) or {} + rates[key] = { + "rate": got.get("rate") if mine is None else mine, + "from": "user" + if mine is not None + else ("auto" if got.get("rate") is not None else None), + "auto": got.get("rate"), + "why": got.get("why") or "", + } + rate = {key: r["rate"] for key, r in rates.items()} + base_in = num(est.get("safety_base")) + safety_base = base_in if base_in is not None else (auto.get("safety") or {}).get("base") or 0.0 + rates["safety"]["base"] = safety_base line: dict[str, float | None] = {} line["indirect_labor"] = _pct(labor, rate["indirect_labor"]) @@ -89,10 +185,8 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) 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 + if labor_material is not None and target is not None and rate["safety"] is not None: + base = safety_base 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) @@ -158,4 +252,5 @@ def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) "supply": {"net": supply_net, "fee": fee, "raw": supply_raw}, "cost": cost, "share": share, + "rates": rates, } diff --git a/B09_Estimation/B09_Estimation_Calc_Total.ts b/B09_Estimation/B09_Estimation_Calc_Total.ts index b42725d15..564c05429 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.ts +++ b/B09_Estimation/B09_Estimation_Calc_Total.ts @@ -6,7 +6,8 @@ * 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`. * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * - * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) + * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) · + * table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글 * 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로. * ========================================================================== */ @@ -51,12 +52,67 @@ const pct = (base: Num, rate: Num): Num => /** 조달수수료 = 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 { +/** 구간 번호 — 위 끝(그 값 미만) 목록에서 value 가 넘은 개수 */ +const step = (bounds: number[], value: number): number => bounds.filter((b) => value >= b).length; + +function band(bounds: number[], i: number, days = false): string { + const fmt = (v: number): string => (days ? `${v}일` : `${v / 1e8}억`); + if (i === 0) return `${fmt(bounds[0])} 미만`; + if (i === bounds.length) return `${fmt(bounds[bounds.length - 1])} 이상`; + return `${fmt(bounds[i - 1])} 이상 ${fmt(bounds[i])} 미만`; +} + +/** 표 한 줄 → [자동 율, 기초액, 까닭 글] — 고르는 값이 빈칸이면 율 null */ +function pick(spec: Row, ctx: Row): [Num, Num, string] { + if ("pct" in spec) return [spec.pct, null, "단일 요율"]; + if ("price" in spec) { + if (ctx.price === null) return [null, null, "추정가격 빈칸"]; + const i = step(spec.price, ctx.price); + return [spec.pcts[i], null, `추정가격 ${band(spec.price, i)}`]; + } + if ("grid" in spec) { + if (ctx.direct === null || ctx.days === null) return [null, null, "직접공사비 · 공기 빈칸"]; + const i = step(spec.direct, ctx.direct); + const j = step(spec.days, ctx.days); + const why = `직접공사비 ${band(spec.direct, i)} · 공기 ${band(spec.days, j, true)}`; + return [spec.grid[i][j], null, why]; + } + if ("target" in spec) { + if (ctx.target === null) return [null, null, "대상액 빈칸"]; + const i = step(spec.target, ctx.target); + return [spec.pcts[i], spec.bases[i], `대상액 ${band(spec.target, i)}`]; + } + if ("years" in spec) { + const pct = spec.years[String(ctx.year)] ?? null; + return [pct, null, pct !== null ? `${ctx.year}년` : `${ctx.year}년 요율 없음`]; + } + if ("kinds" in spec) { + const kind = ctx.env === "road" ? "road" : "other"; + return [spec.kinds[kind] ?? null, null, kind === "road" ? "도로" : "그 밖의 토목"]; + } + return [null, null, ""]; +} + +/** {줄 키: {rate, base, why}} — 표 없으면 {}(자동 없음) */ +export function pickRates(table: Row | null | undefined, ctx: Row): Record { + const out: Record = {}; + for (const [key, spec] of Object.entries(table && typeof table === "object" ? table : {})) { + const [rate, base, why] = pick(spec as Row, ctx); + const note = (spec as Row).note || ""; + out[key] = { rate, base, why: note ? `${why} · ${note}` : why }; + } + return out; +} + +export function computeTotal( + sums: Row | null | undefined, + estimate: Row | null | undefined, + table?: Row | null, + year?: number | null, +): 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); @@ -64,6 +120,34 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null const expense = num(s.expense); const supplyNet = num(s.supply_net); const direct = total([material, labor, expense]); + const laborMaterial = total([labor, material]); + const target = + laborMaterial === null ? null : laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR; + const auto = pickRates(table, { + direct, + target, + price: num(est.estimated_price), + days: num(est.duration_days), + year: year || new Date().getFullYear(), + env: est.environment_kind ?? null, + }); + const rates: Record = {}; + for (const [key] of [...RATE_LINES, ...TAIL_LINES]) { + const mine = num(ratesIn[key]); + const got: Row = auto[key] || {}; + const autoRate: Num = got.rate ?? null; + rates[key] = { + rate: mine === null ? autoRate : mine, + from: mine !== null ? "user" : autoRate !== null ? "auto" : null, + auto: autoRate, + why: got.why || "", + }; + } + const rate: Record = {}; + for (const [key, r] of Object.entries(rates)) rate[key] = r.rate; + const baseIn = num(est.safety_base); + const safetyBase: number = baseIn !== null ? baseIn : (auto.safety || {}).base || 0; + rates.safety.base = safetyBase; const line: Record = {}; line.indirect_labor = pct(labor, rate.indirect_labor); @@ -75,10 +159,8 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null 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; + if (laborMaterial !== null && target !== null && rate.safety !== null) { + const base = safetyBase; safetyA = rounddown((target * rate.safety) / 100 + base); safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B); } @@ -144,5 +226,6 @@ export function computeTotal(sums: Row | null | undefined, estimate: Row | null supply: { net: supplyNet, fee, raw: supplyRaw }, cost, share, + rates, }; } diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py index 73113b860..5f5486b3d 100644 --- a/B09_Estimation/B09_Estimation_Rates.py +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -1,12 +1,16 @@ -"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄. +"""B09 원가 요율(PLAN 145-4 · 166-1) — M01 `요율_조달청제비율.json` 을 읽는 유일한 곳. -후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음. -요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`). +rate_candidates 줄마다 후보 글 한 줄(출처 풍선) +rate_table 짝 셈(`B09_Estimation_Calc_Total`)이 자동으로 고르는 압축 표 — 총괄 줄 키마다 + {rt, pct} 단일 · {price, pcts} 추정가격 구간 · {direct, days, grid} 직접공사비 × 공기 · + {target, pcts, bases} 안전 대상액 구간 · {years} 연도 · {kinds} 환경 공종 + 구간 = 위 끝 목록(그 값 미만 · 마지막 = 끝없음) · 공종 = 토목(임도) 고정 """ from __future__ import annotations import json +import math from functools import lru_cache from pathlib import Path from typing import Any @@ -27,13 +31,18 @@ def _percents(value: Any) -> list[float]: return [] +@lru_cache(maxsize=1) +def _rows() -> tuple[dict[str, Any], ...]: + try: + return tuple(json.loads(FILE.read_text(encoding="utf-8")).get("줄") or []) + except (OSError, ValueError): + 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 {} + rows = _rows() out: dict[str, str] = {} for row in rows: percents = sorted(set(_percents(row.get("값")))) @@ -45,3 +54,117 @@ def rate_candidates() -> dict[str, str]: text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)" out[str(row.get("키"))] = f"M01 {row.get('키')} {text}" return out + + +_UNIT = {"million": 10**6, "billion": 10**9, "days": 1} + + +def _upper(label: Any) -> float | None: + """구간 이름 → 위 끝(그 값 미만) — 「gte_」 = 끝없음(inf) · 일수는 끝 포함이라 +1 · + 수 없는 이름(below_official_threshold) = None. `billion` = 십억(M01 basis 글).""" + words = str(label).split("_") + if words[0] == "gte": + return math.inf + for i in range(len(words) - 1, 0, -1): + if words[i] in _UNIT and words[i - 1].isdigit(): + return int(words[i - 1]) * _UNIT[words[i]] + (words[i] == "days") + return None + + +def _steps(rows: list[dict[str, Any]], field: str) -> tuple[list[float], list[dict[str, Any]]]: + """구간 줄 → (위 끝 목록 · 끝없음 뺌, 줄 목록) — 위 끝 차례.""" + keyed = sorted( + ((u, r) for r in rows if (u := _upper(r.get(field))) is not None), key=lambda p: p[0] + ) + return [u for u, _ in keyed[:-1]], [r for _, r in keyed] + + +def _grid(rows: list[dict[str, Any]]) -> dict[str, Any]: + """간접노무비 · 기타경비 — 직접공사비 × 공기 × 토목.""" + civil = [r for r in rows if r.get("work_type") == "civil"] + d_up = sorted({_upper(r["direct_cost_bracket"]) for r in civil}) + t_up = sorted({_upper(r["duration_bracket"]) for r in civil}) + grid: list[list[float | None]] = [[None] * len(t_up) for _ in d_up] + for r in civil: + i = d_up.index(_upper(r["direct_cost_bracket"])) + grid[i][t_up.index(_upper(r["duration_bracket"]))] = r["rate_percent"] + return {"direct": d_up[:-1], "days": t_up[:-1], "grid": grid, "note": "토목"} + + +def _priced(rows: list[dict[str, Any]], field: str, note: str = "") -> dict[str, Any]: + bounds, picked = _steps(rows, field) + return {"price": bounds, "pcts": [r["rate_percent"] for r in picked], "note": note} + + +@lru_cache(maxsize=1) +def rate_table() -> dict[str, dict[str, Any]]: + """총괄 줄 키 → 압축 표(머리 글) — 파일 · 줄이 없으면 그 줄 없음(자동 없음 = 빈칸).""" + v = {str(r.get("키")): r.get("값") or {} for r in _rows()} + out: dict[str, dict[str, Any]] = {} + + def put(key: str, rt: str, build: Any) -> None: + if rt in v: + try: + out[key] = {"rt": rt, **build(v[rt])} + except (KeyError, TypeError, ValueError, IndexError): + pass # M01 꼴이 바뀌면 그 줄만 자동 없음 + + for key, rt in (("accident", "RT000001"), ("health", "RT000003"), ("care", "RT000005")): + put(key, rt, lambda x: {"pct": x["rate_percent"]}) + put("vat", "RT000019", lambda x: {"pct": x["rate_percent"]}) + put( + "machine", + "RT000015", + lambda x: { + "pct": next( + r["rate_percent"] + for r in x["general_construction"] + if r["work_type"] == "civil_general" + ), + "note": "종합공사 토목 일반", + }, + ) + put("employment", "RT000002", lambda x: _priced(x["brackets"], "estimated_amount_bracket")) + put( + "overhead", + "RT000017", + lambda x: _priced( + x["civil_landscape_industrial"], "estimated_price_bracket", "종합(토목) 표" + ), + ) + put("profit", "RT000018", lambda x: _priced(x["brackets"], "estimated_price_bracket")) + put("indirect_labor", "RT000008", lambda x: _grid(x["brackets"])) + put("other", "RT000009", lambda x: _grid(x["brackets"])) + + def safety(x: dict[str, Any]) -> dict[str, Any]: + # ponytail: 관리자 선임 대상(추정 1000억 이상) 구간은 안 고름 — 임도 규모 밖 + civil = [ + r + for r in x["brackets"] + if r["work_type"] == "civil" and "at_or_above" not in r["target_amount_bracket"] + ] + bounds, picked = _steps(civil, "target_amount_bracket") + return { + "target": bounds, + "pcts": [r["rate_percent"] for r in picked], + "bases": [r.get("base_amount_krw", 0) for r in picked], + "note": "토목", + } + + put("safety", "RT000006", safety) + put( + "pension", + "RT000004", + lambda x: {"years": {str(r["year"]): r["rate_percent"] for r in x["annual_rates"]}}, + ) + put( + "environment", + "RT000010", + lambda x: { + "kinds": { + "road" if r["work_type"] == "road" else "other": r["rate_percent"] + for r in x["forest_road_candidates"] + } + }, + ) + return out diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index c3f322437..fe9c66f5b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -35,7 +35,7 @@ from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys -from B09_Estimation.B09_Estimation_Rates import rate_candidates +from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -70,8 +70,10 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> sheets.update(sg) sheets.update({k: v for k, v in labor.items() if k != "gaps"}) sheets["gaps"] = [*sheets["gaps"], *sg["sg_gaps"], *labor["gaps"]] - # 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 - sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate")) + # 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력 + sheets["total"] = compute_total( + (sheets.get("bill") or {}).get("sums"), user.get("estimate"), rate_table() + ) return sheets @@ -96,6 +98,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "book": {"made": book.get("made")} if book else None, "sheets": sheets, "rate_candidates": rate_candidates(), + "rate_table": rate_table(), "notes": notes, # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 "summary": compute_summary(sheets, hopyo_lines(sheets)), diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 1710b3f92..27ae5bf29 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -29,6 +29,7 @@ ESTIMATE_FIELDS = ( "estimated_price", # 추정가격(원) — 요율 구간 고름 "duration_days", # 공사기간(일) "work_type", # 공종(글) + "environment_kind", # 환경보전비 공종 — road 도로 · 그 밖 = 그 밖의 토목(166-1) "safety_base", # 산업안전보건관리비 기초액(원) "profit_adjust", # 이윤 보정(원 · 빼는 값) "vat_adjust", # 부가가치세 조정(원) @@ -37,6 +38,8 @@ ESTIMATE_FIELDS = ( "procurement_fee_pct", # 조달수수료율(%) ) +TEXT_FIELDS = ("work_type", "environment_kind") + def folder(project_root: str | Path) -> Path: return Path(project_root).joinpath(*FOLDER) @@ -94,7 +97,7 @@ def clean_user(raw: Any) -> dict[str, Any]: if field not in given: continue value = given[field] - estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value) + estimate[field] = (str(value) if value else None) if field in TEXT_FIELDS else _num(value) rates = given.get("rates") if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 7120684a4..9de746e71 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -128,8 +128,8 @@ export function computeSheets(payload: Row, user: Row): Row { ...kept, ...daega, gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps], - // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 - total: computeTotal(base.bill?.sums, user.estimate), + // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력 + total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table), }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index d24914c29..4eccc4440 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -3,8 +3,10 @@ * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. * * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) - * 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 · - * 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸) + * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) + * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 + * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · + * 엑셀에 없는 현행 비목 = 줄만(빈칸) * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 * ========================================================================== */ @@ -27,6 +29,8 @@ const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleS /** 손입력 칸 — 줄 키 → user.estimate 칸 */ const HAND: Record = { + in_price: ["estimated_price", "추정가격(원)"], + in_days: ["duration_days", "공사기간(일)"], safety: ["safety_base", "기초액(원)"], profit: ["profit_adjust", "이윤 보정(원 · 뺌)"], vat: ["vat_adjust", "부가세 조정(원)"], @@ -36,7 +40,9 @@ const HAND: Record = { }; const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); -/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */ +const ENV_NAME: Record = { road: "도로", other: "그 밖의 토목" }; + +/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 자동 · 손입력 0) */ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void { const v = parseNumber(value); if (v === undefined) return; @@ -54,6 +60,20 @@ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: st ctx.setUser({ ...ctx.user, estimate }); } +/** 칸 누름 — 환경 공종 줄 = 도로 ↔ 그 밖 · 사용자 요율 줄의 출처 칸 = 자동으로 되돌림 */ +function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: unknown): void { + const estimate: Row = { ...(ctx.user.estimate || {}) }; + if (rowKey === "in_env") { + if (estimate.environment_kind === "road") delete estimate.environment_kind; + else estimate.environment_kind = "road"; + } else if (column === "candidate" && from === "user") { + const rates: Row = { ...(estimate.rates || {}) }; + delete rates[rowKey]; + estimate.rates = rates; + } else return; + ctx.setUser({ ...ctx.user, estimate }); +} + function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); } @@ -61,11 +81,18 @@ function rateText(formula: string, rate: Num): string { export const buildTotal: TabBuild = (ctx): TabTable => { const t: Row = ctx.sheets.total || {}; const est: Row = ctx.user.estimate || {}; - const rates: Row = est.rates || {}; + const picked: Row = t.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 rate = (key: string): Num => picked[key]?.rate ?? null; + /** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */ + const source = (key: string): string => { + const r: Row = picked[key] || {}; + if (r.from === "user") + return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; + return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`; + }; const hand = (key: string): Num => { const v = est[HAND[key][0]]; return typeof v === "number" ? v : null; @@ -91,13 +118,36 @@ export const buildTotal: TabBuild = (ctx): TabTable => { 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, + candidate: source(key), + from: picked[key]?.from ?? null, + rt, ...(HAND[key] ? { hand: hand(key) } : {}), }); const safety: Row = t.safety || {}; const supply: Row = t.supply || {}; const sums: Row = t.sums || {}; + const env = est.environment_kind === "road" ? "road" : "other"; const rows: DataRow[] = [ + row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", { + hand: hand("in_price"), + }), + row( + "in_days", + "요율", + "공사기간(일)", + null, + "간접노무비 · 기타경비 구간(직접공사비 = 순공사비계)", + { + hand: hand("in_days"), + }, + ), + row( + "in_env", + "요율", + `환경보전비 공종 = ${ENV_NAME[env]}`, + null, + `누르면 ${ENV_NAME[env === "road" ? "other" : "road"]}으로`, + ), row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", { labor: sums.labor ?? null, material: sums.material ?? null, @@ -143,17 +193,28 @@ export const buildTotal: TabBuild = (ctx): TabTable => { qty("rate", "요율(%)", 5), money("hand", "손입력", undefined, 2), text("note", "산식", 320), - text("candidate", "요율 후보(확정 아님)", 260), + text("candidate", "요율 출처", 260), ], rows, frozen: 2, hint: - "끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " + - "손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", + "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + + "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + + "손입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 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), + onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from), + tip: (i, key) => { + const r = rows[i]; + if (!r?.rt || (key !== "rate" && key !== "candidate")) return null; + const why = picked[String(r.key)]?.why; + return [why ? `자동 근거 — ${why}` : "", cand[String(r.rt)] ?? r.rt] + .filter(Boolean) + .join(" · "); + }, }; }; @@ -185,11 +246,11 @@ 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 picked: Row = t.rates || {}; const formula: Record = Object.fromEntries( [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ key, - rateText(f, typeof rates[key] === "number" ? rates[key] : null), + rateText(f, picked[key]?.rate ?? null), ]), ); const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py index a4a87e750..ad95a5763 100644 --- a/resources/tester/test_145_4_b09_total_calc.py +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -140,8 +140,8 @@ def _run_ts(cases: list) -> list: 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, + "--input-type=module-typescript"], + input=script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return json.loads(result.stdout) @@ -203,8 +203,8 @@ 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, + "--input-type=module-typescript"], + input=script, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) diff --git a/resources/tester/test_166_1_b09_rate_auto.py b/resources/tester/test_166_1_b09_rate_auto.py new file mode 100644 index 000000000..a00b15292 --- /dev/null +++ b/resources/tester/test_166_1_b09_rate_auto.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +"""166-1 B09 총괄 요율 자동 — M01 압축 표(`B09_Estimation_Rates.rate_table`) · 구간 고르기 · 사용자 값 앞섬 · +엑셀 검산(손값 그대로) · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`) · 화면 칸(고르는 값 · 되돌리기). + +엑셀 2024 요율은 2026 표와 연도 차로 다름 — 자동값은 꼴(구간 고르기)로만 잼. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +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_table # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} +RATES = { # 엑셀 2024 M49~M64 + "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, +} # fmt: skip +EXCEL = { + "rates": RATES, + "safety_base": 5349000, + "profit_adjust": 905980, + "procurement_fee_pct": 0.54, +} +PICK = {"estimated_price": 1_000_000_000, "duration_days": 183, "procurement_fee_pct": 0.54} + + +def total(sums, est, year=2026): + return compute_total(sums, est, rate_table(), year) + + +def rate(out, key): + return out["rates"][key]["rate"] + + +def test_압축_표_꼴(): + t = rate_table() + assert t["indirect_labor"]["direct"] == [10**9, 5 * 10**9, 30 * 10**9, 100 * 10**9] # 십억 + assert t["indirect_labor"]["days"] == [184, 366, 1096] + assert t["overhead"]["price"] == [5 * 10**9, 30 * 10**9, 100 * 10**9] + assert t["employment"]["pcts"][0] == 1.01 and t["employment"]["pcts"][-1] == 1.57 + assert t["safety"] == { + "rt": "RT000006", + "target": [5 * 10**8, 5 * 10**9], + "pcts": [3.15, 2.53, 2.6], + "bases": [0, 3300000, 0], + "note": "토목", + } + assert t["environment"]["kinds"] == {"road": 0.9, "other": 0.8} + + +def test_직접공사비_10억_공기_183일_경계(): + small = total({"material": 0, "labor": 999_999_999, "expense": 0}, PICK) + assert rate(small, "indirect_labor") == 19.1 and rate(small, "other") == 5.5 + assert ( + small["rates"]["indirect_labor"]["why"] == "직접공사비 10억 미만 · 공기 184일 미만 · 토목" + ) + edge = total({"material": 0, "labor": 1_000_000_000, "expense": 0}, PICK) + assert rate(edge, "indirect_labor") == 18.9 and rate(edge, "other") == 5.8 + longer = total( + {"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184} + ) + assert rate(longer, "indirect_labor") == 19.3 + assert longer["rates"]["indirect_labor"]["why"].startswith( + "직접공사비 10억 이상 50억 미만 · 공기 184일 이상" + ) + + +def test_추정가격_50억_경계(): + under = total(SUMS, {**PICK, "estimated_price": 4_999_999_999}) + assert (rate(under, "overhead"), rate(under, "profit"), rate(under, "employment")) == ( + 8, + 15, + 1.01, + ) + at = total(SUMS, {**PICK, "estimated_price": 5_000_000_000}) + assert (rate(at, "overhead"), rate(at, "profit")) == (6.5, 12) + assert at["rates"]["overhead"]["why"] == "추정가격 50억 이상 300억 미만 · 종합(토목) 표" + + +def test_안전_5억_경계와_기초액(): + small = total( + {"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK + ) + assert rate(small, "safety") == 3.15 and small["rates"]["safety"]["base"] == 0 + # 견본 = 재료비 + 직노 + 관급 ÷ 1.1 = 6.04억 → 5억~50억 2.53 + 3,300,000 + out = total(SUMS, PICK) + assert rate(out, "safety") == 2.53 and out["rates"]["safety"]["base"] == 3300000 + target = 194234417 + 346800554 + 69474220 / 1.1 + assert out["safety"]["a"] == int(target * 2.53 / 100 + 3300000) + mine = total(SUMS, {**PICK, "safety_base": 1000}) + assert mine["rates"]["safety"]["base"] == 1000 + + +def test_연금_해_환경_공종_단일(): + out = total(SUMS, PICK) + assert rate(out, "pension") == 4.75 and rate(total(SUMS, PICK, 2027), "pension") == 5.0 + late = total(SUMS, PICK, 2033) + assert rate(late, "pension") is None and late["subtotals"]["net_cost"] is None + assert late["rates"]["pension"]["why"] == "2033년 요율 없음" + assert rate(out, "environment") == 0.8 + assert rate(total(SUMS, {**PICK, "environment_kind": "road"}), "environment") == 0.9 + assert (rate(out, "accident"), rate(out, "care"), rate(out, "vat"), rate(out, "machine")) == ( + 3.56, + 13.14, + 10, + 0.4, + ) + assert out["subtotals"]["grand"] is not None and out["rates"]["vat"]["from"] == "auto" + + +def test_사용자_값이_앞섬_지우면_자동(): + out = total(SUMS, {**PICK, "rates": {"indirect_labor": 14.5, "care": None}}) + assert out["rates"]["indirect_labor"] == { + "rate": 14.5, + "from": "user", + "auto": 19.1, + "why": "직접공사비 10억 미만 · 공기 184일 미만 · 토목", + } + assert out["lines"]["indirect_labor"] == 50286080 # 엑셀 D49 + assert out["rates"]["care"]["from"] == "auto" and rate(out, "care") == 13.14 + + +def test_고르는_값_빈칸이면_자동_없음(): + out = total(SUMS, {"procurement_fee_pct": 0.54}) + for key in ("indirect_labor", "other", "overhead", "profit", "employment"): + assert rate(out, key) is None and out["rates"][key]["from"] is None + assert out["lines"]["indirect_labor"] is None and out["subtotals"]["net_cost"] is None + assert rate(out, "health") == 3.595 # 단일 요율은 그대로 + + +def test_엑셀_검산_손값이면_그대로(): + out = total(SUMS, EXCEL, 2026) + assert out["subtotals"]["grand"] == 1201879000 + assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955 + assert all(r["from"] == "user" for r in out["rates"].values()) + + +def _node(script: str) -> str: + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=script, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return result.stdout + + +def _source(*names: str) -> str: + return "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / n).read_text(encoding="utf-8")) for n in names + ) + + +CASES = [ + [SUMS, PICK, 2026], + [{"material": 0, "labor": 1_000_000_000, "expense": 0}, {**PICK, "duration_days": 184}, 2026], + [SUMS, {**PICK, "estimated_price": 5_000_000_000, "environment_kind": "road"}, 2027], + [{"material": 100_000_000, "labor": 399_999_999, "expense": 0, "supply_net": 0}, PICK, 2026], + [SUMS, {**PICK, "safety_base": 1000, "rates": {"indirect_labor": 14.5, "care": None}}, 2026], + [SUMS, {"procurement_fee_pct": 0.54}, 2033], + [SUMS, EXCEL, 2026], + [None, PICK, 2026], +] + + +def test_거울_TS_가_같은_값(): + body = ( + f"\nconst table = {json.dumps(rate_table(), ensure_ascii=False)};" + f"\nconst cases = {json.dumps(CASES, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(cases.map(([s, e, y]) => computeTotal(s, e, table, y))));" + ) + out = json.loads( + _node(_source("B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Total.ts") + body) + ) + assert out == [total(s, e, y) for s, e, y in CASES] + + +def test_화면_고르는_값_칸과_되돌리기(): + body = f""" +let user = {{ estimate: {{}} }}; +const setUser = (u) => (user = u); +const table = {json.dumps(rate_table(), ensure_ascii=False)}; +const sums = {json.dumps(SUMS)}; +const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate, table, 2026) }}, + payload: {{ rate_table: table, rate_candidates: {{}} }}, user, setUser }}); +let t = buildTotal(ctx()); +const at = (key) => t.rows.findIndex((r) => r.key === key); +const out = {{}}; +out.editable = [t.editable(at("in_price"), "hand"), t.editable(at("in_days"), "hand"), + t.editable(at("in_env"), "hand")]; +out.blank = t.rows[at("indirect_labor")].rate; +t.onEdit(at("in_price"), "hand", "1,000,000,000"); +t = buildTotal(ctx()); +t.onEdit(at("in_days"), "hand", "183"); +t = buildTotal(ctx()); +out.price = user.estimate.estimated_price; +out.auto = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate]; +out.tip = t.tip(at("indirect_labor"), "rate"); +t.onCell(at("in_env"), "name"); +t = buildTotal(ctx()); +out.env = [user.estimate.environment_kind, t.rows[at("environment")].rate, t.rows[at("in_env")].name]; +t.onEdit(at("indirect_labor"), "rate", "14.5"); +t = buildTotal(ctx()); +out.mine = [t.rows[at("indirect_labor")].rate, t.rows[at("indirect_labor")].candidate]; +t.onCell(at("indirect_labor"), "candidate"); +t = buildTotal(ctx()); +out.back = [t.rows[at("indirect_labor")].rate, "indirect_labor" in user.estimate.rates]; +out.contract = t.rows[at("contract")].amount; +console.log(JSON.stringify(out)); +""" + names = ( + "B09_Estimation_Calc_Base.ts", + "B09_Estimation_Calc_Total.ts", + "B09_Estimation_UI_Tabs.ts", + "B09_Estimation_UI_Tabs_Total.ts", + ) + out = json.loads(_node(_source(*names) + body)) + assert out["editable"] == [True, True, False] + assert out["blank"] is None # 고르는 값 빈칸 = 간노 자동 없음 + assert out["price"] == 1_000_000_000 + assert out["auto"] == [19.1, "자동 — 직접공사비 10억 미만 · 공기 184일 미만 · 토목"] + assert "자동 근거" in out["tip"] + assert out["env"] == ["road", 0.9, "환경보전비 공종 = 도로"] + assert out["mine"] == [14.5, "사용자 값 · 누르면 자동 19.1%으로"] + assert out["back"] == [19.1, False] + assert out["contract"] is not None # 요율 모두 자동 — 도급공사비까지 셈(관급은 수수료율 빈칸)