Files
Aislo/B09_Estimation/B09_Estimation_Labor_Source.py
T
eomsangdonandClaude Opus 5.5 82a3cb5e7d feat(b09): 호표 갈래 뒤집기 — 일위대가화 줄 = 대가 · 단가산출 줄 = 산근(155-3 · 155-4)
- 일위대가목록 = 구조물 조합 + 사전조합 + 일위대가화(승격 · 옛 SP) 줄 · 단가산출 줄 = 산근 줄
- 산근 줄 수량 = 수량 그대로(배 ≠ 1 로직 금액 1/배 흠) · 대가 줄만 ÷ 배
- 품셈 블록 로직 줄 판정 = 산근 풀이와 같은 call_of(기계경비 = 중기 · 그 밖 = 하위 산근)
- 비고 「산근 n호표」(Calc_Labor 파이썬 · TS 짝) · 산근 줄 누름 = 단가산출근거 탭
- 산근목록 원천 = 단가산출 줄 + 사전조합 안 줄 + 일위대가화 줄 하위 로직(노무 · 자재 · 중기 줄 제외)
- 산근 자재 자리표시 키(MO:경유:…) = 자재단가대비표 키로
- 시험: 145-1 다섯 · 145-2 셋 새 갈래로(머리에 근거 한 줄) · 새 test_155_b09_sources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 19:20:39 +09:00

