From e393d67d98d2910455e216b4251ebc3f6cf579eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:44:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(793-2):=20=EA=B0=80=EC=A0=B8=EC=98=A8=20?= =?UTF-8?q?=EC=8B=9D=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20SK=20?= =?UTF-8?q?=EB=8B=B4=EA=B8=B0=20=E2=80=94=20=EB=B0=94=ED=83=95=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=C2=B7=20=EC=9B=90=EB=B3=B8=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=C2=B7=20=EA=B2=80=EC=82=B0=20=C2=B7=20=EC=9A=B0=EB=A6=AC=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EB=A1=9C=20=C2=B7=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=9A=B0=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Library_Same.py | 4 +- M02_MasterTemplete/M02_Sangeun_Const.py | 9 +- M02_MasterTemplete/M02_Sangeun_Doc.py | 14 +- M02_MasterTemplete/M02_Sangeun_Imported.py | 442 +++++++++++++++++- M02_MasterTemplete/M02_Sangeun_Router.py | 74 ++- .../tester/test_793_2_sangeun_imported.py | 347 ++++++++++++++ 6 files changed, 864 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_793_2_sangeun_imported.py diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index a0061cd98..a42819cc2 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -22,6 +22,7 @@ from typing import Any from common_util.common_util_derived_store import derived_key from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Imported as imp DROP = {"키", "이름", "구분", "상세구분", "본뜬", "메모", "별칭", "바탕판", "원값", "계산값", "열"} DROP |= {"일위대가번", "종류", "수정일", "출처", "처음수량", "뺀더한줄"} # 744-1 되돌림 기억 @@ -85,7 +86,7 @@ class Same: doc = flat.flat_doc(kind, doc) body = self._sk(doc, trail) if kind == "sangeun" else self._sc(doc, trail) - if kind == "sangeun" and not body.get("바탕") and not body.get("더한줄"): + if kind == "sangeun" and not any(body.get(k) for k in ("바탕", "더한줄", "가져온")): return None if kind != "sangeun" and not body.get("담은로직") and not body.get("자재"): return None @@ -133,6 +134,7 @@ class Same: "뺀줄": sorted(map(str, doc.get("뺀줄") or [])), # 값 바꾸는 새 칸(790-2 · 792-1) — 없으면 빠짐 = 옛 지문 그대로 **{k: norm(doc.get(k)) for k in ("상수덮개", "표값덮개", "식덮개", "변수줄")}, + "가져온": norm(imp.same_part(doc)), # 가져온 식(793-2) = 로직 · 단가기준 } return {k: v for k, v in body.items() if v not in EMPTY} diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index b0c8647c2..0e4a586b7 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -31,6 +31,7 @@ from typing import Any from M02_MasterTemplete import M02_Sangeun_Doc as sd from M02_MasterTemplete import M02_Sangeun_Formula as fx +from M02_MasterTemplete import M02_Sangeun_Imported as imp NEW = ("상수덮개", "표값덮개", "글줄", "까닭") _SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$") @@ -160,6 +161,8 @@ def no_flat(doc: Any) -> None: """펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로).""" if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개") or fx.has(doc)): raise sd.DocError("상수 · 표값 덮개 · 식 칸 단가산출은 펼치지 않음(참조 그대로)") + if imp.is_imported(doc): # 가져온 식(793-2) = M01 기초 없음 + raise sd.DocError("가져온 식 단가산출은 펼치지 않음(참조 그대로)") def covers_n(doc: dict) -> int: @@ -226,7 +229,7 @@ def clean_more(raw: Any, out: dict) -> dict: why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()} if why := {k: v for k, v in why.items() if v}: out["까닭"] = why - return fx.clean(raw, out) # 식 칸(792-1) + return imp.clean(raw, fx.clean(raw, out)) # 식 칸(792-1) · 가져온 식(793-2) def _print(one: Any) -> str: @@ -242,6 +245,8 @@ def stamp(row: dict, doc: dict | None = None) -> dict[str, Any]: if doc is not None and fx.has(doc): fixed = fx.settle(row, doc, strict=True)[2] doc.update({k: fixed[k] for k in ("더한줄", *fx.FIELDS) if k in fixed}) + if doc is not None and imp.is_imported(doc): # 가져온 식 검산(793-2) = 서버가 다시 + doc[imp.KEY]["검산"] = imp.check(doc) lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])] spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row) @@ -325,7 +330,7 @@ def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) - from M02_MasterTemplete import M02_Sangeun_Calc as calc out = {**calc.views.finish(calc.per_unit(got, clean)), "머리": calc.head_of(clean)} - return calc.labor._json(out | sheet(out, clean, raw, given, trail)) + return calc.labor._json(imp.more(out | sheet(out, clean, raw, given, trail), clean)) def _bare(doc: dict) -> dict: diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index af60e8522..24661c871 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -511,13 +511,7 @@ def patched( def empty_row(doc: dict) -> dict[str, Any]: - """바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈).""" - return { - "키": doc.get("키"), - "이름": doc.get("이름"), - "결과단위": f"원/{doc.get('단위') or ''}", - "입력": [], - "중간": [], - "호표": [], - "덧줄": [], - } + """바탕 없는 단가산출의 바탕 줄 — 입력 · 줄 없음(더한줄만 셈) · 가져온 식(793-2) = 그 줄.""" + from M02_MasterTemplete import M02_Sangeun_Imported as imp + + return imp.base_row(doc) diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index c2a1a3671..c6ab69229 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -6,18 +6,23 @@ 가져온 로직 줄(만들기 몸 `로직`) = {이름, 결과단위 「원/단위」, 입력?, 중간? [{이름, 식, 표시?}], 호표 [줄], 덧줄? [{이름, 식, 비목, 번지?, 합밖?, 자리?}], 단계?, 끝전?, 조정?, 끝수?} - 호표 줄 = {종류 인력|재료|기계|로직, 이름, 규격?, 단위, 수량(엔진 식 글), 비목(로직 줄은 없으면 - 노 · 재 · 경 나눠 얹음), 단가?(엑셀 단가 · 인력 · 재료 · 기계) | 값묶음? {노무비, 재료비, 경비} - (엑셀 · 로직 줄), 이음?(M01 요소 키 LB · MT · EQ … | 로직 줄 = 「로직(GC…, 이름='값')」 글 | null), - 번지?, 합밖?, 자리?} · `요소` 칸은 서버가 채움(이음 | {이름}) — 줄 정체 cid 머리 = 이음 키 | 이름. -원본(만들기 몸 `원본`) = {파일, 시트, 행 [처음, 끝], 갈래 STmate|EST, 엑셀금액 {노무비, 재료비, 경비, 계} - (1 단위), 글줄 [{행, 글, 자리?(셈 줄 cid | mid:이름), 값?(그 줄 엑셀 금액)}]}. -SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않음), 단가기준 엑셀|우리, 원본, 검산 {같음, - 셈 {노..계}, 엑셀 {노..계}, 줄 [{자리, 행, 셈, 엑셀, 같음, 까닭?}]}, 근거후보 [{기초, 옵션, 품검산, - 단 확정|근접|사람}](793-3 이 채움), 바꿈?(우리 기초로 바꾼 뒤 = 그때 로직 · 단가기준 · 전 · 후 · 차이)} · - 바탕 = null · 가져온 식 SK = `가져온.로직` 있음. -단가기준 엑셀 = 줄 단가 · 값묶음 그대로 / 우리 = 이음 있는 줄만 단가 · 값묶음 떼고 M01(B09 = 사본 단가) - 지금 값 · 이음 없는 줄 = 엑셀 단가 그대로 + 표지(`이음없음`). + 호표 줄 = {종류 인력|재료|기계|로직, 이름, 규격?, 단위, 수량(엔진 식 글), + 비목(로직 줄은 없으면 노 · 재 · 경 나눠 얹음), + 단가?(엑셀 단가 · 인력 · 재료 · 기계) | 값묶음? {노무비, 재료비, 경비}(엑셀 · 로직 줄), + 이음?(M01 요소 키 LB · MT · EQ … | 로직 줄 = 「로직(GC…, 이름='값')」 글 | null), + 번지?, 합밖?, 자리?} · `요소` 칸은 서버가 채움(이음 | {이름}) · + 줄 정체 cid 머리 = 이음 키 | 이름. +원본(만들기 몸 `원본`) = {파일, 시트, 행 [처음, 끝], 갈래 STmate|EST, + 엑셀금액 {노무비, 재료비, 경비, 계}(1 단위), + 글줄 [{행, 글, 자리?(셈 줄 cid | mid:이름), 값?(그 줄 엑셀 금액)}]}. +SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않음), 단가기준 엑셀|우리, 원본, + 검산 {같음, 까닭, 셈 {노..계}, 엑셀 {노..계}, 줄 [{자리, 행, 셈, 엑셀, 같음, 까닭?}]}, + 근거후보 [{기초, 옵션, 품검산, 단 확정|근접|사람}](793-3 이 채움), + 바꿈?(우리 기초로 바꾼 뒤 = 그때 로직 · 단가기준 · 전 · 후 · 차이)} · + 바탕 = null · 가져온 식 SK = `가져온.로직` 있음 · 검산 = 저장 때 서버가 다시. +단가기준 엑셀 = 줄 단가 · 값묶음 그대로 / + 우리 = 이음 있는 줄만 단가 · 값묶음 떼고 M01(B09 = 사본 단가) 지금 값 · + 이음 없는 줄 = 엑셀 단가 그대로 + 표지(`이음없음`). 길(`M02_Sangeun_Router` · 저장 없음 = 화면 캐시 · 만들기만 층 쓰기): POST …/sangeun/imported 몸 {로직, 원본, 이름?, 단위?, 구분?, 상세구분?, 새로?, 씀?} → {이름(SK 키), 검산, 단가기준}(서버가 다시 셈해 엑셀과 맞댐 · 같은 내용 409 = 기존 길) @@ -27,3 +32,416 @@ SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않 셈 답(`…/sangeun/calc`) = 옛 꼴 그대로 · `원래` = 엑셀 금액(「M01 원래」 자리) · 덧칸 `가져온` {단가기준, 파일, 시트, 행, 갈래, 엑셀금액, 글줄 [{행, 글, 자리, 엑셀, 지금, 같음}], 이음없음}. """ + +from __future__ import annotations + +import json +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +KEY = "가져온" +BASIS = ("엑셀", "우리") +COSTS = ("노무비", "재료비", "경비") +KINDS = ("인력", "재료", "기계", "로직") +ROW = ("이름", "결과단위", "입력", "중간", "호표", "덧줄", "단계", "끝전", "조정", "끝수") +LINE = ("종류", "이름", "규격", "단위", "수량", "비목", "단가", "값묶음", "이음", "번지", "합밖") +LINE += ("자리", "출처", "비고") +EXTRA = ("이름", "식", "비목", "번지", "합밖", "자리", "출처", "비고") +META = ("원본", "검산", "근거후보", "바꿈") # 검산 = 저장 때 서버가 다시 적음(`stamp`) +MANY = 300 # 호표 · 덧줄 · 중간 줄 수 +BIG = 400_000 # 메타 글 크기(바이트) +_KEYED = ("덮개", "고름", "자재표시", "상수덮개", "표값덮개", "식덮개", "까닭", "M01판") + + +def _mf() -> Any: + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor.m01.cm.mf + + +def _whole() -> Any: + from M01_MasterData import M01_MasterData_Store as m01 + + return m01.loaded()[1] + + +def is_imported(doc: Any) -> bool: + return isinstance(doc, dict) and isinstance((doc.get(KEY) or {}).get("로직"), dict) + + +# ── 바탕 줄 ─────────────────────────────────────────────────────────── +def row_of(doc: Any) -> dict | None: + """가져온 식 바탕 줄(엔진 로직 줄) — 단가기준 우리 = 이음 있는 줄의 단가 · 값묶음 뗌 · 요소 = + 이음 | {이름}(로직 줄 = 이음 | None) · 가져온 식 아님 = None.""" + if not is_imported(doc): + return None + meta = doc[KEY] + ours = meta.get("단가기준") == "우리" + items = [] + for one in meta["로직"].get("호표") or []: + link = one.get("이음") + item = {k: v for k, v in one.items() if k != "이음"} + item["요소"] = link or (None if one.get("종류") == "로직" else {"이름": one.get("이름")}) + if ours and link: + item.pop("단가", None) + item.pop("값묶음", None) + items.append(item) + row = {"입력": [], "중간": [], "덧줄": [], **meta["로직"]} + return {**row, "키": doc.get("키"), "호표": items} + + +def base_row(doc: dict) -> dict[str, Any]: + """바탕 없는 SK 바탕 줄(`sd.empty_row`) — 가져온 식 = 그 줄 · 아니면 입력 · 줄 없음.""" + return row_of(doc) or { + "키": doc.get("키"), + "이름": doc.get("이름"), + "결과단위": f"원/{doc.get('단위') or ''}", + **{k: [] for k in ("입력", "중간", "호표", "덧줄")}, + } + + +# ── 꼴 검사 ─────────────────────────────────────────────────────────── +def _formula(raw: Any, where: str) -> str: + from M02_MasterTemplete import M02_Sangeun_Formula as fx + + text = str(raw if raw is not None else "").strip() + if not text: + raise sd.DocError(f"{where} · 식이 빔") + if bad := fx.limits(text): + raise sd.DocError(f"{where} · {bad[0]}") + try: + _mf().parse(text) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError + raise sd.DocError(f"{where} · {e}") from e + return text + + +def _link(raw: Any, kind: str, where: str, whole: Any) -> str | None: + """이음 — 로직 줄 = 「로직(GC…)」 글(부르는 로직 있음) · 그 밖 = M01 요소 키(있음).""" + if raw in (None, ""): + return None + text = str(raw).strip() + try: + if kind == "로직": + node = _mf().parse(text) + if node[0] != "logic": + raise ValueError("로직(…) 꼴 아님") + whole.logic(node[1]) + else: + whole.get(text) + except Exception as e: # noqa: BLE001 + raise sd.DocError(f"{where} · 없는 이음 「{text}」 ({e})") from e + return text + + +def _line(raw: Any, where: str, whole: Any) -> dict: + if not isinstance(raw, dict): + raise sd.DocError(f"{where} · 묶음 아님") + kind = raw.get("종류") + if kind not in KINDS: + raise sd.DocError(f"{where} · 종류는 {' · '.join(KINDS)}") + out = {k: raw[k] for k in LINE if raw.get(k) not in (None, "")} + out["이름"] = str(raw.get("이름") or "").strip() or where + out["단위"] = str(raw.get("단위") or "") + out["수량"] = _formula(raw.get("수량"), f"{where} 수량") + out["이음"] = _link(raw.get("이음"), kind, where, whole) + if kind != "로직" and out.get("비목") not in sd.PARTS.values(): + raise sd.DocError(f"{where} · 비목은 노무비 · 재료비 · 경비") + if "단가" in out: + out["단가"] = sd.plain(sd._num(out["단가"], f"{where} 단가")) + if "값묶음" in out: + pack = out["값묶음"] if isinstance(out["값묶음"], dict) else {} + out["값묶음"] = {k: sd.plain(sd._num(pack.get(k, 0), f"{where} {k}")) for k in COSTS} + if kind == "로직" and "단가" in out: + raise sd.DocError(f"{where} · 로직 줄 엑셀 값은 값묶음") + if not any(k in out for k in ("단가", "값묶음")) and not out["이음"]: + raise sd.DocError(f"{where} · 엑셀 단가 · 이음 둘 다 없음") + return out + + +def clean_row(raw: Any, whole: Any = None) -> dict[str, Any]: + """가져온 로직 줄 꼴 — 줄 수 · 식(엔진 파서 · 상한) · 이음 키 · 엑셀 값 · 틀리면 `DocError`.""" + whole = whole or _whole() + if not isinstance(raw, dict): + raise sd.DocError("가져온 로직 · 묶음 아님") + out = {k: raw[k] for k in ROW if raw.get(k) not in (None, "", [], {})} + out["이름"] = str(raw.get("이름") or "").strip() or "가져온 식" + if not re.match(r"^원/\S+$", str(raw.get("결과단위") or "").strip()): + raise sd.DocError("가져온 로직 · 결과단위는 「원/단위」") + items, extras, mids = raw.get("호표") or [], raw.get("덧줄") or [], raw.get("중간") or [] + if not all(isinstance(x, list) for x in (items, extras, mids)) or not items: + raise sd.DocError("가져온 로직 · 호표(한 줄 이상) · 덧줄 · 중간은 목록") + if max(len(items), len(extras), len(mids)) > MANY: + raise sd.DocError(f"가져온 로직 · 줄은 {MANY} 까지") + out["호표"] = [_line(x, f"호표 {n}째", whole) for n, x in enumerate(items, 1)] + if extras: + out["덧줄"] = [_extra(x, f"덧줄 {n}째") for n, x in enumerate(extras, 1)] + if mids: + out["중간"] = [ + {**x, "식": _formula(x.get("식"), f"중간 「{x.get('이름')}」")} + for x in mids + if isinstance(x, dict) and str(x.get("이름") or "").strip() + ] + return out + + +def _extra(raw: Any, where: str) -> dict: + if not isinstance(raw, dict) or raw.get("비목") not in sd.PARTS.values(): + raise sd.DocError(f"{where} · 묶음 · 비목(노무비 · 재료비 · 경비)") + out = {k: raw[k] for k in EXTRA if raw.get(k) not in (None, "")} + return {**out, "이름": str(raw.get("이름") or where), "식": _formula(raw.get("식"), where)} + + +def _meta(raw: Any, name: str) -> Any: + try: + size = len(json.dumps(raw, ensure_ascii=False, default=str).encode("utf-8")) + except (TypeError, ValueError) as e: + raise sd.DocError(f"가져온 {name} · 글로 못 담음") from e + if size > BIG: + raise sd.DocError(f"가져온 {name} · {BIG} 바이트를 넘음") + want = list if name == "근거후보" else dict + if not isinstance(raw, want): + raise sd.DocError(f"가져온 {name} · {'목록' if want is list else '묶음'} 아님") + return raw + + +def clean(raw: Any, out: dict) -> dict: + """`sd.clean` 끝(`const.clean_more`) — `가져온` 칸 · 바탕과 같이 못 둠.""" + meta = raw.get(KEY) if isinstance(raw, dict) else None + if meta in (None, {}): + return out + if not isinstance(meta, dict): + raise sd.DocError("가져온 · 묶음 아님") + got = {k: _meta(meta[k], k) for k in META if meta.get(k) not in (None, {}, [])} + if meta.get("로직") is not None: + if out.get("바탕"): + raise sd.DocError("가져온 식 단가산출은 바탕 없음") + basis = meta.get("단가기준") or "엑셀" + if basis not in BASIS: + raise sd.DocError("단가기준은 엑셀 · 우리") + got |= {"로직": clean_row(meta["로직"]), "단가기준": basis} + return out | ({KEY: got} if got else {}) + + +def same_part(doc: Any) -> dict | None: + """같은 내용 지문 몫(`M02_Library_Same`) — 로직 · 단가기준(원본 · 검산 · 근거후보 뺌).""" + if not is_imported(doc): + return None + return {k: doc[KEY].get(k) for k in ("로직", "단가기준")} + + +def unlinked(doc: dict) -> list[dict]: + """이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름}]`.""" + row = row_of(doc) or {} + ids = sd.line_ids(row) + return [ + {"자리": ids[n], "이름": one.get("이름") or ""} + for n, one in enumerate(doc[KEY]["로직"].get("호표") or []) + if not one.get("이음") + ] + + +# ── 셈 · 검산 ───────────────────────────────────────────────────────── +def _json(value: Any) -> Any: + from M02_MasterTemplete import M02_Labor_Doc as labor + + return labor._json(value) + + +def _run(doc: dict, whole: Any) -> dict: + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + return calc.run(sd.clean(doc, None, whole), whole) + + +def _money(got: dict) -> dict | None: + unit = got.get("1단위") + if not unit: + return None + return {**{k: unit[k] for k in COSTS}, "계": unit["계"]} + + +def _same(mine: Any, excel: Any) -> bool: + """셈 ≈ 엑셀 — 엑셀 값 끝자리 한 칸 안(엑셀 보이는 값 = 끊은 값).""" + if mine is None or excel is None: + return False + want = Decimal(str(excel)) + step = Decimal(1).scaleb(min(want.normalize().as_tuple().exponent, 0)) + return abs(Decimal(str(mine)) - want) < step + + +def check(doc: dict, whole: Any = None) -> dict[str, Any]: + """검산 — 가져온 줄(엑셀 단가 · 고침 없음)을 엔진으로 다시 셈 → 엑셀 금액 · 줄마다 맞댐 · + 못 셈 = {같음 False, 까닭}.""" + whole = whole or _whole() + meta = doc[KEY] + bare = {k: doc.get(k) for k in ("키", "이름", "단위", "입력값", "환산")} + bare |= {"바탕": None, KEY: {"로직": meta["로직"], "단가기준": "엑셀"}} + src = meta.get("원본") or {} + excel = src.get("엑셀금액") or {} + try: + got = _run(bare, whole) + except sd.DocError as e: + return {"같음": False, "까닭": str(e), "셈": None, "엑셀": excel, "줄": []} + mine = _money(got) + lines = _lines(src, {x.get("cid"): x.get("금액") for x in got.get("줄") or []}) + ok = bool(mine) and all(_same(mine.get(k), excel.get(k)) for k in excel) and excel != {} + why = None if ok else (got.get("까닭") or ("엑셀 금액 없음" if not excel else "금액 다름")) + ok = ok and all(x["같음"] for x in lines) + return _json({"같음": ok, "까닭": why, "셈": mine, "엑셀": excel, "줄": lines}) + + +def _lines(src: dict, now: dict) -> list[dict]: + out = [] + for one in src.get("글줄") or []: + spot, want = one.get("자리"), one.get("값") + if not spot or want is None: + continue + got = now.get(spot) + same = _same(got, want) + why = None if same else ("짝 줄 없음" if spot not in now else f"셈 {got} · 엑셀 {want}") + out.append({"자리": spot, "행": one.get("행"), "셈": got, "엑셀": want, "같음": same}) + out[-1] |= {"까닭": why} if why else {} + return out + + +def more(out: dict, clean_: dict) -> dict: + """셈 답 덧칸(`const.answer`) — 가져온 식: `원래` = 엑셀 금액 · `가져온` 원본 글 보기.""" + if not is_imported(clean_): + return out + meta = clean_[KEY] + src = meta.get("원본") or {} + spots = (out.get("시트") or {}).get("자리") or {} + now = {k: v.get("지금") for k, v in spots.items()} + lines = {x["자리"]: x for x in _lines(src, now)} + texts = [ + {k: one.get(k) for k in ("행", "글", "자리")} + | ({"엑셀": one["값"]} if one.get("값") is not None else {}) + | ({"지금": lines[one["자리"]]["셈"], "같음": lines[one["자리"]]["같음"]} + if one.get("자리") in lines else {}) + for one in src.get("글줄") or [] + ] # fmt: skip + head = {k: src.get(k) for k in ("파일", "시트", "행", "갈래", "엑셀금액")} + view = {"단가기준": meta.get("단가기준"), **head, "글줄": texts, "이음없음": unlinked(clean_)} + view |= {k: meta[k] for k in ("검산", "근거후보", "바꿈") if meta.get(k)} + excel = src.get("엑셀금액") + return out | {KEY: view} | ({"원래": excel} if excel else {}) + + +# ── 고치기(화면 캐시 · 저장 없음) ─────────────────────────────────────── +def _need(doc: Any) -> dict: + if not is_imported(doc): + raise sd.DocError("가져온 식 단가산출 아님") + return doc + + +def _with(doc: dict, **meta: Any) -> dict: + return {**doc, KEY: {**doc[KEY], **meta}} + + +def to_price(doc: Any, basis: str) -> dict[str, Any]: + """[우리 단가로] · [엑셀 단가로] — 단가기준만 바꾼 문서 · 바꾸기 전후 1 단위 금액 · 이음없음.""" + doc = _need(doc) + if basis not in BASIS: + raise sd.DocError("단가기준은 엑셀 · 우리") + whole = _whole() + after = _with(doc, 단가기준=basis) + before = _money(_run(doc, whole)) + return { + "문서": after, + "전": before, + "후": _money(_run(after, whole)), + "이음없음": unlinked(doc), + } + + +def link(doc: Any, spot: str, key: Any) -> dict[str, Any]: + """자원 줄 이음 고름 — 그 줄 `이음` · 줄 정체가 바뀐 만큼 덮개 · 뺀줄 · 글줄 · 까닭 열쇠 + 옮김.""" + doc = _need(doc) + whole = _whole() + old = sd.line_ids(row_of(doc)) + if spot not in old[: len(doc[KEY]["로직"]["호표"])]: + raise sd.DocError(f"없는 자원 줄 「{spot}」") + items = [dict(x) for x in doc[KEY]["로직"]["호표"]] + at = old.index(spot) + items[at]["이음"] = _link(key, items[at]["종류"], f"「{spot}」", whole) + after = _with(doc, 로직={**doc[KEY]["로직"], "호표": items}) + move = dict(zip(old, sd.line_ids(row_of(after)))) + return {"문서": _rekey(after, {a: b for a, b in move.items() if a != b})} + + +def _rekey(doc: dict, move: dict) -> dict: + if not move: + return doc + out = dict(doc) + for name in _KEYED: + if isinstance(doc.get(name), dict): + out[name] = {move.get(c, c): v for c, v in doc[name].items()} + if isinstance(doc.get("뺀줄"), list): + out["뺀줄"] = [move.get(c, c) for c in doc["뺀줄"]] + if isinstance(doc.get("글줄"), list): + out["글줄"] = [{**t, "자리": move.get(t.get("자리"), t.get("자리"))} for t in doc["글줄"]] + out["더한줄"] = [_rate_rekey(x, move) for x in doc.get("더한줄") or []] + src = doc[KEY].get("원본") or {} + if src.get("글줄"): + lines = [{**t, "자리": move.get(t.get("자리"), t.get("자리"))} for t in src["글줄"]] + out[KEY] = {**out[KEY], "원본": {**src, "글줄": lines}} + return out + + +def _rate_rekey(one: Any, move: dict) -> Any: + """율 더한줄 기준 줄 목록(778-4 · `열쇠@비목`) 열쇠 옮김.""" + rate = ((one.get("고정") or {}).get("비율") or {}) if isinstance(one, dict) else {} + if not isinstance(rate.get("기준"), list): + return one + keys = [] + for k in rate["기준"]: + head, _, part = str(k).partition("@") + keys.append(move.get(head, head) + (f"@{part}" if part else "")) + return {**one, "고정": {**one["고정"], "비율": {**rate, "기준": keys}}} + + +def to_base(doc: Any, code: str, given: dict | None = None) -> dict[str, Any]: + """[우리 기초로 바꾸기] — 가져온 식 SK → M01 기초(바탕 로직) + 옵션 SK · 고침 걷힘 · + 원본 · 검산 · 근거후보 보존 + `바꿈` {로직, 단가기준, 전, 후, 차이}(1 단위).""" + doc = _need(doc) + whole = _whole() + before = _money(_run(doc, whole)) + head = {k: doc[k] for k in ("키", "이름", "단위", "구분", "상세구분") if k in doc} + new = {**head, "바탕": {"로직": str(code or "").strip()}, "입력값": dict(given or {})} + new |= {"열린입력": [], "고름": {}, "자재표시": {}, "덮개": {}, "뺀줄": [], "더한줄": []} + after = _money(_run(new, whole)) + gap = {k: after[k] - before[k] for k in after} if before and after else None + keep = {k: doc[KEY][k] for k in ("원본", "검산", "근거후보") if doc[KEY].get(k)} + was = {k: doc[KEY].get(k) for k in ("로직", "단가기준")} + new[KEY] = {**keep, "바꿈": {**was, "전": before, "후": after, "차이": gap}} + return {"문서": new, "전": before, "후": after, "차이": gap} + + +# ── 만들기(개인 층) ─────────────────────────────────────────────────── +def create( + logic: Any, source: Any, name: str = "", unit: str = "", head: dict | None = None, + new: bool = False, use: str | None = None, +) -> dict[str, Any]: # fmt: skip + """엔진 로직 줄 + 원본 → 지금 층 새 가져온 식 SK — 서버가 다시 셈해 엑셀과 맞댐(검산 · + 다르면 `같음` False 로 표지 · 막지 않음) · 같은 층 같은 내용 = 409(`ask` · 733-2).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Library_Ask as ask + + whole = _whole() + row = clean_row(logic, whole) + unit = str(unit or "").strip() or row["결과단위"].split("/", 1)[1] + doc = {"키": "SK-000000", "이름": str(name or "").strip() or row["이름"], "단위": unit} + doc |= {k: str((head or {}).get(k) or "") for k in ("구분", "상세구분")} + doc |= {"바탕": None, "입력값": {}, "덮개": {}, "뺀줄": [], "더한줄": []} + meta = {"로직": row, "단가기준": "엑셀"} + if source not in (None, {}): + meta["원본"] = _meta(source, "원본") + doc[KEY] = meta + got = check(sd.clean(doc, None, whole), whole) + key = ask.ask(sd.KIND, doc, new, use) or refs.create(sd.KIND, doc)["이름"] + return {"이름": key, "검산": got, "단가기준": "엑셀"} diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 5399add5a..c1af7f3d3 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,6 +1,8 @@ """M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · [단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) · -식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한). +식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) · +가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` · +`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) · @@ -21,6 +23,7 @@ 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 import M02_Sangeun_Formula as fx +from M02_MasterTemplete import M02_Sangeun_Imported as imp from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun"]) @@ -90,3 +93,72 @@ def check_formula( ) -> dict: """식 칸 검사(792-1) — {되나, 까닭, 알림, 식(엔진), 글(이름 꼴)} · 셈 · 저장 없음.""" return _call(_root(session, layer, owner), fx.check_doc, body.문서, body.자리, body.식) + + +class ImportedBody(BaseModel): + 로직: dict[str, Any] + 원본: dict[str, Any] | None = None + 이름: str = "" + 단위: str = "" + 구분: str = "" + 상세구분: str = "" + 새로: bool = False + 씀: str | None = None + + +@router.post("/layers/{layer}/sangeun/imported") +def sangeun_imported( + layer: Layer, + body: ImportedBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """가져온 식 단가산출 만들기(793-2) — 답 = {이름, 검산, 단가기준} · 같은 내용 409.""" + folder = _root(session, layer, owner, write=True) + head = {"구분": body.구분, "상세구분": body.상세구분} + args = (body.로직, body.원본, body.이름, body.단위, head, body.새로, body.씀) + return _call(folder, _sd(imp.create), *args) + + +class ImportedEditBody(BaseModel): + 문서: dict[str, Any] + 기준: str | None = None # price + 자리: str | None = None # link + 이음: str | None = None # link + 로직: str | None = None # to-base + 입력값: dict[str, Any] | None = None # to-base + + +def _sd(fn: Any) -> Any: + """`sd.DocError` → 400 · 422(`store.StoreError`).""" + + def run(*args: Any) -> Any: + try: + return calc.labor._json(fn(*args)) + except sd.DocError as e: + raise calc._fail(e) from e + + return run + + +_EDIT = { + "price": lambda b: (imp.to_price, b.문서, b.기준), + "link": lambda b: (imp.link, b.문서, b.자리, b.이음), + "to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값), +} + + +@router.post("/layers/{layer}/sangeun/imported/{how}") +def sangeun_imported_edit( + layer: Layer, + how: str, + body: ImportedEditBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · to-base [우리 기초로 + 바꾸기] · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액.""" + if how not in _EDIT: + raise HTTPException(status_code=404, detail="price · link · to-base") + fn, *args = _EDIT[how](body) + return _call(_root(session, layer, owner), _sd(fn), *args) diff --git a/resources/tester/test_793_2_sangeun_imported.py b/resources/tester/test_793_2_sangeun_imported.py new file mode 100644 index 000000000..82c86979e --- /dev/null +++ b/resources/tester/test_793_2_sangeun_imported.py @@ -0,0 +1,347 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""793-2 가져온 식 단가산출(SK 바탕 갈래) — 경쟁사 산근 블록 = 엔진 로직 줄을 SK 가 품고 다시 셈. + +- 견본 둘 = 791-1 손 옮김 견본(봉화 D00102 합판거푸집 STmate 번지 · 율 / 북부청임도 18 문양거푸집 + EST 줄 자리) · 단가 = 엑셀 목록표 값 → 셈 = 엑셀 금액 · 줄마다 검산 +- 덮개 · 상수 덮개 · 식 칸 · 변수 줄이 가져온 줄에도 먹음 +- 셈 답 `원래` = 엑셀 금액 · `가져온` 원본 글 +- [우리 단가로] · [엑셀 단가로] · 이음 고름(덮개 열쇠 옮김) · [우리 기초로 바꾸기] +- 만들기(임시 층) · 같은 내용 409 · 옛 SK 지문 그대로 · 펼침 막음 · B09 굳힘 · 단가 전파 +""" + +from __future__ import annotations + +import copy +import json +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Sangeun as sk +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Library_Flat as flat +from M02_MasterTemplete import M02_Library_Same as same +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Const as const +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +D = Decimal +MAN, CARP = "LB000002", "LB000007" # M01 보통인부 · 형틀목공 +FORM = "GC000211" # 합판거푸집 기초 +F211 = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님"} +F211 |= {"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} + + +def _whole(): + return m01.loaded()[1] + + +def _man(qty, name, price, **more): + line = {"종류": "인력", "이름": name, "단위": "인", "수량": qty, "비목": "노무비"} + return {**line, "단가": price, **more} + + +def _mat(qty, name, unit, price, **more): + line = {"종류": "재료", "이름": name, "단위": unit, "수량": qty, "비목": "재료비"} + return {**line, "단가": price, **more} + + +def _bonghwa() -> tuple[dict, dict]: + """봉화 단가산출근거 2909~2943 행(791-1 견본) — 보통인부만 M01 에 이음.""" + row = { + "이름": "합판거푸집(봉화 D00102)", + "결과단위": "원/㎡", + "호표": [ + _mat("1.03 * 0.327", "합판(내수)", "㎡", 11468, 번지="A01"), + _mat("0.038 * 0.327", "각재", "㎥", 500661, 번지="A01"), + _man("5 / 50", "형틀목공", 274978, 번지="A02"), + _man("2 / 50", "보통인부", 165545, 번지="A02", 이음=MAN), + ], + "덧줄": [ + {"이름": "소모자재(박리재 등)", "식": "번지.A01.재료비 * 11 / 100", "비목": "재료비"}, + {"이름": "공구손료", "식": "번지.A02.노무비 * 1 / 100", "비목": "경비"}, + ], + "단계": [ + {"이름": "자재비 소계", "단": 1, "줄": ["합판(내수)", "각재", "소모자재(박리재 등)"]}, + {"이름": "설치 소계", "단": 1, "줄": ["형틀목공", "보통인부", "공구손료"]}, + {"이름": "계", "단": 2}, + ], + } + src = { + "파일": "봉화.xlsx", + "시트": "단가산출근거", + "행": [2909, 2943], + "갈래": "STmate", + "엑셀금액": {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}, + "글줄": [ + {"행": 2909, "글": "합판거푸집 ㎡ 당"}, + {"행": 2910, "글": "합판(내수) &M00081& * 1.03 * 0.327", "자리": PLY, "값": 3862.5}, + {"행": 2912, "글": "형틀목공 5 / 50", "자리": "labor:형틀목공#0", "값": 27497.8}, + {"행": 2913, "글": "보통인부 2 / 50", "자리": f"labor:{MAN}#0", "값": 6621.8}, + {"행": 2915, "글": "소모자재 &PA01M:M& * 11'%'", "자리": BURN, "값": 1109.2}, + ], + } + return row, src + + +PLY, BURN = "material:합판(내수)#0", "pct:소모자재(박리재 등)#0" + + +def _bukbu() -> tuple[dict, dict]: + """북부청임도 단가산출 18 번(791-1 견본) — 잡재료 줄만 반올림 0 · 이음 없음.""" + row = { + "이름": "문양거푸집(북부청 18)", + "결과단위": "원/㎡", + "호표": [ + _mat("1 * (1 / 0.72)", "문양거푸집(ABS코팅폼)", "㎡", 4166), + _man("0.07", "형틀목공", 275790), + _man("0.03", "보통인부", 172068), + ], + "덧줄": [ + { + "이름": "잡재료비", + "식": "재료비 * 2 / 100", + "비목": "재료비", + "자리": {"방법": "반올림", "자리": 0}, + } + ], + "단계": [{"이름": "인부소계", "단": 1, "줄": ["형틀목공", "보통인부"]}], + } + src = {"파일": "북부청임도.xlsx", "시트": "단가산출", "행": [410, 420], "갈래": "EST"} + src |= {"엑셀금액": {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}} + src |= {"글줄": [{"행": 415, "글": "5,786.1 × 2% = 116", "자리": "pct:잡재료비#0", "값": 116}]} + return row, src + + +def _doc(sample=_bonghwa, **more): + row, src = sample() + meta = {"로직": row, "단가기준": "엑셀", "원본": src} + return {"키": "SK-000001", "이름": "가져온", "단위": "㎡", "바탕": None, imp.KEY: meta, **more} + + +def _run(doc, whole=None): + whole = whole or _whole() + return calc.run(sd.clean(doc, None, whole), whole) + + +def _sum(doc, whole=None) -> dict: + return {k: float(v) for k, v in _run(doc, whole)["1단위"].items()} + + +def _total(doc, whole=None) -> float: + return _sum(doc, whole)["계"] + + +# ── 셈 = 엑셀 ───────────────────────────────────────────────────────── +def test_봉화_셈_엑셀_검산(): + assert _sum(_doc()) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + got = imp.check(sd.clean(_doc(), None, _whole())) + assert got["같음"] is True and len(got["줄"]) == 4 and all(x["같음"] for x in got["줄"]) + bad = _doc() + bad[imp.KEY]["원본"]["엑셀금액"]["계"] = 45653 + bad[imp.KEY]["원본"]["글줄"][1]["값"] = 3870 + got = imp.check(sd.clean(bad, None, _whole())) + assert got["같음"] is False and got["까닭"] == "금액 다름" + assert got["줄"][0]["까닭"] == "셈 3862.5 · 엑셀 3870" + + +def test_북부청_18_줄_자리_셈_엑셀(): + assert _sum(_doc(_bukbu)) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369} + assert imp.check(sd.clean(_doc(_bukbu), None, _whole()))["같음"] is True + + +# ── 덮개 · 식 칸 · 셈 답 ───────────────────────────────────────────── +def test_덮개_상수_식칸_가져온_줄에_먹음(): + base = _total(_doc()) + covered = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}) + assert _total(covered) > base # 형틀목공 5/50 → 0.2 인 + assert ( + _total(_doc(상수덮개={BURN: {"0": 12}})) == 45652 + 101 + ) # 소모자재 11 → 12 %(A01 재료 10,083.7 × 1 %) + assert _total(_doc(식덮개={"labor:형틀목공#0": "6 / 50"})) > base + var = [{"이름": "할증", "식": "1.2", "자리": ""}] + assert _total(_doc(변수줄=var, 식덮개={"labor:형틀목공#0": "5 / 50 * 할증"})) > base + assert _total(_doc(뺀줄=[PLY])) < base + adds = [{"종류": "인력", "요소": MAN, "수량": 0.01}] + assert _total(_doc(더한줄=adds)) > base + + +def test_셈_답_원래_엑셀_원본_글(world): + doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}, 까닭={"labor:형틀목공#0": "현장 품"}) + with store.at(world["personal"]): + got = calc.calc_doc(doc) + assert got["원래"] == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + assert got["기준"]["계"] > 45652 + spot = got["시트"]["자리"]["labor:형틀목공#0"] + assert spot["덮개"] == ["품"] and spot["까닭"] == "현장 품" + view = got[imp.KEY] + assert view["단가기준"] == "엑셀" and view["파일"] == "봉화.xlsx" and view["행"] == [2909, 2943] + lines = view["글줄"] + assert lines[0] == {"행": 2909, "글": "합판거푸집 ㎡ 당", "자리": None} + assert lines[1]["같음"] is True and lines[2]["같음"] is False # 덮은 줄 = 엑셀과 다름 + assert [x["이름"] for x in view["이음없음"]] == ["합판(내수)", "각재", "형틀목공"] + + +# ── 단가 기준 · 이음 ────────────────────────────────────────────────── +def test_우리_단가로_엑셀_단가로(): + got = imp.to_price(_doc(), "우리") + assert got["문서"][imp.KEY]["단가기준"] == "우리" and float(got["전"]["계"]) == 45652 + price = float(_whole().get(MAN)["값"]) + assert float(got["후"]["노무비"]) == pytest.approx(34119 + (price - 165545) * 2 / 50, abs=1) + back = imp.to_price(got["문서"], "엑셀") + assert float(back["후"]["계"]) == 45652 and back["전"] == got["후"] + assert len(got["이음없음"]) == 3 + with pytest.raises(sd.DocError): + imp.to_price(_doc(), "남의") + with pytest.raises(sd.DocError): + imp.to_price({"키": "SK-000001", "바탕": {"로직": FORM}}, "우리") + + +def test_이음_고름_덮개_열쇠_옮김(): + doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}, 까닭={"labor:형틀목공#0": "현장"}) + doc["글줄"] = [{"자리": "labor:형틀목공#0", "글": "※ 현장 조정"}] + got = imp.link(doc, "labor:형틀목공#0", CARP)["문서"] + new = f"labor:{CARP}#0" + assert got["덮개"] == {new: {"qty": 0.2}} and got["까닭"] == {new: "현장"} + assert got["글줄"][0]["자리"] == new + assert got[imp.KEY]["원본"]["글줄"][2]["자리"] == new + assert _total(got) == _total(doc) # 엑셀 단가 그대로 = 금액 그대로 + ours = imp.to_price(got, "우리") + assert len(ours["이음없음"]) == 2 and ours["후"]["계"] != ours["전"]["계"] + with pytest.raises(sd.DocError): + imp.link(doc, "labor:형틀목공#0", "LB999999") + with pytest.raises(sd.DocError): + imp.link(doc, "labor:없는줄#0", CARP) + + +def test_우리_기초로_바꾸기(): + doc = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}) + got = imp.to_base(doc, FORM, F211) + new = got["문서"] + assert new["바탕"] == {"로직": FORM} and not imp.is_imported(new) and new["덮개"] == {} + assert float(got["후"]["계"]) == 51419 and float(got["전"]["계"]) > 45652 + assert got["차이"]["계"] == got["후"]["계"] - got["전"]["계"] + meta = new[imp.KEY] + assert meta["원본"]["파일"] == "봉화.xlsx" and meta["바꿈"]["로직"]["이름"].startswith("합판") + assert _total(new) == 51419 and same.finger("sangeun", new) == same.finger( + "sangeun", {k: v for k, v in new.items() if k != imp.KEY} + ) + with pytest.raises(sd.DocError): + imp.to_base(doc, "GC999999", {}) + + +# ── 꼴 검사 ─────────────────────────────────────────────────────────── +@pytest.mark.parametrize( + ("change", "why"), + [ + (lambda r: r.update(결과단위="㎥/시간"), "결과단위"), + (lambda r: r["호표"][0].update(수량="1.03 * (0.327"), "수량"), + (lambda r: r["호표"][0].update(비목="잡비"), "비목"), + (lambda r: r["호표"][0].pop("단가"), "둘 다 없음"), + (lambda r: r["호표"][0].update(이음="MT999999"), "없는 이음"), + (lambda r: r["덧줄"][0].update(식="번지.A01.재료비 * "), "덧줄"), + (lambda r: r.update(호표=[]), "호표"), + ], +) +def test_틀린_가져온_로직(change, why): + doc = _doc() + change(doc[imp.KEY]["로직"]) + with pytest.raises(sd.DocError, match=why): + sd.clean(doc, None, _whole()) + + +def test_바탕과_같이_못_둠_펼침_막음(): + with pytest.raises(sd.DocError, match="바탕 없음"): + sd.clean(_doc(바탕={"로직": FORM}), None, _whole()) + with pytest.raises(sd.DocError, match="펼치지 않음"): + const.no_flat(_doc()) + + +# ── 만들기 · 저장 · 같은 내용 ──────────────────────────────────────── +def test_만들기_저장_같은_내용(world): + mine = world["personal"] + row, src = _bonghwa() + with store.at(mine): + made = imp.create(row, src, "합판거푸집 가져옴") + doc = store.read("sangeun", made["이름"])["문서"] + with pytest.raises(store.StoreError) as e: # 같은 내용 = 409 + imp.create(row, {**src, "파일": "다른.xlsx"}, "또") + assert e.value.status == 409 + again = imp.create(row, src, "또", new=True)["이름"] + ours = imp.create(row, src, "우리", new=True)["이름"] + assert made["검산"]["같음"] is True and made["단가기준"] == "엑셀" + meta = doc[imp.KEY] + assert doc["바탕"] is None and doc["단위"] == "㎡" and meta["원본"] == src + assert meta["검산"]["같음"] is True and meta["로직"]["호표"][3]["이음"] == MAN + assert again != made["이름"] != ours + plain = {"키": "SK-000009", "이름": "x", "단위": "식", "바탕": {"로직": FORM}, "입력값": F211} + one = same.finger("sangeun", doc) + assert one and one != same.finger("sangeun", imp.to_price(doc, "우리")["문서"]) + assert same.finger("sangeun", {**doc, imp.KEY: {**doc[imp.KEY], "원본": {}}}) == one + assert same.finger("sangeun", {k: v for k, v in plain.items()}) == same.finger( + "sangeun", {**plain, imp.KEY: {}} + ) + with store.at(mine): # 저장 = 서버가 검산 다시(브라우저 값 안 받음) + lie = {**doc, imp.KEY: {**doc[imp.KEY], "검산": {"같음": False}}} + store.write("sangeun", made["이름"], store.read("sangeun", made["이름"])["판"], lie) + assert store.read("sangeun", made["이름"])["문서"][imp.KEY]["검산"]["같음"] is True + + +def test_옛_SK_그대로(): + plain = {"키": "SK-000001", "이름": "x", "단위": "식", "바탕": {"로직": FORM}, "입력값": F211} + got = sd.clean(plain, None, _whole()) + assert imp.KEY not in got and _total(plain) == 51419 + empty = {"키": "SK-000001", "이름": "x", "단위": "식", "바탕": None} + assert sd.empty_row(empty)["호표"] == [] and same.finger("sangeun", empty) is None + + +# ── B09 굳힘 · 단가 전파 ─────────────────────────────────────────────── +class _Priced: + """M01 색인 위 노임 하나만 바꿈(단가 전파 흉내).""" + + def __init__(self, whole, key, value): + self._whole, self._key, self._value = whole, key, value + + def value(self, ref): + got = self._whole.value(ref) + return (D(self._value), got[1]) if ref == self._key else got + + def __getattr__(self, name): + return getattr(self._whole, name) + + +def _frozen_total(doc, whole): + rid = "personal|7/42|SK-000001" + frozen = sk.freeze({rid: sd.clean(doc, None, _whole())})[rid] + key, row = frozen["바탕"]["로직"], json.loads(json.dumps(frozen[sk.ROW])) + assert key.startswith("GX9") and frozen["원본"][imp.KEY]["로직"] + over = calc._Over(whole, key, row) + return float(calc.run(sd.clean(frozen, None, over), over)["1단위"]["계"]) + + +def test_B09_굳힘_단가_전파(): + whole = _whole() + excel, ours = _doc(), imp.to_price(_doc(), "우리")["문서"] + assert _frozen_total(excel, whole) == 45652 + live = _total(ours) + assert _frozen_total(ours, whole) == live + up = _Priced(whole, MAN, float(whole.get(MAN)["값"]) + 50000) + assert _frozen_total(excel, up) == 45652 # 엑셀 단가 그대로 = 안 바뀜 + assert _frozen_total(ours, up) == pytest.approx(live + 50000 * 2 / 50 * 1.01, abs=2) + covered = _doc(덮개={"labor:형틀목공#0": {"qty": 0.2}}) + assert _frozen_total(covered, whole) == _total(covered) + + +def test_원본_보존_사본(): + doc = _doc() + keep = copy.deepcopy(doc) + imp.to_price(doc, "우리") + imp.link(doc, "labor:형틀목공#0", CARP) + imp.to_base(doc, FORM, F211) + assert doc == keep # 받은 문서 안 고침(화면 캐시 = 답 문서로 바꿈) + assert refs.kind_of("SK-000001") == "sangeun" and MINE["층"] == "personal" + assert flat.flat_doc("sangeun", doc) == doc # 참조 없음 = 그대로