Files
Aislo/B09_Estimation/B09_Estimation_Labor_Source.py
T
eomsangdonandClaude Sonnet 5.5 8c65a323cc fix(번호 노출): 복사 창 규격 · 재료 창고 규격 · B09 줄 비고 · M02 격자 머리 · 비고 = 사람 말(층 낱말 · 이름) · 번호는 풍선만 (PLAN 704 딸림 ⑤)
- 사전조합 대가 블록 규격 = 「내 자료집 · 회사 · 마스터」 · 키는 spec_raw(풍선) · 복사해 새 일위대가 창 규격은 빈 칸
- B09 설계내역서 구조물 줄 비고 = 「층 낱말 · 조합 이름」(옛 「personal|1/3|SP3-000015」)
- M02 일위대가 격자 머리 = 번호꼴 일위대가번이면 이름 · 「일위대가」 · 참조 줄 비고 = 층 낱말 · 참조 창 제목 · 이미 담음 알림에서 키 뺌
- 내 자료집 저장 알림 · 같은 이름 물음에서 키 뺌
- 시험: test_704_3 격자 · test_701_1 비고 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:30:51 +09:00

450 lines
22 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 = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) ·
자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음)
품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄)
— 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근
산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`)
(155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음)
고정 고정 줄(705-1) = `fixed` 줄 하나 · `own` = 줄 단가 · `link` = 이음 키(셈 짝이 단가 책 값)
같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) ·
덮개 열쇠(702-2 · 내용 해시 밖 · 수량 · 단가가 바뀌어도 그대로) = 블록 `cover` + 줄 `cid` —
cover = 조합 `S:사본 폴더` · 서고 `L:폴더` · 사전조합 `C:참조` · 품셈 `P:로직 + 입력 해시`
cid = 줄 정체(비목 : 노임 · 자재 · 중기 코드 · 산근 · 부른 대가 로직 · 참조) + `#n번째`
줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 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_Bill_Library as library
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation import B09_Estimation_Sangeun_Text as st
from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD
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_Fixed as fixed
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], cover: str | None = None) -> str:
bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}"
if bid not in self.blocks:
mark = {"품셈": "P", "사전조합": "C"}.get(block["kind"], "S")
if cover is None:
origin = block["origin"]
cover = f"C:{origin['참조']}" if mark == "C" else f"{mark}:{_digest(origin)}"
lines = [{**ln, "cid": cid} for ln, cid in zip(block["lines"], self._cids(block))]
self.blocks[bid] = {"id": bid, "cover": cover, **block, "lines": lines}
return bid
def _cids(self, block: dict[str, Any]) -> list[str]:
"""줄 정체(702-2) — 같은 정체 n번째 = `#n` · 재적용으로 줄이 늘거나 바뀌어도 맞는 줄에."""
seen: dict[str, int] = {}
out = []
for line in block["lines"]:
kind, key = line["kind"], str(line.get("key") or "")
if kind == "daega" and key in self.blocks:
origin = self.blocks[key]["origin"]
key = str(origin.get("로직") or origin.get("참조") or key)
elif kind == "sangeun":
key = key.rsplit(":", 1)[0] # SG:로직:입력 해시 → 로직만
elif kind == "pct":
key = str(line.get("base") or "")
head = f"{kind}:{key or line.get('name') or ''}"
seen[head] = seen.get(head, -1) + 1
out.append(f"{head}#{seen[head]}")
return out
# ── 품셈(로직 하나) ─────────────────────────────────────────────────
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()))
shown = " · ".join( # 157-4 기계 · 직종 원문번호 = 「이름 규격」(번호 = 화면 풍선 spec_raw)
f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}"
for k, v in sorted(given.items())
)
return self.put(
{
"kind": "품셈",
"name": title or logic.get("이름") or code,
"spec": shown,
"spec_raw": 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
if fixed.is_fixed(one):
return fixed_line(one[fixed.FIXED], q)
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"],
"spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip
def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str:
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2)."""
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))
lines += [
_material(whole, mat, (mat.get("수량") or {}).get("손"))
for mat in doc.get("자재") or []
]
return self.put(
{
"kind": "사전조합",
"name": doc.get("이름") or ref.get("키"),
"spec": LAYER_WORD.get(str(ref.get("층")), ""), # 번호(키)는 풍선 글로만
"spec_raw": str(ref.get("키") or ""),
"unit": doc.get("단위") or "",
"cite": "",
"origin": {"참조": refs.ref_id(ref)},
"lines": lines,
}
)
# ── 조합(구조물 사본) ───────────────────────────────────────────────
def combo(self, folder: Path, cover: str | None = None) -> 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)
lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []]
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,
},
cover or f"S:{folder.name}",
)
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
if manifest.get("종류") != "precombo":
return self.combo(folder, f"L:{folder.name}")
labor = read_json(folder / LABOR_FILE)
ref = (manifest.get("원본") or {}).get("참조")
copies = (labor or {}).get("참조사본") or {}
doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None
if not isinstance(doc, dict):
return None
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
return self.precombo(whole, ref, doc, copies, 1)
def fixed_line(fx: dict, qty: Any) -> dict[str, Any]:
"""고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 ·
비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율)."""
if rate := fx.get("비율"):
name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"}
return {**name, "kind": "pct", "part": PART[rate["비목"]],
"base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip
link = fx.get("이음") or {}
return {
"kind": "fixed",
**{k: fx.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))},
"qty": _f(qty),
"own": {PART[k]: _f(v) for k, v in (fx.get("단가") or {}).items() if k in PART},
"link": {
"labor": link.get("노임"),
"material": link.get("자재"),
"machine": link.get("기계"),
},
}
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}
return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)}
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 cover_count(project_root: str | Path, folder: str) -> int:
"""702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록에 붙은 B09 고친 칸 수(카드 = 사본 셈)."""
from B09_Estimation.B09_Estimation_Store import read_user
cover = read_user(project_root).get("daega_cover") or {}
if not cover:
return 0
got = daega_inputs(
project_root
) # ponytail: 프로젝트 한 벌 셈 — 카드 열 때만 · 느리면 사본 하나로
by_id = {b["id"]: b for b in got["blocks"]}
owners = {linked["폴더"]: o for o, linked in linked_snapshots(project_root).items()}
todo, seen = [got["owners"].get(owners.get(folder, ""))], set()
while todo:
block = by_id.get(todo.pop())
if block is None or block["id"] in seen:
continue
seen.add(block["id"])
todo += [ln.get("key") for ln in block["lines"] if ln["kind"] == "daega"]
return sum(len(cover.get(by_id[bid]["cover"]) or {}) for bid in seen)
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 ·
설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 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
libs: dict[str, str] = {}
for folder, manifest in library.copies(project_root):
try:
bid = build.library(folder, manifest)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
bid = None
if bid:
libs[folder.name] = bid
order = {"조합": 0, "사전조합": 1, "품셈": 2}
blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]])
out = {"blocks": blocks, "owners": owners}
return _plain({**out, "libs": libs} if libs else out)