- 원천(서버 단독) = 연결된 사본 → 사본 셈 한 벌(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
332 lines
16 KiB
Python
332 lines
16 KiB
Python
"""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})
|