Files
Aislo/B09_Estimation/B09_Estimation_Sangeun.py
T
eomsangdonandClaude Opus 5.5 5d8d9a4fd3 test(841-1): B09 = M02 값 — 산근 · 대가 · 고정 줄 셈 다듬기 · 낡은 시험 정리 — C 걸음
코드
- 고정 줄 단가 = M02_Labor_Fixed.line(사본 묶음 · 이은 노임 · 자재 = 묶음 값) — B09 단가 책으로 다시 안 덮음
- 대가 · 산근 고친 칸 = 품만 실제로(단가 칸 덮기 줄 남던 흠 · 품 고친 뒤 줄 금액 다시 셈 흠 py · ts 짝)
- 산근 글 = 엔진 단가 셈 그대로 · 별도계상 재료 줄 끝 「· 별도계상 = 0.0」 · 계 줄 = 블록 합
- 노무비 · 중기목록 이름에서 규격 겹침 뗌 · TS 바탕 셈 이름 PARTS → MACHINE_PARTS(짝 시험이 TS 를 이어 붙일 때 겹침)

시험(고친 까닭 = 단가 원천이 B09 책 → M02 엔진 · 노임 · 기계 덮개 · 단가 고친 칸 · 중기사용료 걷음)
- 144 바탕 · 뼈대: 운전사 · 중기사용료 · 경비 · 덮개 시험 걷음 → 노무 · 중기 나열 · 사급 표시 · 자재만 책
- 145-1 · 145-2 · 155 · 157-1 · 166-2 · 166-6 · 719-3 · 719-4 · 723-4 · 778-4 · 792-3: 줄 단가 = own(엔진) · 산근 = Solver() · collect 새 부름 꼴
- 702 · 705-1 · 719-2: 단가 칸 덮개 = 안 읽음 · 고정 줄 = M02 셈(노임 새 판 따라가기 시험 걷음)
- 203-1 · 296 · 703-3 · 795-4: 단가 책 = 자재 줄로
- 707 · 707-6 · 707-7: 단가표 불러오기 = 자재만 · 거창 이은 안내판 줄 = M02 사본 셈(엑셀 −50,000)
- 201-1: 환율 · 중기사용료 표 걷음 · b09 저장 속도: 산근 앞 풀이 그대로(단가 표 같음 견줌 걷음)
- 대상 113 파일 764 통과 · 25 실패 = 원래 실패(formatMoney · formatPrice 없음 · 단위 글 ㎜ · sec · 719-1 · 796-2e · 717-4 · 721 · 311 · 243 · 246)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 16:57:01 +09:00

