diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index dff01a191..71d496dd0 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -42,6 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]: "별칭": alias, "메모": str(memo) if memo else None, "수량": _qty(one.get("수량"), where), + **patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3) } ) continue @@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]: **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), **({"승격": True} if one.get("승격") is True else {}), + **patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3) **( {"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {} ), @@ -512,7 +515,7 @@ def calc( lines = [] for one in doc.get("담은로직") or []: if _is_ref(one): - lines.append(ref_line(one["참조"], expand(one["참조"]))) + lines.append(ref_line(one["참조"], patch.ref(one, expand))) continue if fixed.is_fixed(one): lines.append(fixed.line(whole, one[fixed.FIXED])) @@ -525,6 +528,9 @@ def calc( "분모": pill.get("단위"), "배": pill.get("배") or 1, } + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지 + lines.append({**line, **patch.run(whole, one, read)}) + continue got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 35a73300c..10054f5c7 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -11,6 +11,7 @@ from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -81,10 +82,13 @@ class Expand: 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) + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + got = patch.run(self.whole, one, self.read) + else: + 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 { @@ -109,9 +113,10 @@ class Expand: 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 단위 값(기준 ÷ 배 · 승격과 같음) + if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 + head = {"참조": ref} + sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self) + 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["고정"]) @@ -156,7 +161,14 @@ class Expand: return { "이름": doc.get("이름"), "단위": doc.get("단위"), + "머리": head_of(doc), "기준": None if why else labor._sum(rows), "까닭": why, "안": inner, } + + +def head_of(doc: dict) -> str | None: + """상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None.""" + unit = str(doc.get("단위") or "").strip() + return f"1 {unit} 당" if unit else None diff --git a/M02_MasterTemplete/M02_Labor_Patch.py b/M02_MasterTemplete/M02_Labor_Patch.py new file mode 100644 index 000000000..aa0b3db09 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Patch.py @@ -0,0 +1,123 @@ +"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5). + +줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 · +로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 · +줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리. +더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로). +셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는 +알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +FIELD = "패치" +KEEP = ("덮개", "뺀줄", "더한줄") +TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함 + + +def _bad(text: str): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return store.StoreError(400, text) + + +def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict: + """줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400.""" + raw = one.get(FIELD) + if not raw: + return {} + if not isinstance(raw, dict): + raise _bad(f"{where} · 패치는 묶음") + if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)): + raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로") + body = {k: raw.get(k) for k in KEEP} + try: + got = sd.clean( + {"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole + ) + except sd.DocError as e: + raise _bad(f"{where} · {e}") from e + out = {k: got[k] for k in KEEP if got[k]} + return {FIELD: out} if out else {} + + +def ref_clean(one: dict, ref: dict, where: str) -> dict: + """단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + if not one.get(FIELD): + return {} + if refs.kind_of(ref["키"]) != sd.KIND: + raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만") + from M01_MasterData import M01_MasterData_Store as m01 + + doc = refs.read_ref(ref) or {} + return clean(one, where, m01.loaded()[1], None, doc.get("열린입력")) + + +def _keyed(value: Any, ids: list[str]) -> dict: + """차례 열쇠 → cid 열쇠(바탕 줄 차례).""" + out = {} + for n, v in (value or {}).items() if isinstance(value, dict) else (): + at = int(n) if str(n).isdigit() else -1 + if 0 <= at < len(ids): + out[ids[at]] = v + return out + + +def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]: + """일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로.""" + body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP} + head = {"키": TEMP, "이름": name, "단위": unit, **body} + if "참조" in one: + return {**head, "바탕": {"참조": one["참조"]}} + try: + ids = sd.line_ids(whole.logic(one["로직"])) + except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭 + ids = [] + extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {} + return { + **head, + **extra, + "바탕": {"로직": one["로직"]}, + "입력값": dict(one.get("입력값") or {}), + "고름": _keyed(one.get("고름"), ids), + "자재표시": _keyed(one.get("자재표시"), ids), + } + + +def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]: + """패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + doc = sd.clean(as_doc(one, whole), None, whole) + return calc.run(doc, whole, None, read or refs.read_ref) + except sd.DocError as e: + return {"기준": None, "까닭": str(e)} + + +def ref(one: dict, expand: Any) -> dict[str, Any]: + """참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not one.get(FIELD): + return expand(one["참조"]) + doc = as_doc(one, expand.whole) + base = expand.read(one["참조"]) or {} + doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]} + return calc.unit(doc, expand.whole, expand, ()) + + +def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]: + """[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버).""" + if "로직" not in one and "참조" not in one: + raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장") + doc = as_doc(one, whole, name, unit) + doc.pop("키") + return doc diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index f8ede9600..b474e7b66 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out: dict[str, Any] = {"종류": TITLE[kind], "키": key} if not name: raise _bad("사전조합 이름이 빔") - out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + lines = _combo_lines(doc, key) + if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) · + raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음 + out.update(이름=name, 단위=unit or None, 담은로직=lines) out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None @@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: lines = [] for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: - head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]} + head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: - head["입력값"] = one["입력값"] + head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) @@ -331,7 +334,8 @@ def users(ref: Ref) -> list[str]: """`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌).""" rid, out = ref_id(ref), [] - for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")): + # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」 + for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")): for at, path in layers.layer_files(kind): try: doc = json.loads(path.read_bytes()) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 0ea7b5e10..a6f0743fd 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -170,6 +170,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} +def head_of(doc: dict) -> str: + return f"1 {doc.get('단위')} 당" + + # ── 저장 ────────────────────────────────────────────────────────────── def _check_refs(doc: dict, here_: refs.Here) -> None: me = {"층": here_[0], "주인": here_[1], "키": doc["키"]} @@ -227,4 +231,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: got = run(clean, whole, given, trail=(me,)) except sd.DocError as e: raise _fail(e) from e - return labor._json(got) + return labor._json({**got, "머리": head_of(clean)}) + + +def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: + """[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK · + 답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Labor_Patch as patch + + if not isinstance(line, dict): + raise store.StoreError(400, "줄이 묶음 아님") + doc = patch.to_sangeun(line, name, unit, m01.loaded()[1]) + made = refs.create(sd.KIND, doc) + at = refs.here() + keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line} + return { + "이름": made["이름"], + "줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep}, + } diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index bbc516045..72d0a415e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,4 +1,5 @@ -"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`. +"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · +[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). @@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]: return calc.calc_doc(doc, body.입력값) +class FromLineBody(BaseModel): + 줄: dict[str, Any] + 이름: str + 단위: str + + +@router.post("/layers/{layer}/sangeun/from-line") +def sangeun_from_line( + layer: Layer, + body: FromLineBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, calc.from_line, body.줄, body.이름, body.단위) + + @router.post("/layers/{layer}/sangeun/calc") def calc_sangeun( layer: Layer, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 248695cfd..3688f2103 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None} def _save(folder, doc): with store.at(folder): - key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"] return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) @@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811 # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): - key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ - "이름" - ] + key = refs.create( + "precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats} + )["이름"] stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) return key, stone + _price(MAT) * 2 @@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F with store.at(world["system"]): outer = refs.create( "precombo", - {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + { + "이름": "겹", + "단위": "㎡", + "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], + }, )["이름"] got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index e0cfa1ebf..1a6a9a5c6 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch): company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): - other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py new file mode 100644 index 000000000..b6b4de67a --- /dev/null +++ b/resources/tester/test_715_3_labor_patch.py @@ -0,0 +1,135 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage. + +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · +줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +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 MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘 +COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}} + + +def _whole(): + return m01.loaded()[1] + + +def _sc(folder, lines, unit="㎡"): + with store.at(folder): + return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"] + + +def _value(folder, key): + with store.at(folder): + return refs.Expand(_whole())({**MINE, "키": key}) + + +def test_logic_line_cover_in_precombo(world): + """사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄).""" + bare = labor.run_line(_whole(), PCT, {}) + sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}]) + with store.at(world["personal"]): + kept = store.read("precombo", sc)["문서"]["담은로직"][0] + assert kept["패치"] == COVER + got = _value(world["personal"], sc)["기준"] + labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal( + str(bare["줄"][1]["금액"]) + ) + assert got["노무비"] == pytest.approx(labor_sum) + assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1) + + +def test_line_pick_by_order_stays_after_drop(world): + """줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + want = mp.search(whole, item["요소"], {}, item.get("단위"))[1] + one = { + "로직": TWO_MATS, + "고름": {"2": want}, + "자재표시": {"2": {"별도계상": "직접계산"}}, + "패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]}, + } + with store.at(world["personal"]): + doc = labor.clean({"담은로직": [one]}) + got = labor.calc(doc, {})["줄"][0] + picked = [x for x in got["줄"] if x.get("품목")] + assert [x["품목"]["키"] for x in picked] == [want] + + +def test_ref_line_cover_only_that_line(world): + """SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로.""" + with store.at(world["personal"]): + sk = refs.create( + "sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + before = store.read("sangeun", sk)["판"] + ref = {**MINE, "키": sk} + sc = _sc( + world["personal"], + [{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}], + ) + inner = _value(world["personal"], sc)["안"] + plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"] + covered = calc.calc_doc( + {"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER} + ) + assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"]))) + assert inner[0]["기준"]["계"] != plain["계"] + with store.at(world["personal"]): + assert store.read("sangeun", sk)["판"] == before + + +def test_save_as_sangeun(world): + """[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값.""" + one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"} + with store.at(world["personal"]): + line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"] + got = calc.from_line(one, "줄로 만든", "㎡") + doc = store.read("sangeun", got["이름"])["문서"] + value = calc.calc_doc(doc)["기준"] + assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"} + assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5 + assert value["계"] == pytest.approx(line_value["계"]) + + +def test_refusals(world): + with store.at(world["personal"]): + with pytest.raises(store.StoreError, match="단위가 빔"): + refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]}) + assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"] + bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}} + with pytest.raises(store.StoreError, match="참조 못 넣음"): + labor.clean({"담은로직": [bad]}) + with pytest.raises(store.StoreError, match="수가 아님"): + labor.clean( + {"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]} + ) + sc = _sc(world["personal"], [{"로직": PCT}]) + with pytest.raises(store.StoreError, match="줄 덮개는"): + labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]}) + + +def test_no_patch_same_value(world): + """덮개 없는 줄 = run_line 값 그대로(옛 셈 길).""" + sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}]) + with store.at(world["personal"]): + assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0] + assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx( + labor.run_line(_whole(), PCT, {})["기준"]["계"] + ) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 753028f25..05be42e2d 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,4 +1,5 @@ -"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · 순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. @@ -46,7 +47,7 @@ def _bare(code): def test_qty_cover_hand_and_pct_recomputed(world): - """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값.""" + """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈.""" bare = _bare(PCT) first, second = bare["줄"][0], bare["줄"][1] key = _sk( @@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world): with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]}, + { + "이름": "조합", + "단위": "㎡", + "담은로직": [{"참조": {**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) + assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 7f96f4ad3..144178dde 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra): def _combo(folder, lines, name="조합"): with store.at(folder): - return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] + return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"] def _read(folder, kind, key): @@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 one = got["줄"][0] want = Decimal(str(_base(TEN)["계"])) / 10 * 2 assert one["기준"]["계"] == pytest.approx(float(want)) - assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref)) + assert (one["분모"], one["배"], one["알약"]) == ( + "㎡", + 1, + refs.ref_id(ref), + ) # 단위 = 그 사전조합 것 assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) - assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] + assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}] def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): @@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world): } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" - body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}} + body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}} got = client.post(url.format("personal", "precombo"), json=body).json() assert got["이름"] == "SC-000001" assert client.post(url.format("system", "precombo"), json=body).status_code == 403