diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py new file mode 100644 index 000000000..35a73300c --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -0,0 +1,162 @@ +"""M02 서고 참조 펼침(계산) — `M02_Labor_Ref.Expand` 를 떼어 둠(PLAN 715-2 · 700 줄). + +참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 단가승격(옛 SP) · 사전조합(SC) · 단가산출(SK · +`M02_Sangeun_Calc.unit`). 이름은 `M02_Labor_Ref.Expand` 로 그대로 부름. +""" + +from __future__ import annotations + +from collections.abc import Callable +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Calc as calc + +Ref = dict[str, Any] + + +class Expand: + """참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 · + 깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본).""" + + def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = refs.read_ref) -> None: + self.whole, self.read, self.memo = whole, read, {} + + def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]: + rid = refs.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) >= refs.DEPTH: + return self._stop(ref, f"참조가 {refs.DEPTH}단을 넘음") + if rid not in self.memo: + doc = self.read(ref) + if not isinstance(doc, dict): + return self._stop(ref, f"없는 참조 「{ref['키']}」") + if refs.kind_of(ref["키"]) == "promote": + self.memo[rid] = self._promote(doc) + elif refs.kind_of(ref["키"]) == "sangeun": # 단가산출(715-2) = 고친 줄 셈 1 단위 + self.memo[rid] = calc.unit(doc, self.whole, self, (*trail, rid)) + 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, + } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 34a8f3005..ae47ac4be 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -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) diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 25ed692aa..1b0b7962a 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -106,7 +106,7 @@ def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[ @router.post("/layers/{layer}/library/{kind}") def new_library( layer: Layer, - kind: Literal["precombo"], + kind: Literal["precombo", "sangeun"], body: LibraryBody, owner: str | None = None, session: Session = Depends(verify_session), diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py new file mode 100644 index 000000000..0ea7b5e10 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -0,0 +1,230 @@ +"""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 +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._PER.findall(str(row.get("결과단위") or "")) + head = per[0][0].replace(",", "") if len(per) == 1 else "" + return Decimal(head) if head 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 = ()) -> dict[str, Any]: + """SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` · + `trail` = 지나온 참조 이름표(순환 · 깊이 422).""" + base = _base_of(doc, whole, read, trail) + got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"]) + rate = doc.get("켜쌓기율", base["켜쌓기율"]) + return { + **got, + "짝잃음": base["짝잃음"] + got["짝잃음"], + "입력값": {**base["입력값"], **(doc.get("입력값") or {})}, + "켜쌓기율": rate, + "배": times_of(base["줄"]), + "바탕판": fingerprint(base["줄"]), + } + + +def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> str | None: + """더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈이면 까닭.""" + for item in 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["고정"]) + if sub.get("기준") is None: + return f"「{sub.get('이름') or item.get('이름')}」 {sub.get('까닭')}" + item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS} + item["이름"] = item.get("이름") or sub.get("이름") or "" + item["단위"] = item.get("단위") or sub.get("단위") or "" + return None + + +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) + row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} + why = _bundles(row, whole, expand or Expand(whole, read), trail) + head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]} + if why: + return {**head, "기준": None, "1단위": None, "줄": [], "까닭": why} + key = str(doc.get("키") or "SK-000000") + over = _Over(whole, key, {**row, "키": key}) + values = {**got["입력값"], **(given or {})} + out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) + covers = doc.get("덮개") or {} + lines = [] + for cid, line in 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 {} + 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 + return {**out, **head, "1단위": unit} + + +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)} + why = got.get("까닭") 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, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} + + +# ── 저장 ────────────────────────────────────────────────────────────── +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) diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py new file mode 100644 index 000000000..f9c04e50d --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -0,0 +1,387 @@ +"""M02 단가산출(SK) 문서 — 꼴 · 검사 · 줄 정체 · 고친 줄(PLAN 715-1a · 요구서 내역12 4장 12 · 13). + +문서 `{층}/sangeun/SK-######.json` = M01 로직에 연결 + 바꾼 것만 — + {종류: "단가산출", 키, 이름, 단위(필수), 구분, 상세구분, 본뜬, + 바탕: {로직: GF…} | {참조: {층, 주인, 키: SK-…}} | null(바탕 없음 = 더한줄만), + 입력값 {이름: 값}, 열린입력 [이름](설계 때 정함 · 그 SK 품 덮개 = 배율만), 켜쌓기율?, + 고름 {cid: MT키}, 자재표시 {cid: {세 칸}}, + 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, + 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?} + | {종류: 로직, 로직, 입력값, 수량} | {참조 | 고정, 이름?, 단위?, 수량}], + 뺀줄 [cid], 바탕판} +줄 정체 cid = `kind:키#n`(B09 702 덮개 꼴) — kind = labor · material · machine · logic · pct(덧줄) · +키 = 요소 키(로직 줄 = 부르는 GC 키 · 조건 재료 = 이름 · 덧줄 = 이름 · 더한줄 참조 = 그 키) · +n = 같은 머리 차례 · 바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄)에서 셈 — 뺀줄 · 더한줄 뒤 같은 줄. +고름 · 자재표시도 cid 열쇠(호표 차례 열쇠면 뺀줄 뒤 엉뚱한 줄에 붙음) → `patched` 가 차례로 바꿈. +덮개 = 숫자만(식 안 받음 · 4-1 확정 4) — qty = 품 수 · 배율 = 품 × 율 · labor · material · expense = +줄 단가(로직 줄 = 노 · 재 · 경 묶음) · 덧줄 = qty(율 %) · 배율만 · `원값` · `바탕판` = 저장 때 +서버가 셈(브라우저 값 안 받음). 짝 잃은 덮개(M01 개정으로 줄이 사라짐) = 지우지 않고 `짝잃음` 알림. +엔진 입력 덧칸(715-1b · `master_pick`) = 줄 `단가`(수) · 로직 줄 `값묶음{노무비 · 재료비 · 경비}` +""" + +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation +from typing import Any + +KIND = "sangeun" +TITLE = "단가산출" +PREFIX = "SK" +KEY = re.compile(r"^SK-\d{6}$") +PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"} +COVER_KEYS = ("qty", "배율", *PARTS) +KINDS = {"인력": "labor", "재료": "material", "기계": "machine", "로직": "logic"} +ADD_PART = {"인력": "노무비", "재료": "재료비", "기계": "경비"} +_CID = re.compile(r"^(labor|material|machine|logic|pct):.*#\d+$") +_CALL = re.compile(r"^\s*로직\(\s*([A-Z]{2}\d{6})") +_PCT = re.compile(r"^(?P.+)\*(?P[^*]+)/\s*100\s*$", re.S) # 「X * Y / 100」 +_NAME = re.compile(r"^[\w가-힣]+$") + + +class DocError(ValueError): + def __init__(self, text: str, status: int = 400) -> None: + super().__init__(text) + self.status = status + + +def _num(raw: Any, where: str) -> Decimal: + try: + got = Decimal(str(raw).replace(",", "").strip()) + except (InvalidOperation, TypeError): + got = None + if raw is None or isinstance(raw, bool) or got is None or not got.is_finite(): + raise DocError(f"{where} · 수가 아님 「{raw}」") + return got + + +def plain(value: Decimal) -> int | float: + return int(value) if value == value.to_integral_value() else float(value) + + +def text(value: Any) -> str: + """식 안에 넣을 수 글 — 지수 꼴 없이.""" + got = value if isinstance(value, Decimal) else Decimal(str(value)) + return format(got.normalize(), "f") + + +# ── 줄 정체 ─────────────────────────────────────────────────────────── +def _head(item: dict, extra: bool) -> str: + if extra: + return f"pct:{item.get('이름') or ''}" + kind = KINDS.get(str(item.get("종류")), "logic") + elem = item.get("요소") + if isinstance(item.get("참조"), dict): + key = str(item["참조"].get("키") or "") + elif isinstance(item.get("고정"), dict): + key = str(item["고정"].get("이름") or "") + elif kind == "logic": + found = _CALL.match(str(elem or "")) + key = found.group(1) if found else str(item.get("이름") or "") + else: + key = elem if isinstance(elem, str) else str(item.get("이름") or "") + return f"{kind}:{key}" + + +def line_ids(row: dict) -> list[str]: + """로직 줄(호표 → 덧줄 차례)마다 cid — 같은 머리 n번째 = `#n`.""" + seen: dict[str, int] = {} + out = [] + pairs = [(x, False) for x in row.get("호표") or []] + [(x, True) for x in row.get("덧줄") or []] + for item, extra in pairs: + head = _head(item, extra) + seen[head] = seen.get(head, -1) + 1 + out.append(f"{head}#{seen[head]}") + return out + + +# ── 정리 · 검사 ─────────────────────────────────────────────────────── +def _cid(raw: Any, where: str) -> str: + if not isinstance(raw, str) or not _CID.match(raw): + raise DocError(f"{where} · 줄 정체는 kind:키#n 「{raw}」") + return raw + + +def _base(raw: Any) -> dict | None: + if raw is None: + return None + if isinstance(raw, dict) and isinstance(raw.get("로직"), str) and raw["로직"].strip(): + return {"로직": raw["로직"].strip()} + if isinstance(raw, dict) and isinstance(raw.get("참조"), dict): + ref = raw["참조"] + if not KEY.match(str(ref.get("키") or "")): + raise DocError("바탕 참조 키는 SK-######") + return {"참조": {"층": ref.get("층"), "주인": ref.get("주인"), "키": str(ref["키"])}} + raise DocError("바탕은 {로직} · {참조} · null") + + +def _given(raw: Any, where: str) -> dict: + if raw is None: + return {} + if not isinstance(raw, dict): + raise DocError(f"{where} · 입력값이 묶음 아님") + return {str(k): v for k, v in raw.items() if v not in (None, "")} + + +def _cover(raw: Any, where: str, opened: bool) -> dict: + if not isinstance(raw, dict): + raise DocError(f"{where} · 덮개는 묶음") + unknown = set(raw) - {*COVER_KEYS, "원값"} + if unknown: + raise DocError(f"{where} · 모르는 덮개 칸 {sorted(unknown)}") + out = {k: plain(_num(raw[k], f"{where} {k}")) for k in COVER_KEYS if raw.get(k) is not None} + if "qty" in out and "배율" in out: + raise DocError(f"{where} · 품은 qty · 배율 중 하나") + if opened and "qty" in out: + raise DocError(f"{where} · 옵션을 열어 둔 단가산출은 품 배율만") + if any(v < 0 for v in out.values()): + raise DocError(f"{where} · 음수 덮개") + if not out: + raise DocError(f"{where} · 빈 덮개") + return out + + +def _added(raw: Any, where: str, whole: Any) -> dict: + """더한줄 하나 — 수량은 숫자(식 안 받음).""" + if not isinstance(raw, dict): + raise DocError(f"{where} · 묶음 아님") + qty = plain(_num(raw.get("수량"), f"{where} 수량")) + name, unit = str(raw.get("이름") or "").strip(), str(raw.get("단위") or "").strip() + if isinstance(raw.get("참조"), dict): + ref = raw["참조"] + if not re.match(r"^(SK|SC)-\d{6}$", str(ref.get("키") or "")): + raise DocError(f"{where} · 참조 키는 SK · SC") + head: dict[str, Any] = {"참조": {k: ref.get(k) for k in ("층", "주인", "키")}} + elif isinstance(raw.get("고정"), dict): + from M02_MasterTemplete import M02_Labor_Fixed as fixed + + try: + head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]} + except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400) + raise DocError(str(getattr(e, "detail", e))) from e + if head["고정"].get("비율"): + raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로") + elif raw.get("종류") == "로직": + code = str(raw.get("로직") or "") + row = _logic(whole, code, where) + names = {str(s.get("이름")) for s in (row or {}).get("입력") or []} + given = _given(raw.get("입력값"), where) + head = {"종류": "로직", "로직": code, "입력값": given} + if row is not None: + head["입력값"] = {k: v for k, v in given.items() if k in names} + name = name or str(row.get("이름") or code) + elif raw.get("종류") in ADD_PART: + key = str(raw.get("요소") or "") + got = _element(whole, key, where) + part = str(raw.get("비목") or ADD_PART[raw["종류"]]) + if part not in PARTS.values(): + raise DocError(f"{where} · 비목은 노무비 · 재료비 · 경비") + head = {"종류": raw["종류"], "요소": key, "비목": part} + if raw.get("단가") is not None: + head["단가"] = plain(_num(raw["단가"], f"{where} 단가")) + name = name or str((got or {}).get("이름") or key) + unit = unit or str((got or {}).get("단위") or "") + else: + raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정") + return {**head, "이름": name, "단위": unit, "수량": qty} + + +def _logic(whole: Any, code: str, where: str) -> dict | None: + if whole is None: + return None + try: + return whole.logic(code) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError(가져오기 둘레 피함) + raise DocError(f"{where} · {e}") from e + + +def _element(whole: Any, key: str, where: str) -> dict | None: + if not key: + raise DocError(f"{where} · 요소 키가 빔") + if whole is None: + return None + try: + return whole.get(key) + except Exception as e: # noqa: BLE001 + raise DocError(f"{where} · 없는 요소 「{key}」") from e + + +def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str, Any]: + """서버가 받는 꼴로 — 틀리면 `DocError`(400). `base_row` = 패치를 얹을 바탕 줄(입력 이름 · + 열린입력 검사) · `whole` = M01(요소 · 로직 · 자재 키 검사). 짝 없는 cid 는 받음(셈 때 알림) · + `원값` · `바탕판` 은 서버 몫이라 버림.""" + if not isinstance(doc, dict): + raise DocError("단가산출 문서가 묶음 아님") + key = str(doc.get("키") or "") + if not KEY.match(key): + raise DocError("단가산출 키는 SK-######") + name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() + if not name: + raise DocError("단가산출 이름이 빔") + if not unit: + raise DocError("단가산출 단위가 빔 — 1 단위 값의 단위") + base = _base(doc.get("바탕")) + if base and "로직" in base and whole is not None and base_row is None: + base_row = _logic(whole, base["로직"], "바탕") + names = {str(s.get("이름")) for s in (base_row or {}).get("입력") or []} + given = _given(doc.get("입력값"), "입력값") + opened = [str(x) for x in doc.get("열린입력") or []] + if base_row is not None: + given = {k: v for k, v in given.items() if k in names} + bad = [x for x in opened if x not in names or x in given] + if bad: + raise DocError(f"열린입력은 바탕 입력 중 값 안 넣은 것만 {bad}") + out: dict[str, Any] = {"종류": TITLE, "키": key, "이름": name, "단위": unit, "바탕": base} + out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) + out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None + out["입력값"], out["열린입력"] = given, opened + if doc.get("켜쌓기율") is not None: + out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율")) + picks = {} + for cid, mt in (doc.get("고름") or {}).items(): + mt = str(mt or "") + if not mt.startswith("MT") or (whole is not None and _element(whole, mt, "고름") is None): + raise DocError(f"고름 「{cid}」 · M01 자재품목 키 아님 「{mt}」") + picks[_cid(cid, "고름")] = mt + out["고름"] = picks + out["자재표시"] = _marks(doc.get("자재표시")) + out["덮개"] = { + _cid(cid, "덮개"): _cover(one, f"덮개 「{cid}」", bool(opened)) + for cid, one in (doc.get("덮개") or {}).items() + } + out["더한줄"] = [ + _added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1) + ] + drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []] + if len(set(drop)) != len(drop): + raise DocError("뺀줄에 같은 줄 두 번") + out["뺀줄"] = drop + return out + + +def _marks(raw: Any) -> dict[str, dict[str, str]]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + out = {} + for cid, one in (raw or {}).items() if isinstance(raw, dict) else (): + got = labor.marks(one) + if got: + out[_cid(cid, "자재표시")] = got + return out + + +# ── 고친 줄 ─────────────────────────────────────────────────────────── +def _covered(item: dict, cover: dict, extra: bool) -> tuple[dict, str | None]: + """덮개 한 줄을 로직 줄에 — (고친 줄, 못 덮은 까닭).""" + item = dict(item) + if extra: # 덧줄 = 율(qty) · 배율만 + if any(k in cover for k in PARTS): + return item, "덧줄은 율만 고침" + formula = str(item.get("식") or "") + if "배율" in cover: + item["식"] = f"({formula}) * {text(cover['배율'])}" + return item, None + found = _PCT.match(formula) + if not found: + return item, "덧줄 식이 「X * 율 / 100」 꼴 아님" + item["식"] = f"{found.group('x').strip()} * {text(cover['qty'])} / 100" + return item, None + if "qty" in cover: + item["수량"] = text(cover["qty"]) + elif "배율" in cover: + item["수량"] = f"({item.get('수량')}) * {text(cover['배율'])}" + prices = {PARTS[k]: Decimal(str(cover[k])) for k in PARTS if k in cover} + if not prices: + return item, None + if item.get("종류") == "로직": + item["값묶음"] = {**dict(item.get("값묶음") or {}), **prices} + return item, None + part = str(item.get("비목") or "") + if set(prices) != {part}: + return item, f"비목 다름 — 이 줄은 {part or '비목 없음'}" + item["단가"] = prices[part] + return item, None + + +def _add_item(one: dict) -> dict: + """더한줄 → 엔진 호표 줄 — 참조 · 고정 줄 `값묶음` 은 셈(`M02_Sangeun_Calc`)이 채움.""" + head = {"이름": one.get("이름") or "", "단위": one.get("단위") or ""} + qty = text(one["수량"]) + if "참조" in one or "고정" in one: + kept = {k: one[k] for k in ("참조", "고정") if k in one} + return {**head, "종류": "로직", "요소": None, "수량": qty, **kept, "값묶음": None} + if one.get("종류") == "로직": + args = "".join( + f", {k if _NAME.match(k) else repr(k)}=" + + (text(v) if isinstance(v, (int, float, Decimal)) else f"'{v}'") + for k, v in (one.get("입력값") or {}).items() + ) + return {**head, "종류": "로직", "요소": f"로직({one['로직']}{args})", "수량": qty} + item = {**head, "종류": one["종류"], "요소": one["요소"], "수량": qty, "비목": one["비목"]} + return {**item, **({"단가": Decimal(str(one["단가"]))} if "단가" in one else {})} + + +def patched( + row: dict, doc: dict, picks: dict | None = None, shown: dict | None = None +) -> dict[str, Any]: + """바탕 줄(M01 로직 줄 · 바탕 SK 의 고친 줄) + SK 패치 → 고친 줄(순수 · 엔진 안 부름). + `picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김. + 돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`], + 짝잃음 [{cid, 까닭}]}.""" + items, extras = list(row.get("호표") or []), list(row.get("덧줄") or []) + ids = line_ids(row) + covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or []) + mine = doc.get("고름") or {} + marks = doc.get("자재표시") or {} + lost = [ + {"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids + ] + new_items, new_extras, cids, out_picks, out_marks = [], [], [], {}, {} + for at, cid in enumerate(ids): + if cid in drop: + continue + extra = at >= len(items) + item = extras[at - len(items)] if extra else items[at] + if cid in covers: + item, why = _covered(item, covers[cid], extra) + if why: + lost.append({"cid": cid, "까닭": why}) + if extra: + new_extras.append(item) + continue + n = str(len(new_items)) + old = (picks or {}).get(str(at)) + if cid in mine and item.get("종류") != "재료": + lost.append({"cid": cid, "까닭": "고름 — 재료 줄 아님"}) + elif cid in mine or old: + out_picks[n] = mine.get(cid) or old + if (shown or {}).get(str(at)) or cid in marks: + out_marks[n] = marks.get(cid) or shown[str(at)] + new_items.append(item) + cids.append(cid) + for k, one in enumerate(doc.get("더한줄") or []): + new_items.append(_add_item(one)) + cids.append(f"add:#{k}") + cids += [c for at, c in enumerate(ids) if at >= len(items) and c not in drop] + for name, keys in (("덮개", covers), ("고름", mine), ("자재표시", marks)): + lost += [{"cid": c, "까닭": f"{name} — 바탕에 없는 줄"} for c in keys if c not in ids] + return { + "줄": {**row, "호표": new_items, "덧줄": new_extras}, + "고름": out_picks, + "자재표시": out_marks, + "정체": cids, + "짝잃음": lost, + } + + +def empty_row(doc: dict) -> dict[str, Any]: + """바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈).""" + return { + "키": doc.get("키"), + "이름": doc.get("이름"), + "결과단위": f"원/{doc.get('단위') or ''}", + "입력": [], + "중간": [], + "호표": [], + "덧줄": [], + } diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py new file mode 100644 index 000000000..bbc516045 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -0,0 +1,50 @@ +"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`. + +저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. +몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). +문서 읽기 · 쓰기 · 새로 · 복사 · 지우기는 양식 길 · 서고 길 그대로(종류 `sangeun`). +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"]) + + +class SangeunCalcBody(BaseModel): + 문서: dict[str, Any] | None = None + 키: str | None = None + 입력값: dict[str, Any] | None = None + + +def _calc(body: SangeunCalcBody) -> dict[str, Any]: + doc = body.문서 + if doc is None: + if not sd.KEY.match(str(body.키 or "")): + raise store.StoreError(400, "문서 또는 키(SK-######)") + doc = store.read(sd.KIND, str(body.키))["문서"] + return calc.calc_doc(doc, body.입력값) + + +@router.post("/layers/{layer}/sangeun/calc") +def calc_sangeun( + layer: Layer, + body: SangeunCalcBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """단가산출 셈 — 줄(cid · 덮개 · 원값) · 기준 · 1단위 · 짝잃음(저장 안 함).""" + if body.문서 is None and body.키 is None: + raise HTTPException(status_code=400, detail="문서 또는 키") + return _call(_root(session, layer, owner), _calc, body) diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 96cc87707..48c858d5f 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -29,7 +29,8 @@ LAYERS = ("system", "company", "personal", "project") # area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) # labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) # precombo = 사전조합(SC-) — 일위대가 사전조합 서고(`M02_Labor_Ref` · 옛 단가승격 promote 는 134-2 에서 뺌) -LIBRARY_KINDS = ("precombo",) +# sangeun = 단가산출(SK- · 715-2 · 꼴 `M02_Sangeun_Doc`) +LIBRARY_KINDS = ("precombo", "sangeun") KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS) TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" @@ -174,6 +175,7 @@ _SKELETON = { "labor": ("담은로직",), "area": ("시트",), "precombo": ("담은로직",), + "sangeun": ("더한줄",), } diff --git a/main.py b/main.py index 0209fd0a9..d375c0544 100644 --- a/main.py +++ b/main.py @@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서 +app.include_router( + __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, + dependencies=protected, +) # 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router, diff --git a/resources/tester/test_715_sangeun_doc.py b/resources/tester/test_715_sangeun_doc.py new file mode 100644 index 000000000..dc66e4514 --- /dev/null +++ b/resources/tester/test_715_sangeun_doc.py @@ -0,0 +1,179 @@ +"""M02 단가산출(SK) 문서 꼴 · 줄 정체 · 고친 줄(PLAN 715-1a · `M02_Sangeun_Doc`) — 엔진 안 부름.""" + +from decimal import Decimal + +import pytest + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +ROW = { + "키": "GX000001", + "결과단위": "원/㎡", + "입력": [{"이름": "높이"}, {"이름": "돌", "고르기": ["깬잡석", "견치돌"]}], + "호표": [ + { + "종류": "인력", + "요소": "LB000033", + "이름": "석공", + "수량": "높이 * 0.1", + "비목": "노무비", + }, + {"종류": "재료", "요소": {"검색어": "모래"}, "이름": "모래", "수량": "1", "비목": "재료비"}, + {"종류": "인력", "요소": "LB000033", "이름": "석공", "수량": "0.2", "비목": "노무비"}, + {"종류": "재료", "요소": {"검색어": "자갈"}, "이름": "자갈", "수량": "2", "비목": "재료비"}, + { + "종류": "로직", + "요소": "로직(GC000276, 기계='5401-0017')", + "이름": "굴착기", + "수량": "0.5", + }, + ], + "덧줄": [{"이름": "공구손료", "식": "(석공 * LB000033) * 3 / 100", "비목": "경비"}], +} + + +def _doc(**extra): + return { + "키": "SK-000001", + "이름": "돌쌓기 내 값", + "단위": "㎡", + "바탕": {"로직": "GX000001"}, + **extra, + } + + +def test_line_ids_kind_key_n(): + assert sd.line_ids(ROW) == [ + "labor:LB000033#0", + "material:모래#0", + "labor:LB000033#1", + "material:자갈#0", + "logic:GC000276#0", + "pct:공구손료#0", + ] + + +def test_clean_shape_and_refusals(): + got = sd.clean(_doc(입력값={"높이": 3, "모름": 1, "돌": ""}, 원값=1), ROW) + assert got["입력값"] == {"높이": 3} and got["종류"] == "단가산출" and got["뺀줄"] == [] + for bad, why in ( + ({"키": "SC-000001"}, "SK-"), + ({"이름": " "}, "이름"), + ({"단위": ""}, "단위"), + ({"바탕": {"참조": {"키": "SC-000001"}}}, "SK-"), + ({"덮개": {"labor:LB000033#0": {"qty": "식"}}}, "수가 아님"), + ({"덮개": {"labor:LB000033#0": {"qty": 1, "배율": 2}}}, "하나"), + ({"덮개": {"labor:LB#x": {"qty": 1}}}, "kind:키#n"), + ({"뺀줄": ["material:모래#0", "material:모래#0"]}, "두 번"), + ({"열린입력": ["높이"], "입력값": {"높이": 3}}, "열린입력"), + ({"더한줄": [{"종류": "인력", "요소": "LB000002"}]}, "수량"), + ({"더한줄": [{"종류": "식", "수량": 1}]}, "종류"), + ): + with pytest.raises(sd.DocError, match=why): + sd.clean(_doc(**bad), ROW) + # 원값 · 바탕판 = 서버 몫 — 브라우저 값 버림 + got = sd.clean(_doc(덮개={"labor:LB000033#0": {"qty": 0.5, "원값": 9}}), ROW) + assert got["덮개"] == {"labor:LB000033#0": {"qty": 0.5}} + + +def test_open_inputs_only_ratio(): + with pytest.raises(sd.DocError, match="배율만"): + sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"qty": 1}}), ROW) + got = sd.clean(_doc(열린입력=["돌"], 덮개={"labor:LB000033#0": {"배율": 1.1}}), ROW) + assert got["덮개"]["labor:LB000033#0"] == {"배율": 1.1} + + +def test_patched_cover_qty_price_ratio(): + doc = sd.clean( + _doc( + 덮개={ + "labor:LB000033#1": {"qty": 0.25, "labor": 200000}, + "labor:LB000033#0": {"배율": 1.5}, + "logic:GC000276#0": {"expense": 1000}, + "pct:공구손료#0": {"qty": 5}, + } + ), + ROW, + ) + got = sd.patched(ROW, doc) + rows = got["줄"]["호표"] + assert rows[0]["수량"] == "(높이 * 0.1) * 1.5" + assert rows[2]["수량"] == "0.25" and rows[2]["단가"] == Decimal(200000) + assert rows[4]["값묶음"] == {"경비": Decimal(1000)} + assert got["줄"]["덧줄"][0]["식"] == "(석공 * LB000033) * 5 / 100" + assert got["짝잃음"] == [] and ROW["호표"][2]["수량"] == "0.2" # 바탕은 안 바뀜 + + +def test_drop_keeps_pick_on_its_line(): + """뺀줄 뒤 고름 제자리 — 자갈(바탕 3째) 고름이 뺀줄 뒤 새 2째로 옮겨 감 · 모래 줄에 안 붙음.""" + doc = sd.clean(_doc(뺀줄=["material:모래#0"], 고름={"material:자갈#0": "MT000010"}), ROW) + got = sd.patched(ROW, doc) + names = [x["이름"] for x in got["줄"]["호표"]] + assert names == ["석공", "석공", "자갈", "굴착기"] + assert got["고름"] == {"2": "MT000010"} + assert got["정체"][:4] == [ + "labor:LB000033#0", + "labor:LB000033#1", + "material:자갈#0", + "logic:GC000276#0", + ] + + +def test_added_lines_before_extras(): + doc = sd.clean( + _doc( + 더한줄=[ + {"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": 0.1}, + { + "종류": "로직", + "로직": "GC000001", + "입력값": {"규격": "가", "두께": 2}, + "수량": 1, + }, + {"참조": {"층": "system", "주인": None, "키": "SK-000002"}, "수량": 2}, + ] + ), + ROW, + ) + got = sd.patched(ROW, doc) + added = got["줄"]["호표"][5:] + assert added[0]["비목"] == "노무비" and added[0]["수량"] == "0.1" + assert added[1]["요소"] == "로직(GC000001, 규격='가', 두께=2)" + assert added[2]["참조"]["키"] == "SK-000002" and added[2]["값묶음"] is None + assert got["정체"][5:] == ["add:#0", "add:#1", "add:#2", "pct:공구손료#0"] + + +def test_lost_covers_kept_and_listed(): + """짝 잃은 덮개 = 문서에 남김 + 목록(지우지 않음) · 비목 다른 단가 · 재료 아닌 고름.""" + doc = sd.clean( + _doc( + 덮개={"labor:LB999999#0": {"qty": 1}, "labor:LB000033#0": {"material": 5}}, + 고름={"labor:LB000033#1": "MT000010"}, + 뺀줄=["material:없음#0"], + ), + ROW, + ) + got = sd.patched(ROW, doc) + why = {x["cid"]: x["까닭"] for x in got["짝잃음"]} + assert why["labor:LB999999#0"].startswith("덮개") and "비목 다름" in why["labor:LB000033#0"] + assert why["labor:LB000033#1"].startswith("고름") and why["material:없음#0"].startswith("뺀줄") + assert "labor:LB999999#0" in doc["덮개"] + + +def test_chain_moves_base_picks(): + """바탕 SK 사슬 — 바탕 쪽 차례 고름이 아래 SK 뺀줄 뒤에도 그 줄에.""" + first = sd.patched(ROW, sd.clean(_doc(고름={"material:자갈#0": "MT000010"}), ROW)) + child = sd.clean({**_doc(뺀줄=["labor:LB000033#0"]), "바탕": None}, first["줄"]) + got = sd.patched(first["줄"], child, first["고름"]) + assert got["고름"] == {"2": "MT000010"} and got["줄"]["호표"][2]["이름"] == "자갈" + + +def test_empty_base(): + row = sd.empty_row({"키": "SK-000003", "이름": "자체", "단위": "식"}) + doc = sd.clean( + {"키": "SK-000003", "이름": "자체", "단위": "식", "바탕": None, + "더한줄": [{"종류": "재료", "요소": "MT000010", "수량": 3, "단가": 1200}]}, + row, + ) # fmt: skip + got = sd.patched(row, doc) + assert got["줄"]["호표"][0]["단가"] == Decimal(1200) and got["줄"]["결과단위"] == "원/식" diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py new file mode 100644 index 000000000..753028f25 --- /dev/null +++ b/resources/tester/test_715_sangeun_store.py @@ -0,0 +1,191 @@ +"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. + +고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · +순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘(검색어) + + +def _sk(folder, **doc): + with store.at(folder): + return refs.create("sangeun", {"이름": "내 단가산출", "단위": "㎡", "더한줄": [], **doc})[ + "이름" + ] + + +def _doc(folder, key): + with store.at(folder): + return store.read("sangeun", key)["문서"] + + +def _calc(folder, key, given=None): + with store.at(folder): + return calc.calc_doc(_doc(folder, key), given) + + +def _whole(): + return m01.loaded()[1] + + +def _bare(code): + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor.run_line(_whole(), code, {}) + + +def test_qty_cover_hand_and_pct_recomputed(world): + """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값.""" + bare = _bare(PCT) + first, second = bare["줄"][0], bare["줄"][1] + key = _sk( + world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5, "원값": 9}} + ) + doc = _doc(world["personal"], key) + assert doc["덮개"]["labor:LB000036#0"]["원값"]["qty"] == pytest.approx(float(first["수량"])) + assert len(doc["바탕판"]) == 16 + got = _calc(world["personal"], key) + labor_sum = Decimal("0.5") * Decimal(str(first["단가"])) + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(labor_sum)) + assert got["기준"]["경비"] == pytest.approx(float(labor_sum * 3 / 100), abs=1) + assert got["줄"][0]["덮개"] == ["qty"] and got["줄"][0]["cid"] == "labor:LB000036#0" + assert got["짝잃음"] == [] + # 덧줄 율 덮개 = 5 % + key = _sk( + world["personal"], + 바탕={"로직": PCT}, + 덮개={"pct:" + bare["줄"][2]["이름"] + "#0": {"qty": 5}}, + ) + got = _calc(world["personal"], key) + assert got["기준"]["경비"] == pytest.approx(float(bare["기준"]["노무비"]) * 0.05, abs=1) + + +def test_drop_then_pick_stays_on_its_line(world): + """뺀줄 뒤 고름 제자리 — 둘째 재료 줄을 고른 자재가 첫 재료 줄을 뺀 뒤에도 그 줄 품목.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + picks = mp.search(whole, item["요소"], {}, item.get("단위")) + want = picks[1] if len(picks) > 1 else picks[0] + first_cid = "material:" + row["호표"][1]["이름"] + "#0" + second_cid = "material:" + item["이름"] + "#0" + key = _sk( + world["personal"], + 단위="m", + 바탕={"로직": TWO_MATS}, + 뺀줄=[first_cid], + 고름={second_cid: want}, + 자재표시={second_cid: {"별도계상": "직접계산"}}, # 기본 = 별도계상(222-1) 줄 빠짐 + ) + got = _calc(world["personal"], key) + names = [x["이름"] for x in got["줄"]] + assert row["호표"][1]["이름"] not in names + line = next(x for x in got["줄"] if x["cid"] == second_cid) + assert line["품목"]["키"] == want + + +def test_lost_cover_kept_and_listed(world): + key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB999999#0": {"qty": 1}}) + got = _calc(world["personal"], key) + assert got["짝잃음"][0]["cid"] == "labor:LB999999#0" + assert "labor:LB999999#0" in _doc(world["personal"], key)["덮개"] + assert got["기준"]["계"] == pytest.approx(float(_bare(PCT)["기준"]["계"])) + + +def test_chain_loop_422_and_delete_409(world): + """바탕 사슬 A ← B · B 를 A 의 바탕으로 = 422 · 쓰는 곳 있는 A 지우기 = 409(이름).""" + a = _sk(world["personal"], 바탕={"로직": PCT}) + b = _sk( + world["personal"], 바탕={"참조": {**MINE, "키": a}}, 덮개={"labor:LB000002#0": {"qty": 0}} + ) + got = _calc(world["personal"], b) + bare = _bare(PCT) + assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"])) + with store.at(world["personal"]): + doc = store.read("sangeun", a) + with pytest.raises(store.StoreError) as e: + store.write( + "sangeun", a, doc["판"], {**doc["문서"], "바탕": {"참조": {**MINE, "키": b}}} + ) + assert e.value.status == 422 + with pytest.raises(store.StoreError) as e: + store.delete("sangeun", a) + assert e.value.status == 409 and "단가산출 개인 7/42" in str(e.value.detail) + + +def test_precombo_holds_sangeun(world): + """사전조합 참조 줄이 SK 를 담음 — 1 단위 값 × 수량 · SK 지우기 막음.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + sk = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]}, + )["이름"] + got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) + with pytest.raises(store.StoreError) as e: + store.delete("sangeun", sk) + one = _calc(world["personal"], sk)["1단위"]["계"] + assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) + assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail) + + +def test_layer_copy_round_trip(world): + """개인 B(바탕 = 개인 A) → 회사로 복사 = A 도 같이(새 키 · 본뜬) → 개인으로 다시 = 같은 값.""" + a = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) + b = _sk(world["personal"], 바탕={"참조": {**MINE, "키": a}}, 뺀줄=["labor:LB000002#0"]) + want = _calc(world["personal"], b)["기준"] + src = _doc(world["personal"], b) + with store.at(world["company"]): + made = refs.create( + "sangeun", refs.carry(refs.origin_mark(src, {**MINE, "키": b}), ("personal", "7/42")) + ) + copy = _doc(world["company"], made["이름"]) + assert copy["바탕"]["참조"]["층"] == "company" and copy["본뜬"]["키"] == b + assert _calc(world["company"], made["이름"])["기준"] == want + with store.at(world["personal"]): + back = refs.create( + "sangeun", + refs.carry(refs.origin_mark(copy, {**COMPANY, "키": made["이름"]}), ("company", "7")), + ) + assert _doc(world["personal"], back["이름"])["바탕"]["참조"]["층"] == "company" + assert _calc(world["personal"], back["이름"])["기준"] == want + + +def test_refusals(world): + with store.at(world["personal"]): + for bad, status in ( + ({"이름": "x", "더한줄": []}, 400), # 단위 빔 + ({"이름": "x", "단위": "㎡", "더한줄": [], "바탕": {"로직": "GC999999"}}, 400), + ( + { + "이름": "x", + "단위": "㎡", + "더한줄": [], + "바탕": {"참조": {**MINE, "키": "SK-000999"}}, + }, + 400, + ), + ({"이름": "x", "단위": "㎡"}, 400), # 뼈대(더한줄) 없음 + ): + with pytest.raises(store.StoreError) as e: + refs.create("sangeun", bad) + assert e.value.status == status, bad + key = _sk(world["personal"], 바탕={"로직": PCT}) + with store.at(world["personal"]), pytest.raises(store.StoreError) as e: + calc.calc_doc({**_doc(world["personal"], key), "바탕": {"참조": {**MINE, "키": key}}}) + assert e.value.status == 422 diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index aa12c2a0e..7f96f4ad3 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -85,7 +85,7 @@ def _base(code): # ── 자료 틀 ──────────────────────────────────────────────────────────── def test_단가승격_서고는_없음_층_종류에서_뺌(world): - assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",) + assert "promote" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo", "sangeun") with store.at(world["system"]), pytest.raises(KeyError): refs.create("promote", {"담은로직": [{"로직": TEN}]})