Merge origin/sub_laptop_7 (50-3 ①②⑤ 서고 서버) into sub_laptop_2

- 충돌 M02_Labor_Doc.calc — sub7 run_line 을 취하고 50-4 「줄」 실음을 run_line 으로 옮김
- 단가승격 펼침 · 참조 줄 답에 원 로직 키 · 「줄」 실음(50-3 ④ 줄 상세용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-05 18:53:01 +09:00
co-authored by Claude Opus 5.5
12 changed files with 970 additions and 56 deletions
+76 -26
View File
@@ -1,12 +1,15 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
참조 줄(단가승격 · 사전조합 · 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`.
"""
@@ -19,6 +22,7 @@ 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_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
mf = m01.cm.mf
@@ -67,11 +71,27 @@ def clean(doc: Any) -> dict[str, Any]:
if not isinstance(doc, dict):
raise _bad("일위대가 문서가 묶음 아님")
whole = m01.loaded()[1]
lines, seen = [], set()
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)
@@ -84,16 +104,11 @@ def clean(doc: Any) -> dict[str, Any]:
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), one.get("별칭")
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}자까지")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
lines.append(
{
"로직": code,
"별칭": alias or None,
"별칭": 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),
@@ -133,6 +148,15 @@ def clean(doc: Any) -> dict[str, Any]:
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 from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
@@ -161,8 +185,10 @@ def material_key(code: str) -> str:
def pills(doc: dict) -> list[dict[str, Any]]:
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
"""면적산출 알약 — 로직 물량 알약(`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["코드"]),
@@ -230,16 +256,25 @@ def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | Non
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 = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []}
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.get("로직")), line.get("알약"), have)
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)
@@ -277,6 +312,7 @@ 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 []})["물량"]
@@ -284,6 +320,9 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
}
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 = {
@@ -292,20 +331,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})})
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__})
continue
if "결과" in got: # 돈 아닌 로직 — 반영 없음
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
continue
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
lines.append(
{**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
)
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
mats = [
{
"코드": one["코드"],
@@ -318,6 +344,30 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def run_line(whole: Any, code: str, given: Any) -> 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 = mf.run(whole, code, {**auto, **m01._dec(given or {})})
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):