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:
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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)
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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": ("더한줄",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -559,6 +559,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,
|
||||
|
||||
@@ -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
|
||||
@@ -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}]})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user