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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user