Files
Aislo/B09_Estimation/B09_Estimation_Labor_Source.py
T
eomsangdonandClaude Opus 5.5 a3047f5141 feat(B09): 일위대가표 · 일위대가목록표 — 사본 일위대가 → 조합 · 사전조합 · 품셈 블록 · B09 굳힌 단가 · 엑셀 끝수(145-2)
- 원천(서버 단독) = 연결된 사본 → 사본 셈 한 벌(142-1) 수량 ÷ 배 · 같은 원본 + 같은 입력값 = 한 호표(내용 해시)
  승격 아닌 로직 = 품셈 호표(노무 · 재료 · 중기 · % 줄) · 승격 · 옛 SP = 산근 참조 · SC = 겹 대가
- 셈 짝(파이썬 · TS) = 줄 ROUNDDOWN 1 · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0 · 관급 · 별도계상 = 대가 안 0원 · 빈칸 규칙
- 내역서 이음 daega_price(sheets, owner) / daegaPrice · 집계표 이음 = 블록 key · 줄 ref.key
- 탭 둘(목록표 · 블록표 · 호표 누름 이동 · 사본 없으면 「연결 없음」) · 길 = GET · PUT 셈에 대가
- 단가 수요(used_keys) = 사본 셈과 같은 길(빈 입력 자동값 · 참조사본 안 로직) — 144 시험 옛 단언 고침
- 새 시험 test_145_2_b09_labor_calc(엑셀 대가 1 · 2 · 9 · 14 검산 · 거울 · 견본 사본 · 빈 표 · 이음 꼴)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-09 15:28:45 +09:00

332 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` · 없으면 자리만)
같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) ·
줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) ÷ 배 = 부르는 호표 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 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"}
#: 기계 로직 줄 요소 — `로직(GC…, 기계='0201-0070', …)` 의 원문번호
MACHINE = re.compile(r"기계\s*=\s*['\"]([^'\"]+)['\"]")
#: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * 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`)이 들어오면 그 키 · 전엔 자리만."""
try:
from B09_Estimation.B09_Estimation_Sangeun import sangeun_key
except ImportError:
return f"SG:{code}:{_digest([given or {}, rate, picks or {}])}"
return 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]) -> 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)
for n, x in enumerate(engine):
item = items[n] if n < len(items) else None
lines.append(self._engine_line(whole, item, x, logic, n - len(items), bases))
unit = str(logic.get("결과단위") or "").replace("원/", "")
spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items()))
return self.put(
{
"kind": "품셈",
"name": logic.get("이름") or code,
"spec": spec,
"unit": unit,
"cite": logic.get("출처") or "",
"origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks},
"lines": lines,
}
)
def _engine_line(
self, whole: Any, item: dict | None, x: dict, logic: dict, extra: int, bases: dict
) -> 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
hit = MACHINE.search(str(item.get("요소") or ""))
row = whole.numbers.get("EQ", {}).get(hit.group(1)) if hit else None
if row is None:
# ponytail: 기계 아닌 하위 로직 줄은 B09 단가로 못 펼침 — 줄만 두고 빈칸
return {
**head,
"kind": "blank",
"qty": qty,
"why": "하위 로직 줄 — 대가 단가로 못 펼침",
}
return {**head, "kind": "machine", "key": row["키"], "spec": row.get("규격") or "",
"qty": qty} # fmt: skip
# ── 조합 · 사전조합 줄 ──────────────────────────────────────────────
def line_of(
self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0
) -> dict[str, Any]:
"""담은 로직 줄 하나 → 블록 줄 — 수량 = 수량 ÷ 배(부르는 호표 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]
key = sangeun_ref(str(first.get("로직") or ""), first.get("입력값"),
first.get("켜쌓기율"), first.get("고름")) # fmt: skip
return {"kind": "sangeun", "key": key, "name": doc.get("이름") or 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 True:
key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
return {"kind": "sangeun", "key": key, "name": name, "qty": each}
bid = self.poomsem(whole, one)
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})