"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_144_b09_base_calc.py`. 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. 실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로: 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 `inputs.labor` · `inputs.machines` 로 넣어 줌 · `B09_Estimation_Supply.price_lists`) 나열 — 다시 안 셈(841-1) 경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · B09 가 기계 시간당 · 운전사 노임을 다시 셈하던 곳 · 값 = M02 단가산출 그대로) — 빈 표 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 · 적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저 · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · 로직 자재 = M02 자재표시 · B08 파형강관 = 관급) → 재료비목록표 단가 = 적용 단가 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 = 입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 · 없는 키 빈칸) · `material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열) 빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀). """ from __future__ import annotations import math from typing import Any #: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 EPS = 1e-7 #: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) # ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 · 견적 = 사용자 손입력(엑셀 M/N 열) SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적") #: 자재 줄 사용자 표시 SUPPLY = ("관급", "별도") _PARTS = ("labor", "material", "expense") 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 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 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 {}), "견적": mine.get("quote")} pages = {**(m.get("pages") or {}), "견적": mine.get("quote_page")} low, low_from = lowest(prices) if mine.get("pick") in SOURCES: applied, applied_from = num(prices.get(mine["pick"])), mine["pick"] elif "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 = next((v for v in (mine.get("supply"), m.get("supply")) if v in SUPPLY), None) if mine.get("supply") == "사급": # 841-1 사용자가 기본 표시(M02 자재표시)를 끔 supply = 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: pages.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, "pick": mine.get("pick") if mine.get("pick") in SOURCES else None, "supply": supply, "qty": num(m.get("qty")), } ) return rows def material_sheet( compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None ) -> list[dict[str, Any]]: """재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 · 총액 = ROUND(수량 × 단가, 0).""" keep = ("no", "key", "name", "spec", "unit", "supply", "origin") rows = [] for row in compare: price = row["applied"] q = row["qty"] if qty_of is None else num(qty_of.get(row["key"])) amount = None if price is None or q is None else round_half(q * price, 0) rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount}) return rows def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: """144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표.""" gaps: list[dict[str, str]] = [] compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps) machine = [ {**_head(m, no), "unit": "시간", **{p: num(m.get(p)) for p in _PARTS}, "sum": total([num(m.get(p)) for p in _PARTS])} for no, m in enumerate(inputs.get("machines") or [], 1) ] # fmt: skip return { "labor": labor_sheet(inputs.get("labor") or [], gaps), "expense": [], "basis": {"fx": [], "drivers": []}, "compare": compare, "material": material_sheet(compare, inputs.get("material_qty")), "machine_blocks": [], "machine": machine, "gaps": gaps, }