688 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함).
산근 호표(155-4 · 엑셀 꼴 · 145 정함 뒤집음) = 연결된 사본(`linked_snapshots` · 143-1)의
단가산출 줄(일위대가화 아닌 돈 로직 줄 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) +
사전조합(SC 참조 · 굳힌 `참조사본`) 안 단가산출 줄(입력값 = 그 문서 값) +
일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄(기계경비 밖 · 입력 = 부르는 식 값)
— 노무 · 자재 · 중기 줄은 각 목록표(산근 아님) · 일위대가화 줄 자체 = 대가(145-2 블록)
같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) ·
번호 = 첫 등장 차례 ·
로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근).
풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고
(M01 엔진 식 그대로) ·
단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 ·
중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 ·
% 줄(덧줄) = 엔진 식에 B09 비목 합.
끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) ·
작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 ·
줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합.
빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음)
+ 「연결 없음」.
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
손 줄 산근(166-2) = 설계내역서 손 줄이 고른 M01 로직(`ref` tab logic) — 마스터 로직을 같은 풀이로 ·
번호 = 사본 산근 뒤 · 쓰는 곳 = 「손 줄 명칭」 ·
`sg_hand{줄 키: 산근 키}` 로 내역서가 단가를 읽음.
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
수량 = 율 · 단위 %(149-4 `pct_extras`).
계산서 줄 덧칸(719-4) = 줄 `sheet`{쓰는입력 · 기준단위 · 환산근거} · 블록 `기준단위` —
화면 공용 계산서(`B09_Estimation_UI_Tabs_Sangeun_Sheet`) ·
덮개(719-3) = `B09_Estimation_Sangeun_Cover`(풀이 뒤 덧씌움).
"""
from __future__ import annotations
import copy
import hashlib
import json
from decimal import Decimal
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given
from B09_Estimation import B09_Estimation_Sangeun_Sum as ss
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total
from B09_Estimation.B09_Estimation_Copy_Prices import bundle
from B09_Estimation.B09_Estimation_Store import ref_rows
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete.M02_Item_Snapshot import (
PICKS_FILE,
VALUES_FILE,
linked_snapshots,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json
mf = item_prices.mf
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
PART_COST = {v: k for k, v in COST_PART.items()}
PART_NAMES = tuple((p, k) for k, p in COST_PART.items()) # 글 계 줄 차례(노 · 재 · 경)
PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3)
DEPTH = 4 # 하위 산근 깊이 상한
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
# ── 산근 정체 ─────────────────────────────────────────────────────────
def _clean_given(given: Any) -> dict[str, Any]:
return {
str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄"
}
def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str:
"""산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글.
145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음."""
body = {
"입력값": _clean_given(given),
"율": None if rate in (None, 100) else float(rate),
"고름": {str(k): v for k, v in sorted((picks or {}).items())},
}
text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str)
return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}"
def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None:
"""산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None."""
for row in sheets.get("sg_list") or []:
if row["key"] == key:
return {k: row[k] for k in ("no", *PARTS, "sum")}
return None
def _values(values: dict[str, Any], code: str) -> dict[str, Any]:
return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()}
def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]:
"""사본 하나의 산근 원천 줄(차례대로) — 단가산출 줄 · 사전조합 안 단가산출 줄 ·
일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄. 키 = 대가 블록 산근 줄 키(`Labor_Source`)와 같음."""
from M02_MasterTemplete import M02_Labor_Ref as refs
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return []
if whole is None:
whole = item_prices.load_prices(bundle(folder))
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
copies = labor.get("참조사본") or {}
out: list[dict[str, Any]] = []
def logic_line(code: str, given: Any, rate: Any, chosen: Any, promoted: bool) -> None:
if not is_money(whole, code):
return
if not promoted:
out.append({"code": code, "given": _clean_given(given), "rate": rate, "picks": chosen})
return
for call in line_calls(whole, code, given, chosen).values():
if call and call.get("code"):
out.append({"code": call["code"], "given": call["given"], "rate": None,
"picks": None}) # fmt: skip
def walk(lines: list, inner: bool, depth: int) -> None:
for one in lines:
if not isinstance(one, dict):
continue
ref = one.get("참조")
if isinstance(ref, dict):
doc = copies.get(refs.ref_id(ref))
if not isinstance(doc, dict) or depth >= refs.DEPTH:
continue
if refs.kind_of(ref.get("키")) == "sangeun": # 굳힌 SK(719-2) = 대가 · 하위 = 산근
base = str((doc.get("바탕") or {}).get("로직") or "")
if base and "굳힌줄" in doc:
logic_line(base, doc.get("입력값"), doc.get("켜쌓기율"), None, True)
inner_refs = [
{"참조": x["참조"]}
for x in doc["굳힌줄"].get("호표") or []
if isinstance(x.get("참조"), dict)
]
walk(inner_refs, True, depth + 1) # 단가산출 속 일위대가 · SK
continue
if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화
first = (doc.get("담은로직") or [None])[0]
if isinstance(first, dict) and first.get("로직"):
logic_line(str(first["로직"]), first.get("입력값"),
first.get("켜쌓기율"), first.get("고름"), True) # fmt: skip
continue
walk(doc.get("담은로직") or [], True, depth + 1) # 사전조합 = 굳힌 값
continue
if not one.get("로직"):
continue
code = str(one["로직"])
given = one.get("입력값") if inner else _values(values, code)
chosen = one.get("고름") if inner else (picks.get(code) or one.get("고름"))
logic_line(code, given, one.get("켜쌓기율"), chosen, one.get("승격") is True)
walk((labor.get("줄") or {}).get("담은로직") or [], False, 0)
return out
# ── 풀이 ──────────────────────────────────────────────────────────────
class _Prices:
"""엔진 단가(841-1) — M02 엔진이 사본 단가 묶음으로 낸 줄 금액 ÷ 수량 · 텍스트 수식 글이 읽음
(노무 · 자재 = 수 · 중기 = 노 · 재 · 경). B09 단가 책 · 바탕 표는 안 읽음."""
def __init__(self) -> None:
# ponytail: 키 하나 = 단가 하나(사본마다 기준 시점이 달라도 마지막 본 값) — 글 짓기 전용
self.labor: dict[str, float | None] = {}
self.material: dict[str, float | None] = {}
self.machine: dict[str, dict[str, float | None]] = {}
def _unit(row: dict) -> tuple[str, Decimal]:
"""결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 ·
줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2)."""
per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌
if not per:
return "", Decimal(1)
unit = " · ".join(dict.fromkeys(u for _, u in per))
return unit, Decimal(per[0][0]) if len(per) == 1 else Decimal(1)
def unit_of(whole: Any, code: str) -> str:
"""산근 1 단위 이름(「원/10㎡」 = ㎡) — `whole` = 사본 단가 묶음."""
try:
return _unit(whole.logic(code))[0]
except mf.FormulaError:
return ""
def _env(whole: Any, row: dict, given: dict) -> dict:
"""입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리)."""
auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None}
env = mf._inputs(row, {**auto, **m01._dec(given or {})})
for step in row.get("중간", []):
try:
value = mf.evaluate(mf.parse(step["식"]), env, whole, 0)
except mf.EmptyInput as e:
env[step["이름"]] = mf.Blank(str(e))
continue
env[step["이름"]] = ss.shown(row, step, value) # 표시 · Q ROUND 2(792-3)
return env
def is_money(whole: Any, code: str) -> bool:
"""돈 로직(호표 줄 꼴)인지 — 없는 로직 · 흐름 · 결과 로직 = 아님."""
try:
row = whole.logic(code)
except mf.FormulaError:
return False
return not (isinstance(row.get("흐름"), dict) or "결과" in row)
def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None:
"""로직 호표 줄(종류 로직) → 부른 것 — 기계경비 로직 `{machine: EQ 키 | None}` ·
다른 돈 로직 `{code, given}`(하위 산근) · 못 펼치면 None.
대가 품셈 줄(`B09_Estimation_Labor_Source`)과 산근 풀이가 같은 판정을 씀."""
node = mf.parse(item["요소"])
called = node[1] if node and node[0] == "logic" else None
try:
target = whole.logic(called) if called else None
except mf.FormulaError:
target = None
if not target:
return None
if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1)
keys = _captured(whole, node, env)
return {"machine": keys[0] if keys else None}
given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]}
return {"code": called, "given": _clean_given(given)}
def line_calls(whole: Any, code: str, given: Any, picks: Any = None) -> dict[int, dict | None]:
"""로직 하나의 로직 호표 줄마다 `call_of`(호표 차례 n → 부른 것) — 못 셈 줄 = None."""
try:
row = whole.logic(code)
env = _env(whole, row, _clean_given(given))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return {}
out: dict[int, dict | None] = {}
with mf.mp.choosing(picks):
for n, item in enumerate(row.get("호표", [])):
if item.get("종류") != "로직":
continue
try:
out[n] = call_of(whole, item, env)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
out[n] = None
return out
def _captured(whole: Any, node: Any, env: dict) -> list[str]:
"""식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계."""
seen: list[str] = []
base = whole.get
def get(ref: str) -> dict[str, Any]:
row = base(ref)
key = str(row.get("키") or "")
if key.startswith("EQ") and key not in seen:
seen.append(key)
return row
whole.get = get
try:
mf.evaluate(node, env, whole, 1)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표)
finally:
whole.get = base
return seen
def spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]:
"""규격 글(157-3) → (보일 글, 원문번호 글) — 기계 · 직종 번호 = 「이름 규격」(M02 85-1)."""
def shown(name: str, value: Any) -> Any:
if not isinstance(value, str):
return value
return labor_inputs._labels(whole, {"이름": name, "고르기": [value]}).get(value, value)
raw = ", ".join(f"{k}={v}" for k, v in given.items())
return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw
def _float(value: Any) -> Any:
return float(value) if isinstance(value, Decimal) else value
def _money(price: float | None, qty: float | None) -> float | None:
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
return None if price is None or qty is None else rounddown(price * qty, 1)
def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]:
"""총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸."""
out: dict[str, float | None] = {}
for part in PARTS:
s = total([ln[part] for ln in lines])
out[part] = None if s is None else rounddown(s, 0)
return {**out, "sum": total([out[p] for p in PARTS])}
class Solver:
"""한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함)."""
def __init__(self, supply: Any = None) -> None:
self.supply = supply or (lambda key: None) # 자재 키 → 관급 · 별도 · None(841-1)
self.prices = _Prices()
self.eng: list[dict[str, Any]] = [] # 지금 푸는 로직의 엔진 줄(호표 차례 · 841-1)
self.blocks: dict[str, dict[str, Any]] = {}
def add(
self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0
) -> dict[str, Any]:
key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks"))
if key not in self.blocks:
self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저
self.blocks[key].update(self._solve(whole, src, depth))
if owner and owner not in self.blocks[key]["uses"]:
self.blocks[key]["uses"].append(owner)
return self.blocks[key]
def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]:
code = src["code"]
try:
row = whole.logic(code)
except mf.FormulaError as e:
return self._stop(code, code, f"로직 없음 — {e}")
name = str(row.get("이름") or code)
if isinstance(row.get("흐름"), dict) or "결과" in row:
return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음")
unit, times = _unit(row)
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
lines: list[dict[str, Any]] = []
# 841-1 단가 = M02 엔진 줄 금액 ÷ 수량(켜쌓기율 없이 · 날 값 · B09 단가 책 안 읽음)
eng = labor_doc.run_line(whole, code, src["given"], None, src.get("picks"), raw=True)
try:
env = _env(whole, row, src["given"])
with mf.mp.choosing(src.get("picks")):
for n, item in enumerate(row.get("호표", [])):
self.eng = eng.get("줄") or [] # 하위 산근 풀이가 바꿔 놓아도 되돌림
lines.append(self._line(whole, item, n, env, times, rate, depth))
ss.places(row, lines) # 줄 자리(792-3)
self._extras(whole, row, env, lines)
self._aside(lines)
_annotate(whole, row, env, lines)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return self._stop(code, name, str(e) or type(e).__name__)
sums = ss.totals(row, lines) or block_totals(lines) # 끝전 · 조정(792-3)
why = next((ln["why"] for ln in lines if ln.get("why")), None)
head, foot = self._text(whole, src, times, lines)
for ln in lines: # 글 = 엔진 단가 셈 그대로 · 별도계상 재료 줄은 끝에 0 원 표시(841-1)
if "aside" in ln and ln.get("text"):
ln["text"] = [*ln["text"][:-1], f"{ln['text'][-1]} · 별도계상 = 0.0"]
if foot and any("aside" in ln for ln in lines) and sums.get("sum") is not None:
parts = " + ".join(f"{st_name} {sums[p]:,.0f}" for p, st_name in PART_NAMES)
foot = f"계 : {parts} = {sums['sum']:,.0f}"
spec, spec_raw = spec_text(whole, src["given"])
return {
"logic": code,
"name": name,
"spec": spec,
"spec_raw": spec_raw,
"unit": unit,
"source": row.get("출처") or "",
"lines": lines,
**sums,
"why": why,
"head": head,
"foot": foot,
"기준단위": msl._per_text(msl.mbs.result_per(row.get("결과단위"))) or None,
}
@staticmethod
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
return {"logic": code, "name": name, "spec": "", "spec_raw": "", "unit": "", "source": "",
"lines": [], **dict.fromkeys(PARTS), "sum": None, "why": why,
"head": [], "foot": ""} # fmt: skip
def price_of(self, key: str) -> Any:
"""글 짓기 단가(`Sangeun_Text` 꼴) — 노무 · 자재 = 수 · 중기 · 산근 = 비목 단가 묶음."""
if key in self.prices.labor:
return self.prices.labor[key]
if key in self.prices.material:
return self.prices.material[key]
if key in self.prices.machine:
return {p: num(self.prices.machine[key].get(p)) for p in PARTS}
block = self.blocks.get(key)
return {k: block.get(k) for k in (*PARTS, "sum", "no")} if block else None
def _text(self, whole, src, times, lines) -> tuple[list[str], str]:
"""텍스트 수식(155-5) → (머리 줄들, 계) · 줄마다 `text` 를 얹음(중기 = 세 줄 · 그 밖 한 줄).
줄 수가 안 맞으면 모두 머리에(값 칸과 어긋나게 얹지 않음)."""
from B09_Estimation.B09_Estimation_Sangeun_Text import render_sangeun_text
try:
text = render_sangeun_text(whole, src["code"], src["given"], self.price_of,
times=times, rate=src.get("rate"),
picks=src.get("picks")) # fmt: skip
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return [], ""
counts = [3 if ln["kind"] == "machine" and not ln.get("why") else 1 for ln in lines]
start = len(text) - 1 - sum(counts)
if start < 1:
return text, ""
at = start
for ln, n in zip(lines, counts):
ln["text"] = text[at : at + n]
at += n
return text[:start], text[-1]
def _aside(self, lines: list[dict[str, Any]]) -> None:
"""관급 · 별도계상 재료 = 산근 안 0원(841-1 · % 덧줄 밑수는 엔진 값 그대로 · 자재대 · 관급 단락)."""
for ln in lines:
key = (ln.get("ref") or {}).get("key")
if ln["kind"] != "material" or self.supply(key) not in SUPPLY:
continue
part = next(iter(ln["price"]), "material")
ln["aside"], ln[part], ln["price"] = ln[part], 0.0, {part: 0.0}
def _each(self, n: int, part: str, q: float) -> float | None:
"""엔진 줄 n 비목 단가 = 엔진 금액 ÷ 엔진 수량 · 안 쓰는 줄(수량 0) = 0 · 못 셈 = None."""
x = self.eng[n] if n < len(self.eng) else {}
qty, money = num(_float(x.get("수량"))), (x.get("비목") or {}).get(PART_COST[part])
if not qty:
return 0.0 if q == 0 else None
return None if money is None else float(money) / qty
def _blank(self, item: dict, why: str) -> dict[str, Any]:
part = COST_PART.get(item.get("비목"), "material")
return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None,
"unit": item.get("단위", ""), "ref": None, "price": {},
**{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip
def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]:
try:
raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0)
except mf.EmptyInput as e:
return self._blank(item, str(e))
qty = Decimal(str(raw)) / times * (rate or 1)
q = float(qty)
base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")}
zero = dict.fromkeys(PARTS, 0.0)
kind = item.get("종류")
if kind == "로직":
return self._logic_line(whole, item, n, env, q, base, zero, depth)
if kind == "인력":
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
price = self._each(n, "labor", q)
self.prices.labor[key] = price
why = None if price is not None else f"노임 단가 없음(M02 엔진) 「{key}」"
return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key},
"price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip
ref = (
{k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else mf.fill(item["요소"], env)
)
_price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n)
goods = (extra or {}).get("품목") or {}
key = str(
goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "")
)
try: # 자리표시 글(「MO:경유:전국평균」) = 그 줄 키로
key = str((whole.get(key) or {}).get("키") or key)
except (mf.FormulaError, KeyError, TypeError, ValueError):
pass
part = COST_PART.get(item.get("비목"), "material")
price = self._each(n, part, q)
self.prices.material[key] = price
why = None if price is not None else f"자재 단가 없음(M02 엔진) 「{key}」"
spec = goods.get("규격") or ""
ref = {"tab": "compare", "key": key}
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
"price": {part: price}, part: _money(price, q), "why": why,
"_goods": goods} # fmt: skip
def _logic_line(self, whole, item, n, env, q, base, zero, depth) -> dict[str, Any]:
call = call_of(whole, item, env)
if call and "machine" in call:
keys = [call["machine"]] if call["machine"] else []
price = {p: self._each(n, p, q) for p in PARTS} # 기계 시간당 = M02 엔진 값(841-1)
m = None not in price.values()
if keys:
self.prices.machine[keys[0]] = price
why = (
None
if m
else f"기계 시간당 없음(M02 엔진) 「{keys[0] if keys else item.get('이름')}」"
)
amounts = {p: _money(price[p], q) if m else None for p in PARTS}
ref = {"tab": "machine", "key": keys[0] if keys else ""}
return {**base, "kind": "machine", "ref": ref,
"price": price, **amounts, "why": why} # fmt: skip
if not call or depth >= DEPTH:
return {**self._blank(item, "하위 로직을 못 펼침"), **base}
sub = self.add(whole, {"code": call["code"], "given": call["given"]}, None, depth + 1)
price = {p: sub.get(p) for p in PARTS}
why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸"
return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]},
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
def _extras(self, whole, row, env, lines) -> None:
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별 ·
「X * Y / 100」 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`)."""
heads = [{} for _ in row.get("호표", [])]
pcts = labor_doc.pct_extras(
whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env)
)
for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]):
sums = ss.sums(row, lines) # 합밖 · 제외(792-3)
part = COST_PART.get(extra.get("비목"), "material")
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
if "율" in pct:
line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%")
if of := PCT_OF.get(str(pct.get("규격") or "").removesuffix("의 %")):
line["of"] = of # 719-3 덮개 다시 셈 밑수(`Sangeun_Cover`)
if any(v is None for v in sums.values()):
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
continue
env.update({k: Decimal(str(v)) for k, v in sums.items()})
ss.scope(row, lines, env) # 번지 · 단계(792-3)
env["줄"] = {
ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS)))
for ln in lines
if ln["name"]
}
try:
value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0))
except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e:
lines.append({**line, part: None, "price": {}, "why": str(e)})
continue
money = ss.extra(row, extra, value, env) # 줄 자리(792-3)
lines.append({**line, "price": {part: value}, part: money, "why": None})
def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None:
"""719-4 계산서 줄 덧칸 `sheet`{쓰는입력 · 기준단위 · 환산근거} — `master_sheet_line` 한 벌
(M01 `/text` · M02 `/logic-lib/sheet` 와 같은 뜻) · 묶음 · 장비 = 줄 `kind` 로 화면이 가름."""
per = msl.mbs.result_per(row.get("결과단위"))
items = [*row.get("호표", []), *row.get("덧줄", [])]
for item, ln in zip(items, lines):
goods = ln.pop("_goods", None) or {}
exprs = [item["식"]] if "식" in item else [item.get("수량"), item.get("요소")]
seen = {"품목": goods, "단위": item.get("단위")}
try:
got = msl._fields(whole, row, exprs, dict(env), per, seen, None)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
got = {}
ln["sheet"] = got
# ── 모음 ──────────────────────────────────────────────────────────────
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
def _input_names(code: str) -> list[str]:
try:
return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []]
except mf.FormulaError:
return []
def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]:
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] ·
운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본)."""
out = []
dist = (user or {}).get("haul_dist") or {}
for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함
ref = row.get("ref") or {}
if ref.get("tab") != "logic" or not ref.get("code"):
continue
if row["key"] in dist:
ref = {
**ref,
"given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]),
}
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개
out.append((row["key"], f"{where} {row.get('name') or ref.get('name') or row['key']}", src))
return out
def hand_whole(hands: list[tuple[str, str, dict[str, Any]]]) -> Any:
"""손 줄 로직의 닫힌 단가 묶음(사본 `단가.json` 꼴 · 마스터 캐시는 안 건드림)."""
codes = list(dict.fromkeys(src["code"] for _k, _u, src in hands))
return item_prices.load_prices(item_prices.to_json(item_prices.collect(codes)))
def hand_resource_keys(user: dict[str, Any] | None) -> list[str]:
"""손 줄 로직이 부르는 자원 키(단가 책 수요 · `Linked.used_keys` 꼴) — 부른 차례 · 겹침 없음."""
hands = hand_sources(user)
if not hands:
return []
from B09_Estimation.B09_Estimation_Linked import RESOURCE_TABLES
whole = hand_whole(hands)
seen: list[str] = []
base = whole.get
def get(ref: str) -> dict[str, Any]:
row = base(ref)
key = str(row.get("키") or "")
if key[:2] in RESOURCE_TABLES and key not in seen:
seen.append(key)
return row
whole.get = get # 이 묶음 한 벌에만
for _key, _use, src in hands:
labor_doc.run_line(whole, src["code"], src["given"], src["rate"], src["picks"])
return seen
def collect(
entries: list[tuple[str, Path]],
hands: list[tuple[str, str, dict[str, Any]]] | None = None,
memo: dict[str, Any] | None = None,
supply: Any = None,
) -> dict[str, Any]:
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 ·
supply = 자재 키 → 관급 · 별도(841-1 · 대비표 표시) ·
`hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직) ·
memo = 같은 사본 · 손 줄의 앞 풀이(두 번 셈) — 앞 풀이가 본 단가가 같으면 그 풀이 사본."""
if memo and "sg" in memo: # 841-1 풀이 = 사본 · 엔진만(바탕 표 안 읽음) → 앞 풀이 그대로
return copy.deepcopy(memo["sg"])
solver = Solver(supply)
for owner, folder in entries:
whole = item_prices.load_prices(bundle(folder))
for src in rows_of(folder, whole):
solver.add(whole, src, owner)
hand_keys: dict[str, str] = {}
if hands:
whole = hand_whole(hands)
for row_key, use, src in hands:
hand_keys[row_key] = solver.add(whole, src, use)["key"]
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
listing = [{k: b[k] for k in LISTED} for b in blocks]
gaps = [
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
for b in blocks
if b["sum"] is None
]
out = {
"sg": blocks,
"sg_list": listing,
"sg_gaps": gaps,
"sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]),
"sg_hand": hand_keys,
}
if memo is not None:
memo.update(sg=copy.deepcopy(out))
return out
def build(
project_root: str | Path,
user: dict[str, Any] | None = None,
memo: dict[str, Any] | None = None,
supply: Any = None,
) -> dict[str, Any]:
"""연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 +
설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs` ·
memo = 같은 root · user 두 번 셈의 앞 풀이(`collect`)."""
from B09_Estimation.B09_Estimation_Bill_Library import copies
base = structures_dir(project_root)
entries = [
(owner, base / linked["폴더"])
for owner, linked in sorted(linked_snapshots(project_root).items())
if "일위대가" in linked.get("부분", [])
]
entries += [
(f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root)
]
return collect(entries, hand_sources(user), memo, supply)