- 바탕 없는 SK = 더한줄 로직 입력을 모아(같은 이름 하나) 공통 옵션 칸으로 보임 · 값 자리 = 시험 입력 그대로 · 바탕 있으면 지금 그대로 - 입력 바꾸면 SK 셈 바로(바탕 없음 = 시험 계산 단추 없음) · 셈 때 SK 줄 입력 아닌 이름은 거름(공통 옵션 지역이 SK 줄에서 「모르는 입력」으로 멈춤) - 공용 상세 줄 금액 칸 = 까닭 있는 줄만 「—」 + 풍선(M01 줄은 까닭 없음 = 전후 같음) - 시험 test_725_1 에 바탕 없음 지역 서울 5,297.37 · 화면 짜임 단언 더함 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
329 lines
15 KiB
Python
329 lines
15 KiB
Python
"""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},
|
||
}
|