"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_144_b09_base_calc.py`. 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. 실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로: 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). """ from __future__ import annotations import math from typing import Any #: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 EPS = 1e-7 #: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") #: 자재 줄 사용자 표시 SUPPLY = ("관급", "별도") def _scaled(value: float, places: int, fn) -> float: a = abs(value) if places >= 0: scale = 10**places out = fn(a * scale) / scale else: unit = 10 ** (-places) out = fn(a / unit) * unit return -out if value < 0 else out def rounddown(value: float, places: int = 0) -> float: """엑셀 ROUNDDOWN — 0 쪽으로 버림.""" return _scaled(value, places, lambda x: math.floor(x + EPS)) def roundup(value: float, places: int = 0) -> float: """엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림.""" return _scaled(value, places, lambda x: math.ceil(x - EPS)) def round_half(value: float, places: int = 0) -> float: """엑셀 ROUND — 반 올림(0 에서 먼 쪽).""" return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS)) def num(value: Any) -> float | None: """수 · 빈칸 — 수가 아니면 None.""" if isinstance(value, bool) or not isinstance(value, (int, float)): return None return float(value) if math.isfinite(value) else None def total(values: list[float | None]) -> float | None: """합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움).""" out = 0.0 for v in values: if v is None: return None out += v return out def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]: return {"tab": tab, "key": key, "name": name, "why": why} def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None: """운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0).""" if day is None or not factor or not factor.get("den"): return None return rounddown(day * factor["num"] / factor["den"], 0) def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: rows = [] for no, row in enumerate(labor, 1): price = num(row.get("price")) if price is None: gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음")) rows.append( { **_head(row, no), "code": row.get("code"), "unit": row.get("unit") or "인", "price": price, } ) return rows def _head(row: dict[str, Any], no: int) -> dict[str, Any]: return { "no": no, "key": row["key"], "name": row.get("name") or "", "spec": row.get("spec") or "", "source": row.get("source") or "", } def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]: """경비목록표 — 기계 손료 기준가(천원).""" rows = [] for no, m in enumerate(machines, 1): price = num(m.get("price")) if price is None: gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음")) rows.append({**_head(m, no), "unit": "천원", "price": price}) return rows def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]: """출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸.""" best: tuple[float | None, str | None] = (None, None) for source in SOURCES: v = num(prices.get(source)) if v is not None and v > 0 and (best[0] is None or v < best[0]): best = (v, source) return best def compare_sheet( materials: list[dict[str, Any]], user: dict[str, Any], gaps: list ) -> list[dict[str, Any]]: """자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시.""" rows = [] for no, m in enumerate(materials, 1): mine = user.get(m["key"]) or {} prices = m.get("prices") or {} low, low_from = lowest(prices) if "applied" in mine: applied, applied_from = num(mine["applied"]), "사용자" else: applied, applied_from = low, low_from if applied is None: gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)")) supply = mine.get("supply") if mine.get("supply") in SUPPLY else None rows.append( { **_head(m, no), "unit": m.get("unit") or "", "origin": m.get("origin") or "", "prices": {s: num(prices.get(s)) for s in SOURCES}, "pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES}, "lowest": low, "lowest_from": low_from, "applied": applied, "applied_from": applied_from if applied is not None else None, "supply": supply, } ) return rows def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: """재료비목록표 — 단가 = 대비표 적용 단가.""" keep = ("no", "key", "name", "spec", "unit", "supply", "origin") return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare] def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]: if price is None or qty is None: amount = None elif pct: amount = rounddown(price * qty / 100, 1) else: amount = rounddown(price * qty, 1) return { "name": name, "spec": spec, "qty": qty, "unit": unit, "kind": kind, "price": price, "amount": amount, "ref": ref, } def machine_block( m: dict[str, Any], drivers: dict[str, dict[str, Any]], fuels: dict[str, dict[str, Any]], gaps: list, ) -> dict[str, Any]: """중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합.""" lines = [] loss = num(m.get("loss")) coef = loss / 10000 if loss is not None else None price = num(m.get("price")) lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price, {"tab": "expense", "key": m["key"]})) # fmt: skip crew = num(m.get("crew")) if crew: code = str(m.get("crew_code") or "") driver = drivers.get(code) or {} hour = driver.get("hour") if hour is None: gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음")) lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour, {"tab": "basis", "key": code})) # fmt: skip lph = num(m.get("fuel_lph")) if lph: kind = str(m.get("fuel") or "") fuel = fuels.get(kind) or {} fprice = fuel.get("applied") if fprice is None: gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음")) fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material", fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip lines.append(fuel_line) misc = num(m.get("misc_pct")) if misc: lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"], None, pct=True)) # fmt: skip if price is None or coef is None: gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음")) sums: dict[str, float | None] = {} for part in ("labor", "material", "expense"): s = total([ln["amount"] for ln in lines if ln["kind"] == part]) sums[part] = None if s is None else rounddown(s, 0) return { "key": m["key"], "name": m["name"], "spec": m.get("spec") or "", "unit": "시간", "lines": lines, "labor": sums["labor"], "material": sums["material"], "expense": sums["expense"], "sum": total([sums["labor"], sums["material"], sums["expense"]]), } def basis_sheet( inputs: dict[str, Any], labor_rows: list[dict[str, Any]] ) -> dict[str, list[dict[str, Any]]]: """환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만).""" factor = inputs.get("driver_factor") by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None} codes: list[str] = [] for m in inputs.get("machines") or []: code = str(m.get("crew_code")) if m.get("crew_code") else None if code and num(m.get("crew")) and code not in codes: codes.append(code) drivers = [] for code in codes: row = by_code.get(code) or {} day = row.get("price") drivers.append( { "key": code, "labor_key": row.get("key"), "name": row.get("name") or f"직종 {code}", "day": day, "factor": (factor or {}).get("text") or "", "hour": driver_hour(day, factor), } ) fx = [ {"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "", "value": num(f.get("value")), "date": f.get("date") or ""} for f in inputs.get("fx") or [] ] # fmt: skip return {"fx": fx, "drivers": drivers} def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: """144 탭 일곱 + 「연결 없음」 목록.""" gaps: list[dict[str, str]] = [] labor_rows = labor_sheet(inputs.get("labor") or [], gaps) expense = expense_sheet(inputs.get("machines") or [], gaps) compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps) basis = basis_sheet(inputs, labor_rows) drivers = {d["key"]: d for d in basis["drivers"]} by_key = {row["key"]: row for row in compare} fuels = { kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key } blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []] machine = [ { "no": no, "key": b["key"], "name": b["name"], "spec": b["spec"], "unit": b["unit"], "sum": b["sum"], "labor": b["labor"], "material": b["material"], "expense": b["expense"], } for no, b in enumerate(blocks, 1) ] return { "labor": labor_rows, "expense": expense, "basis": basis, "compare": compare, "material": material_sheet(compare), "machine_blocks": blocks, "machine": machine, "gaps": gaps, }