Files
Aislo/M02_MasterTemplete/M02_Sangeun_Calc.py
T
eomsangdonandClaude Sonnet 5.5 6a429560fb fix(725-6): 바탕 없는 단가산출 공통 옵션 칸(더한줄 로직 입력 모음) · 상세 못 셈 줄 금액 「—」 + 까닭 풍선
- 바탕 없는 SK = 더한줄 로직 입력을 모아(같은 이름 하나) 공통 옵션 칸으로 보임 · 값 자리 = 시험 입력 그대로 · 바탕 있으면 지금 그대로
- 입력 바꾸면 SK 셈 바로(바탕 없음 = 시험 계산 단추 없음) · 셈 때 SK 줄 입력 아닌 이름은 거름(공통 옵션 지역이 SK 줄에서 「모르는 입력」으로 멈춤)
- 공용 상세 줄 금액 칸 = 까닭 있는 줄만 「—」 + 풍선(M01 줄은 까닭 없음 = 전후 같음)
- 시험 test_725_1 에 바탕 없음 지역 서울 5,297.37 · 화면 짜임 단언 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:49:31 +09:00

329 lines
15 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.
"""M02 단가산출(SK) 셈 — 바탕 사슬 → 고친 줄 → M01 엔진 한 벌(PLAN 715-1b · 715-2).
셈 = ① 바탕(M01 로직 · 바탕 SK 의 고친 줄 · 없음) ② `M02_Sangeun_Doc.patched` ③ 더한줄 참조 ·
(SK · SC) · 고정 줄 = 먼저 셈한 1 단위 노 · 재 · 경 → `값묶음`
④ M01 색인 위에 고친 줄을 SK 키로 얹어
(M01 시험 계산 `_swap` 과 같은 꼴 · 메모리만 · 정본 · 캐시 안 고침) `labor.run_line` — 자동값 ·
켜쌓기율 · 고름 · % 덧줄을 엔진이 다시 셈. 1 단위 = 기준 ÷ 배(바탕 로직 결과단위 앞 수).
계산 자리(CLAUDE.md 5장 ③) = 서버 단독 · 화면은 셈 길(저장 안 함 · 최신 응답만).
저장(`clean_doc`) = 꼴 검사 + 참조 위 층 · 있는 문서 · 순환 422 + 서버가 `원값` · `바탕판` 적음.
"""
from __future__ import annotations
import hashlib
import json
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Doc as sd
COSTS = labor.COSTS
Ref = dict[str, Any]
class _Over:
"""M01 색인 위에 고친 줄 하나 — `logic(키)` 만 가로챔(그 밖 그대로 · 색인 안 고침)."""
def __init__(self, whole: Any, key: str, row: dict) -> None:
self._whole, self._key, self._row = whole, key, row
def logic(self, ref: str) -> dict:
return self._row if ref == self._key else self._whole.logic(ref)
def __getattr__(self, name: str) -> Any:
return getattr(self._whole, name)
def _fail(e: sd.DocError) -> store.StoreError:
return store.StoreError(e.status, str(e))
def times_of(row: dict) -> Decimal:
"""결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌
return per[0][0] if len(per) == 1 else Decimal(1)
def fingerprint(row: dict) -> str:
raw = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
# ── 바탕 사슬 → 고친 줄 ─────────────────────────────────────────────────
def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[str, Any]:
"""이 SK 가 얹힐 바탕 — {줄, 고름, 자재표시, 입력값, 켜쌓기율, 짝잃음}."""
base = doc.get("바탕")
empty = {"고름": {}, "자재표시": {}, "입력값": {}, "켜쌓기율": None, "짝잃음": []}
if not base:
return {**empty, "줄": sd.empty_row(doc)}
if "로직" in base:
try:
return {**empty, "줄": whole.logic(base["로직"])}
except Exception as e: # noqa: BLE001 — 엔진 FormulaError
raise sd.DocError(f"바탕 로직 · {e}") from e
ref = base["참조"]
rid = refs.ref_id(ref)
if rid in trail:
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
raise sd.DocError("순환 — " + " → ".join(keys), 422)
if len(trail) >= refs.DEPTH:
raise sd.DocError(f"바탕 사슬이 {refs.DEPTH}단을 넘음", 422)
parent = read(ref)
if not isinstance(parent, dict):
raise sd.DocError(f"없는 바탕 「{ref.get('키')}」")
up = resolve(parent, whole, read, (*trail, rid))
lost = [{**x, "바탕": ref.get("키")} for x in up["짝잃음"]]
return {**up, "짝잃음": lost}
def resolve(
doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = (), given: dict | None = None
) -> dict[str, Any]:
"""SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` ·
`trail` = 지나온 참조 이름표(순환 · 깊이 422) · `given` = 공통 옵션(더한줄 로직 입력)."""
base = _base_of(doc, whole, read, trail)
common = {**base["입력값"], **(doc.get("입력값") or {}), **(given or {})}
doc, lack = _fill(doc, whole, common)
got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"])
items = got["줄"]["호표"]
for at, cid in enumerate(got["정체"][: len(items)]):
hit = re.match(r"^add:#(\d+)$", cid)
if hit and int(hit[1]) in lack:
items[at] = _gap(items[at], lack[int(hit[1])])
rate = doc.get("켜쌓기율", base["켜쌓기율"])
return {
**got,
"짝잃음": base["짝잃음"] + got["짝잃음"],
"입력값": {**base["입력값"], **(doc.get("입력값") or {})},
"켜쌓기율": rate,
"배": times_of(base["줄"]),
"바탕판": fingerprint(base["줄"]),
}
# ── 못 셈 줄 = 그 줄만 빠짐(725-1 · 725-2) ─────────────────────────────────
NO_QTY = "수량 없음" # `M02_Labor_Expand.NO_QTY` 와 같은 글(가져오기 둘레 피함)
NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결"
def _defaults(whole: Any, code: str) -> dict:
"""로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길 · 흐름 예시값 › 옛 자동값)."""
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Flow as flow
try:
got = flow.auto(code) or {"값": m01.mau.values(whole, whole.logic(code))}
except Exception: # noqa: BLE001 — 없는 로직 · 자동값 못 셈 = 기본값 없음
return {}
return got.get("값") or {}
def _fill(doc: dict, whole: Any, common: dict) -> tuple[dict, dict[int, str]]:
"""더한줄 로직 입력 = 줄 값 › 공통 옵션 › 로직 기본값 — (채운 사본, {더한줄 차례: 까닭})."""
adds, lack = [], {}
for k, one in enumerate(doc.get("더한줄") or []):
if one.get("종류") != "로직":
adds.append(one)
continue
try:
names = [str(s.get("이름")) for s in whole.logic(one["로직"]).get("입력") or []]
except Exception: # noqa: BLE001 — 없는 로직 = 검사(`sd.clean`)가 먼저 막음
names = []
mine = dict(one.get("입력값") or {})
blank = [n for n in names if mine.get(n) is None or not str(mine[n]).strip()]
auto = _defaults(whole, one["로직"]) if blank else {}
for n in blank:
mine.pop(n, None)
for v in (common.get(n), auto.get(n)):
if v is not None and str(v).strip():
mine[n] = v
break
miss = [n for n in names if n not in mine]
if miss:
lack[k] = f"입력 「{miss[0]}」 없음"
adds.append({**one, "입력값": mine})
return {**doc, "더한줄": adds}, lack
def _gap(item: dict, why: str) -> dict:
"""못 셈 줄 → 0 값묶음 줄(차례 그대로 · 계에 안 듦) + `빠짐` 까닭."""
zero = dict.fromkeys(COSTS, Decimal(0))
head = {k: item.get(k) or "" for k in ("이름", "단위")}
return {**head, "종류": "로직", "요소": None, "수량": item["수량"], "값묶음": zero, "빠짐": why}
def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None:
"""더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈 줄 = `_gap`(나머지 셈 계속)."""
for at, item in enumerate(row["호표"]):
if item.get("값묶음") is not None or not ("참조" in item or "고정" in item):
continue
sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"])
item["이름"] = item.get("이름") or sub.get("이름") or ""
item["단위"] = item.get("단위") or sub.get("단위") or ""
if sub.get("기준") is None:
why = str(sub.get("까닭") or "")
row["호표"][at] = _gap(item, NO_QTY_WHY if NO_QTY in why else why)
continue
item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS}
def run(
doc: dict,
whole: Any,
given: dict | None = None,
read: Any = refs.read_ref,
expand: Any = None,
trail: tuple = (),
) -> dict[str, Any]:
"""SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` ·
고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError`."""
from M02_MasterTemplete.M02_Labor_Expand import Expand
got = resolve(doc, whole, read, trail, given)
row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]}
_bundles(row, whole, expand or Expand(whole, read), trail)
head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]}
gaps = {at: x for at, x in enumerate(row["호표"]) if x.get("빠짐")}
key = str(doc.get("키") or "SK-000000")
over = _Over(whole, key, {**row, "키": key})
# 이 SK 줄의 입력만 — 더한줄 로직 입력은 `_fill` 이 따로 채움
names = {str(i.get("이름")) for i in row.get("입력") or []}
values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names}
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
covers = doc.get("덮개") or {}
lines = []
for at, (cid, line) in enumerate(zip(got["정체"], out.get("줄") or [])):
mine = covers.get(cid) or {}
mark = {"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {}
if at in gaps: # 못 셈 줄 = 금액 — · 까닭(725)
mark |= {"단가": None, "금액": None, "비목": {}, "까닭": gaps[at]["빠짐"]}
lines.append(
{**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})}
)
out = labor.drop_apart({**out, "줄": lines}, got["자재표시"])
base = out.get("기준")
unit = {k: Decimal(str(v)) / got["배"] for k, v in base.items()} if base else None
gone = [
{"cid": got["정체"][at], "이름": x.get("이름") or "", "까닭": x["빠짐"]}
for at, x in gaps.items()
]
return {**out, **head, "1단위": unit, "빠진줄": gone}
def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
"""펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭."""
head = {"이름": doc.get("이름"), "단위": doc.get("단위")}
try:
got = run(doc, whole, None, expand.read, expand, trail)
except sd.DocError as e:
return {**head, "기준": None, "까닭": str(e)}
gone = got.get("빠진줄") or [] # 쓰는 자리 = 빠진 줄 있으면 통째 기준 없음(부분 값 안 넘김)
why = got.get("까닭") or next((f"「{x['이름']}」 {x['까닭']}" for x in gone), None)
why = why or (None if got.get("1단위") else "돈 아닌 로직")
times = got["배"]
lines = [
{
**x,
"수량": None if x.get("수량") is None else Decimal(str(x["수량"])) / times,
"금액": None if x.get("금액") is None else Decimal(str(x["금액"])) / times,
}
for x in got.get("줄") or []
]
return {
**head,
"기준": None if gone else got["1단위"],
"까닭": why,
"줄": lines,
"짝잃음": got["짝잃음"],
}
def head_of(doc: dict) -> str:
return f"1 {doc.get('단위')} 당"
# ── 저장 ──────────────────────────────────────────────────────────────
def _check_refs(doc: dict, here_: refs.Here) -> None:
me = {"층": here_[0], "주인": here_[1], "키": doc["키"]}
base = doc.get("바탕") or {}
if "참조" in base:
base["참조"] = refs.check_ref(base["참조"], "바탕", here_)
if refs.kind_of(base["참조"]["키"]) != sd.KIND:
raise store.StoreError(400, "바탕 참조는 단가산출(SK)만")
for at, one in enumerate(doc.get("더한줄") or [], 1):
if "참조" in one:
one["참조"] = refs.check_ref(one["참조"], f"더한줄 {at}째", here_)
for ref in refs.refs_in(doc):
if refs.ref_id(ref) == refs.ref_id(me):
raise store.StoreError(422, f"순환 — 자기 자신을 담음 「{doc['키']}」")
refs._no_loop(ref, [me, ref])
def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
"""저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`."""
from M01_MasterData import M01_MasterData_Store as m01
whole = m01.loaded()[1]
try:
rough = sd.clean(doc, None, whole)
_check_refs(rough, here_)
base = _base_of(rough, whole, refs.read_ref, ())
out = sd.clean(doc, base["줄"], whole)
out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"]
if out["덮개"]:
bare = run({**out, "덮개": {}}, whole) # 덮기 전 값 = 원값(그때 M01)
was = {x["cid"]: x for x in bare.get("줄") or []}
for cid, one in out["덮개"].items():
if cid in was:
one["원값"] = _was(was[cid])
out["바탕판"] = fingerprint(base["줄"])
except sd.DocError as e:
raise _fail(e) from e
return out
def _was(line: dict) -> dict[str, Any]:
keep = {"qty": line.get("율", line.get("수량")), "단가": line.get("단가")}
return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None}
def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]:
"""셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422."""
from M01_MasterData import M01_MasterData_Store as m01
whole = m01.loaded()[1]
try:
clean = sd.clean(doc, None, whole)
_check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422
me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]})
got = run(clean, whole, given, trail=(me,))
except sd.DocError as e:
raise _fail(e) from e
return labor._json({**got, "머리": head_of(clean)})
def from_line(line: Any, name: str, unit: str) -> dict[str, Any]:
"""[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK ·
답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜)."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Patch as patch
if not isinstance(line, dict):
raise store.StoreError(400, "줄이 묶음 아님")
doc = patch.to_sangeun(line, name, unit, m01.loaded()[1])
made = refs.create(sd.KIND, doc)
at = refs.here()
keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line}
return {
"이름": made["이름"],
"줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep},
}