335 lines
16 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-2) — 서버 단독 · 읽기만 · 값(단가)은 안 씀.
연결된 사본(`M02_Item_Snapshot.linked_snapshots`)마다 `일위대가.json` 을 사본 셈 한 벌
(`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침:
조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손)
품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄)
— 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근
산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`)
(155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음)
같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) ·
줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 1 단위 몫 ·
산근 단가는 이미 1 단위).
단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄.
"""
from __future__ import annotations
import hashlib
import json
import re
from decimal import Decimal
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Sangeun as sg
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_Ref as refs
from M02_MasterTemplete.M02_Item_Snapshot import (
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
linked_snapshots,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
mf = item_prices.mf
#: 엔진 비목 → 블록 비목
PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
#: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄)
BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*")
BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*")
BASE_LABOR = re.compile(r"^\s*\(.*\*\s*LB\d+")
AREA_FILE = "면적.json"
def _f(value: Any) -> float | None:
if isinstance(value, bool) or value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _digest(value: Any) -> str:
text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
def sangeun_ref(code: str, given: Any, rate: Any, picks: Any) -> str:
"""산근 호표 키 — 145-1 `B09_Estimation_Sangeun.sangeun_key`."""
return sg.sangeun_key(code, given or {}, rate, picks or {})
class Builder:
"""한 프로젝트 블록 모음 — 같은 id 는 한 번만(처음 본 차례)."""
def __init__(self) -> None:
self.blocks: dict[str, dict[str, Any]] = {}
self.gaps: list[dict[str, str]] = []
def put(self, block: dict[str, Any]) -> str:
bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}"
if bid not in self.blocks:
self.blocks[bid] = {"id": bid, **block}
return bid
# ── 품셈(로직 하나) ─────────────────────────────────────────────────
def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None:
code = str(one.get("로직") or "")
given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름")
try:
logic = whole.logic(code)
except mf.FormulaError:
return None
got = labor_doc.run_line(whole, code, given, rate, picks)
if got.get("기준") is None:
return None # 돈 아닌 로직 · 못 돈 로직 — 조합 줄이 까닭을 냄
items = logic.get("호표") or []
lines = []
engine = got.get("줄") or []
bases = _engine_bases(engine[: len(items)], items)
calls = sg.line_calls(whole, code, given, picks)
for n, x in enumerate(engine):
item = items[n] if n < len(items) else None
lines.append(self._engine_line(item, x, logic, n - len(items), bases, calls.get(n)))
eq = {str(r.get("키")): r for r in (whole.numbers.get("EQ") or {}).values()}
for line in lines:
if line["kind"] == "machine":
line["spec"] = (eq.get(line["key"]) or {}).get("규격") or ""
unit = str(logic.get("결과단위") or "").replace("원/", "")
spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items()))
return self.put(
{
"kind": "품셈",
"name": title or logic.get("이름") or code,
"spec": spec,
"unit": unit,
"cite": logic.get("출처") or "",
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
"lines": lines,
}
)
def _engine_line(
self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None
) -> dict[str, Any]:
head = {"name": x.get("이름") or "", "spec": "", "unit": x.get("단위") or ""}
qty = _f(x.get("수량"))
goods = x.get("품목") or {}
if item is None: # 덧줄 — 밑수 × % / 100(% = 엔진 값 ÷ 엔진 밑수)
add = (logic.get("덧줄") or [])[extra]
base, label = _base_of(str(add.get("식") or ""))
value, under = _f(x.get("금액")), bases.get(base) if base else None
if value is None or not under:
# ponytail: 밑수 없는 덧줄(손료 식 등)은 B09 단가로 못 셈 — 줄만 두고 빈칸
return {**head, "kind": "blank", "qty": qty, "why": "덧줄 — 대가 단가로 못 셈"}
part = PART.get(str(add.get("비목")), "expense")
return {**head, "kind": "pct", "part": part, "base": base, "spec": f"{label}의 %",
"unit": "%", "qty": round(value / under * 100, 6)} # fmt: skip
kind = item.get("종류")
if kind == "인력":
key = goods.get("키") or str(item.get("요소") or "")
return {**head, "kind": "labor", "key": key, "qty": qty}
if kind == "재료":
if not goods.get("키"):
return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"}
return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "",
"qty": qty} # fmt: skip
# 로직 줄 — `Sangeun.call_of` 판정: 기계경비 로직 = 중기 · 그 밖 = 하위 산근
if call and call.get("machine"):
return {**head, "kind": "machine", "key": call["machine"], "qty": qty}
if call and call.get("code"):
key = sg.sangeun_key(call["code"], call["given"])
return {**head, "kind": "sangeun", "key": key, "qty": qty}
return {**head, "kind": "blank", "qty": qty, "why": "하위 로직 줄 — 못 펼침"}
# ── 조합 · 사전조합 줄 ──────────────────────────────────────────────
def line_of(
self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0
) -> dict[str, Any]:
"""담은 로직 줄 하나 → 블록 줄 — 대가 줄 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫) ·
산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위)."""
q = _f(qty)
t = _f(times) or 1.0
each = None if q is None else q / t
ref = one.get("참조")
if isinstance(ref, dict):
doc = copies.get(refs.ref_id(ref))
if not isinstance(doc, dict):
return {
"kind": "blank",
"name": name,
"qty": each,
"why": f"없는 참조 「{ref.get('키')}」",
}
if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가
first = (doc.get("담은로직") or [{}])[0]
bid = self.poomsem(whole, first, doc.get("이름") or name)
if bid is None:
return {"kind": "blank", "name": name, "qty": each, "why": "돈 아닌 로직"}
return {"kind": "daega", "key": bid, "name": self.blocks[bid]["name"],
"unit": doc.get("단위") or "", "qty": each} # fmt: skip
bid = self.precombo(whole, ref, doc, copies, depth + 1)
return {"kind": "daega", "key": bid, "name": doc.get("이름") or name,
"unit": doc.get("단위") or "", "qty": each} # fmt: skip
code = str(one.get("로직") or "")
if one.get("승격") is not True: # 단가산출 줄 = 산근
if not sg.is_money(whole, code):
return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"}
key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
return {"kind": "sangeun", "key": key, "name": name or code,
"unit": sg.unit_of(code), "qty": q} # fmt: skip
bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name)
if bid is None:
return {
"kind": "blank",
"name": name or code,
"qty": each,
"why": "돈 아닌 로직 · 못 돈 로직",
}
block = self.blocks[bid]
return {"kind": "daega", "key": bid, "name": block["name"], "spec": block["spec"],
"unit": block["unit"], "qty": each} # fmt: skip
def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str:
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
pills = {
code: pill
for pill in combo_store.combo_pills({"담은로직": inner})["물량"]
for code in pill["로직"]
}
lines = []
for one in inner:
hand = _f((one.get("수량") or {}).get("손"))
pill = pills.get(one.get("로직")) or {}
times = pill.get("배") if "로직" in one else 1
name = pill.get("이름") or str(one.get("로직") or "")
if depth >= refs.DEPTH and isinstance(one.get("참조"), dict):
lines.append({"kind": "blank", "name": name, "qty": hand, "why": "참조 깊이 넘음"})
continue
qty = 1 if hand is None else hand
lines.append(self.line_of(whole, one, qty, times, name, copies, depth))
return self.put(
{
"kind": "사전조합",
"name": doc.get("이름") or ref.get("키"),
"spec": str(ref.get("키") or ""),
"unit": doc.get("단위") or "",
"cite": "",
"origin": {"참조": refs.ref_id(ref)},
"lines": lines,
}
)
# ── 조합(구조물 사본) ───────────────────────────────────────────────
def combo(self, folder: Path) -> str | None:
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return None
prices = read_json(folder / PRICE_FILE) or {}
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
whole = item_prices.load_prices(prices)
result = item_prices.calc(labor, prices, values, picks, read_json(folder / AREA_FILE))
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
copies = labor.get("참조사본") or {}
lines = []
for one, done in zip(doc.get("담은로직") or [], result.get("줄") or []):
name = done.get("이름") or str(one.get("로직") or "")
pill = next(
(p for p in result.get("알약") or [] if p.get("키") == done.get("알약")), {}
)
line = self.line_of(
whole, one, done.get("수량"), done.get("배"), pill.get("이름") or name, copies
)
if line.get("qty") is None and line["kind"] != "blank":
line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"}
lines.append(line)
for mat in result.get("자재") or []:
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "",
"spec": row.get("규격") or "", "unit": row.get("단위") or "",
"qty": _f(mat.get("수량"))}) # fmt: skip
manifest = read_json(folder / MANIFEST_NAME) or {}
origin = manifest.get("원본") or {}
return self.put(
{
"kind": "조합",
"name": origin.get("name") or " ".join(origin.get("경로") or []) or folder.name,
"spec": " › ".join(origin.get("경로") or []),
"unit": (labor.get("줄") or {}).get("단위") or "",
"cite": "",
"origin": {k: origin.get(k) for k in ("layer", "owner", "key")},
"lines": lines,
}
)
def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]:
"""엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`."""
out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()}
out["lines:labor"] = 0.0
for x, item in zip(engine, items):
for name, value in (x.get("비목") or {}).items():
if name in PART:
out[f"part:{PART[name]}"] += _f(value) or 0
if item.get("종류") == "인력":
out["lines:labor"] += _f(x.get("금액")) or 0
if x.get("이름"):
out[f"line:{x['이름']}"] = _f(x.get("금액")) or 0
return out
def _base_of(formula: str) -> tuple[str | None, str]:
"""덧줄 식 → (밑수 갈래, 이름) — 모르는 꼴이면 (None, "")."""
if hit := BASE_PART.match(formula):
return f"part:{PART[hit.group(1)]}", hit.group(1)
if hit := BASE_LINE.match(formula):
return f"line:{hit.group(1)}", hit.group(1)
if BASE_LABOR.match(formula):
return "lines:labor", "인건비"
return None, ""
def _plain(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: _plain(v) for k, v in value.items()}
if isinstance(value, list):
return [_plain(v) for v in value]
return value
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것.
차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례.
"""
base = structures_dir(project_root)
build = Builder()
owners: dict[str, str] = {}
for owner, linked in sorted(linked_snapshots(project_root).items()):
if "일위대가" not in linked.get("부분", []):
continue
try:
bid = build.combo(base / linked["폴더"])
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음
if bid:
owners[owner] = bid
order = {"조합": 0, "사전조합": 1, "품셈": 2}
blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]])
return _plain({"blocks": blocks, "owners": owners})