Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
427 lines
19 KiB
Python
427 lines
19 KiB
Python
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||
|
||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
|
||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||
켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율
|
||
(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수).
|
||
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
|
||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
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_Labor_Inputs as labor_inputs
|
||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||
|
||
mf = m01.cm.mf
|
||
mm = mf.mp.mm
|
||
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||
MATERIAL_HEAD = "자재|"
|
||
_MAT_COLS = ("코드", "이름", "규격", "단위")
|
||
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
|
||
|
||
|
||
def _bad(text: str) -> store.StoreError:
|
||
return store.StoreError(400, text)
|
||
|
||
|
||
def _number(raw: Any) -> Decimal | None:
|
||
"""칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌)."""
|
||
if raw is None or isinstance(raw, bool):
|
||
return None
|
||
try:
|
||
got = Decimal(str(raw).replace(",", "").strip())
|
||
except InvalidOperation:
|
||
return None
|
||
return got if got.is_finite() else None
|
||
|
||
|
||
def _specs(logic: dict) -> list[dict]:
|
||
"""로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」."""
|
||
if isinstance(logic.get("흐름"), dict):
|
||
return mf.inputs_of(logic)
|
||
return list(logic.get("입력") or [])
|
||
|
||
|
||
def _qty(value: Any, where: str) -> dict | None:
|
||
if value is None:
|
||
return None
|
||
hand = _number(value.get("손")) if isinstance(value, dict) else None
|
||
if hand is None:
|
||
raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}")
|
||
return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)}
|
||
|
||
|
||
# ── 정리 · 검사 ───────────────────────────────────────────────────────
|
||
def clean(doc: Any) -> dict[str, Any]:
|
||
"""서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 ·
|
||
모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것."""
|
||
if not isinstance(doc, dict):
|
||
raise _bad("일위대가 문서가 묶음 아님")
|
||
whole = m01.loaded()[1]
|
||
lines, seen, here = [], set(), None
|
||
for at, one in enumerate(doc.get("담은로직") or [], start=1):
|
||
where = f"단가산출 로직 {at}째"
|
||
if not isinstance(one, dict):
|
||
raise _bad(f"{where} · 묶음 아님")
|
||
if one.get("참조") is not None and not one.get("로직"):
|
||
here = here or refs.here()
|
||
ref = refs.check_ref(one["참조"], where, here)
|
||
if refs.ref_id(ref) in seen:
|
||
raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」")
|
||
seen.add(refs.ref_id(ref))
|
||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||
lines.append(
|
||
{
|
||
"참조": ref,
|
||
"별칭": alias,
|
||
"메모": str(memo) if memo else None,
|
||
"수량": _qty(one.get("수량"), where),
|
||
}
|
||
)
|
||
continue
|
||
code = str(one.get("로직") or "")
|
||
try:
|
||
logic = whole.logic(code)
|
||
except mf.FormulaError as e:
|
||
raise _bad(f"{where} · {e}") from e
|
||
if code in seen:
|
||
raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」")
|
||
seen.add(code)
|
||
names = {str(spec.get("이름")) for spec in _specs(logic)}
|
||
given = one.get("입력값") or {}
|
||
if not isinstance(given, dict):
|
||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||
rate = _rate(one.get("켜쌓기율"), code, where)
|
||
lines.append(
|
||
{
|
||
"로직": code,
|
||
"별칭": alias,
|
||
"메모": str(memo) if memo else None,
|
||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||
"수량": _qty(one.get("수량"), where),
|
||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||
}
|
||
)
|
||
mats, codes = [], set()
|
||
for at, one in enumerate(doc.get("자재") or [], start=1):
|
||
where = f"자재 {at}째"
|
||
code = str(one.get("코드") or "") if isinstance(one, dict) else ""
|
||
try:
|
||
row = whole.get(code) if code[:2] == mm.MARKET else None
|
||
except mf.FormulaError:
|
||
row = None
|
||
if row is None:
|
||
raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」")
|
||
if code in codes:
|
||
raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」")
|
||
codes.add(code)
|
||
mats.append(
|
||
{
|
||
"코드": code,
|
||
**{k: row.get(k) or "" for k in _MAT_COLS[1:]},
|
||
"수량": _qty(one.get("수량"), where),
|
||
}
|
||
)
|
||
out = {
|
||
"종류": "일위대가",
|
||
"판": doc.get("판") or 1,
|
||
"열": doc.get("열"),
|
||
"단위": str(doc["단위"]) if doc.get("단위") else None,
|
||
"본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None,
|
||
"담은로직": lines,
|
||
"자재": mats,
|
||
}
|
||
if doc.get("일위대가번"):
|
||
out["일위대가번"] = doc["일위대가번"]
|
||
return out
|
||
|
||
|
||
def _alias(alias: Any, where: str) -> str | None:
|
||
if alias is not None and not isinstance(alias, str):
|
||
raise _bad(f"{where} · 별칭은 글")
|
||
alias = (alias or "").strip()
|
||
if len(alias) > ALIAS_MAX:
|
||
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
|
||
return alias or None
|
||
|
||
|
||
def _rate(value: Any, code: str, where: str) -> int | float | None:
|
||
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
|
||
if value is None or value == "":
|
||
return None
|
||
rate = _number(value)
|
||
if rate is None or not Decimal(0) < rate <= Decimal(200):
|
||
raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %")
|
||
if rate != rate.quantize(Decimal("0.1")):
|
||
raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지")
|
||
if rate == 100 or code not in labor_inputs.layered():
|
||
return None
|
||
return int(rate) if rate == rate.to_integral_value() else float(rate)
|
||
|
||
|
||
def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
|
||
"""엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로."""
|
||
if rate in (None, 100) or "결과" in got:
|
||
return got
|
||
times = Decimal(str(rate)) / 100
|
||
|
||
def mul(value: Any) -> Any:
|
||
return value * times if isinstance(value, Decimal) else value
|
||
|
||
lines = [
|
||
{
|
||
**x,
|
||
"수량": mul(x.get("수량")),
|
||
"금액": mul(x.get("금액")),
|
||
"비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()},
|
||
}
|
||
for x in got.get("줄") or []
|
||
]
|
||
sums = {k: mul(got[k]) for k in COSTS}
|
||
return {**got, "줄": lines, **sums, "계": sum(sums.values())}
|
||
|
||
|
||
def from_combo(key: str) -> dict[str, Any]:
|
||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||
없는 조합은 404."""
|
||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||
|
||
row = combos.find(key)
|
||
if row is None:
|
||
raise store.StoreError(404, f"없는 일위대가 조합 「{key}」")
|
||
layer, owner = combos.where(key) or (None, None)
|
||
return {
|
||
"단위": row.get("단위"),
|
||
"본뜬": {"층": layer, "주인": owner, "키": key},
|
||
"담은로직": [
|
||
{"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None}
|
||
for one in row.get("담은로직") or []
|
||
if isinstance(one, dict)
|
||
],
|
||
"자재": [],
|
||
}
|
||
|
||
|
||
# ── 알약 · 면적 값 ────────────────────────────────────────────────────
|
||
def material_key(code: str) -> str:
|
||
return f"{MATERIAL_HEAD}{code}"
|
||
|
||
|
||
def pills(doc: dict) -> list[dict[str, Any]]:
|
||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) +
|
||
자재 알약 「자재|MT키」."""
|
||
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||
logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)]
|
||
mats = [
|
||
{
|
||
"키": material_key(m["코드"]),
|
||
"이름": m.get("이름") or m["코드"],
|
||
"단위": m.get("단위") or "",
|
||
"자재": {k: m.get(k) or "" for k in _MAT_COLS},
|
||
}
|
||
for m in doc.get("자재") or []
|
||
]
|
||
return [*logic, *mats]
|
||
|
||
|
||
def area_values(area: Any) -> dict[str, Decimal]:
|
||
"""면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만."""
|
||
out: dict[str, Decimal] = {}
|
||
if not isinstance(area, dict):
|
||
return out
|
||
calc = area.get("계산값") or {}
|
||
for sheet in area.get("시트") or []:
|
||
cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {}
|
||
for link in sheet.get("연결") or []:
|
||
at = link.get("칸")
|
||
raw = (got.get(at) or {}).get("수")
|
||
if raw is None:
|
||
raw = (cells.get(at) or {}).get("값")
|
||
value = _number(raw)
|
||
if value is not None and link.get("키"):
|
||
out[str(link["키"])] = value
|
||
return out
|
||
|
||
|
||
def _area_of(column: Any) -> dict[str, Decimal]:
|
||
"""지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것."""
|
||
try:
|
||
path = store._path("area", str(column or ""))
|
||
except store.StoreError:
|
||
return {}
|
||
return area_values(json.loads(path.read_bytes())) if path.is_file() else {}
|
||
|
||
|
||
# ── 계산 ──────────────────────────────────────────────────────────────
|
||
def _money(value: Any) -> dict[str, Decimal] | None:
|
||
if not isinstance(value, dict):
|
||
return None
|
||
return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")}
|
||
|
||
|
||
def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None:
|
||
"""반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None."""
|
||
if qty is None:
|
||
return None
|
||
return {k: base[k] * qty / times for k in (*COSTS, "계")}
|
||
|
||
|
||
def _sum(rows: list[dict | None]) -> dict[str, Decimal]:
|
||
return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")}
|
||
|
||
|
||
def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]:
|
||
"""(수량, 출처) — 손 · 칸(알약 키) · 없음."""
|
||
if isinstance(hand, dict) and _number(hand.get("손")) is not None:
|
||
return _number(hand["손"]), "손"
|
||
if key and key in given:
|
||
return given[key], "칸"
|
||
return None, "없음"
|
||
|
||
|
||
def _is_ref(one: Any) -> bool:
|
||
return isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||
|
||
|
||
def line_id(one: dict) -> Any:
|
||
"""줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`)."""
|
||
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
|
||
|
||
|
||
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
|
||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
|
||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
|
||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
|
||
lines, mats, bases, reflects = [], [], [], []
|
||
for line in result.get("줄") or []:
|
||
qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have)
|
||
base = _money(line.get("기준"))
|
||
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
|
||
bases.append(base)
|
||
reflects.append(done)
|
||
lines.append({**line, "수량": qty, "수량출처": source, "반영": done})
|
||
for one in result.get("자재") or []:
|
||
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
|
||
price = _number(one.get("기준단가"))
|
||
cost = price * qty if price is not None and qty is not None else None
|
||
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
|
||
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
|
||
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
|
||
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
|
||
|
||
|
||
def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
|
||
"""자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element`
|
||
(값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만."""
|
||
row = master.get(code) or {}
|
||
prices = {slot: row.get(slot) for slot in mm.SLOTS}
|
||
try:
|
||
price, source = mm.element(master, code, {}, unit or None)
|
||
except mf.FormulaError as e:
|
||
return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)}
|
||
slot = str(source).split(".")[-1] if source else None
|
||
return {
|
||
"기준단가": price,
|
||
"가격": prices,
|
||
"기준칸": slot if slot in mm.SLOTS else None,
|
||
"까닭": None if price is not None else "단가 없음",
|
||
}
|
||
|
||
|
||
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||
whole = m01.loaded()[1]
|
||
expand = refs.Expand(whole)
|
||
by_logic = {
|
||
code: pill
|
||
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||
for code in pill["로직"]
|
||
}
|
||
lines = []
|
||
for one in doc.get("담은로직") or []:
|
||
if _is_ref(one):
|
||
lines.append(ref_line(one["참조"], expand(one["참조"])))
|
||
continue
|
||
code = one["로직"]
|
||
pill = by_logic.get(code) or {}
|
||
line = {
|
||
"로직": code,
|
||
"알약": pill.get("키"),
|
||
"분모": pill.get("단위"),
|
||
"배": pill.get("배") or 1,
|
||
}
|
||
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
|
||
mats = [
|
||
{
|
||
"코드": one["코드"],
|
||
"알약": material_key(one["코드"]),
|
||
**material_price(whole, one["코드"], one.get("단위")),
|
||
}
|
||
for one in doc.get("자재") or []
|
||
]
|
||
have = _area_of(doc.get("열")) if given is None else given
|
||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||
|
||
|
||
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
|
||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
|
||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||
try:
|
||
logic = whole.logic(code)
|
||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||
if "결과" in got:
|
||
return {"결과": got["결과"], "기준": None, "까닭": None}
|
||
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
|
||
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
|
||
|
||
|
||
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
|
||
"""참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층)."""
|
||
line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")}
|
||
line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")})
|
||
if "줄" in got: # 단가승격 = 원 로직 키 · 표(줄 누름 상세 · 50-3 ④)
|
||
line.update({"원로직": got.get("로직"), "줄": got["줄"]})
|
||
return {**line, "안": got["안"]} if "안" in got else line
|
||
|
||
|
||
def _json(value: Any) -> Any:
|
||
"""Decimal → JSON 수(정수면 int)."""
|
||
if isinstance(value, Decimal):
|
||
return int(value) if value == value.to_integral_value() else float(value)
|
||
if isinstance(value, list):
|
||
return [_json(v) for v in value]
|
||
if isinstance(value, dict):
|
||
return {k: _json(v) for k, v in value.items()}
|
||
return value
|
||
|
||
|
||
def server(doc: Any) -> dict[str, Any]:
|
||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
|
||
got = clean(doc)
|
||
return {**got, "계산값": calc(got)}
|