feat(715-2): M02 단가산출 서고 종류 sangeun(SK-) · 참조 · 순환 422 · 지우기 막음 · 층 복사 + SK 셈 길(715-1b 몫)

- M02_Labor_Ref — PREFIX · TITLE · 키 SK · kind_of 셋째 갈래 · refs_in(담은로직 · 바탕 · 더한줄) 로 users · _no_loop · carry · frozen 이 SK 참조도 훑음 · clean_doc SK 갈래
- Expand 를 새 M02_Labor_Expand.py 로 뗌(refs.Expand 이름 그대로) · SK 갈래 = 1 단위 값
- 새 M02_Sangeun_Calc.py — 바탕 사슬 → 고친 줄 → M01 색인 덧씌움 + run_line(엔진 한 벌) · 저장 때 원값 · 바탕판 서버가 셈
- 새 M02_Sangeun_Router.py POST /api/m02/layers/{층}/sangeun/calc(저장 안 함 · 층 읽기 권한) · main.py 등록 한 줄
- Template_Layers LIBRARY_KINDS += sangeun · 뼈대 = 더한줄 · Labor_Router 서고 새로 길 Literal 한 줄
- 낡은 시험 test_m02_labor_ref 「LIBRARY_KINDS == (precombo,)」 → 새 종류 반영(점검 29)
- 새 시험 test_715_sangeun_store.py(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:58:36 +09:00
co-authored by Claude Opus 5.5
parent 2939bff074
commit 0b50669d4e
9 changed files with 704 additions and 176 deletions
+62 -173
View File
@@ -10,7 +10,9 @@
승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) ·
자재 수량은 손 · 빈칸 = 안 셈(175) ·
값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
sangeun `{층}/sangeun/SK-######.json` 단가산출(715-2) — 꼴 `M02_Sangeun_Doc` · 셈
`M02_Sangeun_Calc` · 참조 자리 = `바탕.참조` · `더한줄[].참조`(`refs_in`)
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC · SK) ·
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는
@@ -22,19 +24,20 @@ from __future__ import annotations
import json
import re
from collections.abc import Callable
from decimal import Decimal
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
PREFIX = {"precombo": "SC"}
TITLE = {"precombo": "사전조합"}
if TYPE_CHECKING:
from M02_MasterTemplete.M02_Labor_Expand import Expand
PREFIX = {"precombo": "SC", "sangeun": "SK"}
TITLE = {"precombo": "사전조합", "sangeun": "단가산출"}
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
REF_HEAD = "참조|"
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
_KEY = re.compile(r"^(SP|SC|SK)-(\d{6})$")
_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
Ref = dict[str, Any]
@@ -46,7 +49,19 @@ def _bad(text: str, status: int = 400) -> store.StoreError:
def kind_of(key: str) -> str:
return "promote" if str(key).startswith("SP") else "precombo"
head = str(key)[:2]
return {"SP": "promote", "SK": "sangeun"}.get(head, "precombo")
def refs_in(doc: Any) -> list[Ref]:
"""문서가 가리키는 참조 — 담은로직 줄 · 단가산출 바탕 · 더한줄(715-2)."""
if not isinstance(doc, dict):
return []
base = doc.get("바탕")
lines = [*(doc.get("담은로직") or []), *(doc.get("더한줄") or []), base]
return [
one["참조"] for one in lines if isinstance(one, dict) and isinstance(one.get("참조"), dict)
]
def ref_id(ref: Ref) -> str:
@@ -118,7 +133,7 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref:
elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")):
raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림")
if not _KEY.match(key):
raise _bad(f"{at} · 참조 키는 SP-###### · SC-######")
raise _bad(f"{at} · 참조 키는 SP · SC · SK-######")
ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key}
if not readable(here_, ref):
raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})")
@@ -141,6 +156,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
key = str(doc.get("키") or "")
check_key(kind, key)
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
if kind == "sangeun":
from M02_MasterTemplete import M02_Sangeun_Calc as calc
return calc.clean_doc(doc, here())
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
if not name:
raise _bad("사전조합 이름이 빔")
@@ -240,9 +259,8 @@ def _no_loop(ref: Ref, trail: list[Ref]) -> None:
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
if len(trail) > DEPTH:
raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422)
for one in (read_ref(ref) or {}).get("담은로직") or []:
child = one.get("참조") if isinstance(one, dict) else None
if not isinstance(child, dict) or not child.get("키"):
for child in refs_in(read_ref(ref)):
if not child.get("키"):
continue
if ref_id(child) == ref_id(trail[0]):
raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422)
@@ -287,6 +305,7 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) ->
def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
"""층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀."""
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete.M02_Labor_Expand import Expand
expand = Expand(m01.loaded()[1])
for row in rows:
@@ -296,21 +315,16 @@ def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
def users(ref: Ref) -> list[str]:
"""`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌)."""
"""`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 ·
프로젝트 뺌)."""
rid, out = ref_id(ref), []
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")):
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")):
for at, path in layers.layer_files(kind):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
lines = doc.get("담은로직") if isinstance(doc, dict) else None
if any(
isinstance(one, dict)
and isinstance(one.get("참조"), dict)
and ref_id(one["참조"]) == rid
for one in lines or []
):
if any(ref_id(one) == rid for one in refs_in(doc)):
name = doc.get("일위대가번") or doc.get("이름") or path.stem
out.append(f"{what} {at} 「{name}」")
return out
@@ -350,12 +364,23 @@ def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
got = read_ref(ref)
if got is None:
raise _bad(f"없는 참조 「{ref['키']}」")
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1)}
body = _move_doc(got, move, depth + 1)
made = create(kind_of(ref["키"]), origin_mark(_spec_moved(body, done), ref))
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
return done[ref_id(ref)]
return _spec_moved({**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}, done)
return _spec_moved(_move_doc(doc, move, 1), done)
def _move_doc(doc: dict[str, Any], move: Callable[[Ref, int], Ref], depth: int) -> dict[str, Any]:
"""문서의 참조 자리를 모두 `move` — 단가산출(715-2) = 바탕 · 더한줄 · 그 밖 = 담은로직."""
if doc.get("종류") != TITLE["sangeun"]:
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, depth)}
base = doc.get("바탕")
out = {**doc, "더한줄": _moved(doc.get("더한줄"), move, depth)}
if isinstance(base, dict) and isinstance(base.get("참조"), dict):
out["바탕"] = {"참조": move(base["참조"], depth)}
return out
def _spec_moved(doc: dict[str, Any], done: dict[str, Ref]) -> dict[str, Any]:
@@ -377,150 +402,6 @@ def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]
]
# ── 펼침(계산) ────────────────────────────────────────────────────────
class Expand:
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None:
self.whole, self.read, self.memo = whole, read, {}
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
rid = ref_id(ref)
if rid in trail:
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
return self._stop(ref, "순환 — " + " → ".join(keys))
if len(trail) >= DEPTH:
return self._stop(ref, f"참조가 {DEPTH}단을 넘음")
if rid not in self.memo:
doc = self.read(ref)
if not isinstance(doc, dict):
return self._stop(ref, f"없는 참조 「{ref['키']}」")
if kind_of(ref["키"]) == "promote":
self.memo[rid] = self._promote(doc)
else:
self.memo[rid] = self._combo(doc, (*trail, rid))
return self.memo[rid]
@staticmethod
def _stop(ref: Ref, why: str) -> dict[str, Any]:
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
def _promote(self, doc: dict) -> dict[str, Any]:
from M02_MasterTemplete import M02_Labor_Doc as labor
line = (doc.get("담은로직") or [{}])[0]
code = str(line.get("로직") or "")
got = labor.run_line(
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
)
times = labor._number(doc.get("배")) or Decimal(1)
base = got.get("기준")
why = got.get("까닭") or (None if base else "돈 아닌 로직")
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
"로직": line.get("로직"),
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
"까닭": why,
# 원 로직 줄(줄 누름 하위 행 · 51-2) — 1 단위로 나눔(하위 합 = 기준)
"줄": [
{
**x,
"수량": Decimal(str(x["수량"])) / times,
"금액": Decimal(str(x["금액"])) / times,
"비목": {k: Decimal(str(v)) / times for k, v in (x.get("비목") or {}).items()},
}
for x in got.get("줄") or []
],
}
def _logic(self, one: dict, pill: dict | None) -> dict[str, Any]:
"""사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor
pill = pill or {}
got = labor.run_line(
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
times = labor._number(pill.get("배")) or Decimal(1)
base = got.get("기준")
return {
"이름": pill.get("이름") or one["로직"],
"단위": pill.get("단위"),
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
"까닭": got.get("까닭") or (None if base else "돈 아닌 로직"),
}
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
kept = doc.get("알약") # 프로젝트 참조사본 = 가져온 때 굳힌 알약(142-2) · 층 = 지금 M01
pills = kept if isinstance(kept, list) else None
by_logic = {
code: pill
for pill in pills or combo_store.combo_pills({"담은로직": lines})["물량"]
for code in pill["로직"]
}
inner, rows, why = [], [], None
for one in lines:
ref = one.get("참조")
if isinstance(ref, dict):
head, sub = {"참조": ref}, self(ref, trail)
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`)
head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"])
else:
continue
qty = labor._number((one.get("수량") or {}).get("손"))
qty = Decimal(1) if qty is None else qty
keep = ("이름", "단위", "기준", "까닭")
inner.append({**head, "수량": qty, **{k: sub.get(k) for k in keep}})
if sub.get("기준") is None:
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
continue
rows.append({k: v * qty for k, v in sub["기준"].items()})
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
qty = labor._number((mat.get("수량") or {}).get("손"))
if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
continue
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
price = labor._number(got["기준단가"])
zero = dict.fromkeys(labor.COSTS, Decimal(0))
base = None if price is None else {**zero, "재료비": price, "계": price}
name = mat.get("이름") or mat["코드"]
inner.append(
{
"자재": mat["코드"],
"수량": qty,
"이름": name,
"단위": mat.get("단위"),
"기준": base,
"까닭": got["까닭"],
}
)
if base is None:
why = why or f"「{name}」 {got['까닭']}"
continue
rows.append({k: v * qty for k, v in base.items()})
under = labor._sum(rows)
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
if (one.get("고정") or {}).get("비율"):
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
rows.append(row)
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
"기준": None if why else labor._sum(rows),
"까닭": why,
"안": inner,
}
def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]:
"""참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것."""
doc = read(ref) or {}
@@ -539,17 +420,16 @@ def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]:
(원본이 바뀌어도 프로젝트는 그대로)."""
out: dict[str, dict[str, Any]] = {}
def walk(lines: Any, depth: int) -> None:
for one in lines or []:
ref = one.get("참조") if isinstance(one, dict) else None
if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH:
def walk(found: list[Ref], depth: int) -> None:
for ref in found:
if ref_id(ref) in out or depth > DEPTH:
continue
got = read_ref(ref)
if got is not None:
out[ref_id(ref)] = with_pills(got)
walk(got.get("담은로직"), depth + 1)
walk(refs_in(got), depth + 1)
walk(doc.get("담은로직"), 1)
walk(refs_in(doc), 1)
return out
@@ -582,3 +462,12 @@ def frozen_logics(copies: dict[str, Any]) -> list[str]:
for line in doc.get("담은로직") or []
if isinstance(line, dict) and line.get("로직")
]
def __getattr__(name: str) -> Any:
"""`Expand` = 떼어 둔 `M02_Labor_Expand`(715-2) — 옛 이름 `refs.Expand` 그대로."""
if name == "Expand":
from M02_MasterTemplete.M02_Labor_Expand import Expand
return Expand
raise AttributeError(name)