From 31874ea3a69070e7cce96036eb593034d9ea6298 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:29:39 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(m02):=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EB=8B=B4=EC=9D=80=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A4=84=20=ED=97=88=EC=9A=A9=20=E2=80=94=2051-11?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 = {참조, 수량, 메모} | {로직, 입력값, 메모, 수량} · 수량 기본 {손: 1} - 검사 = 일위대가 labor.clean 같은 길(모르는 로직 · 같은 로직 · 같은 참조 두 번 · 입력값 꼴) - 펼침 계산 = 로직 줄 run_line 기준 ÷ 배 × 수량 · 안 줄에 로직 줄 - 로직 지우기 검사에 사전조합 포함 · 굳힘 로직 목록에 사전조합 로직 포함 - 시험 test_m02_labor_ref 22 통과(섞인 사전조합 합 = 손셈 · 지우기 409) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M01_MasterData/M01_MasterData_Store_Cache.py | 10 +-- M02_MasterTemplete/M02_Labor_Ref.py | 77 ++++++++++++-------- resources/tester/test_m02_labor_ref.py | 50 ++++++++++++- 3 files changed, 102 insertions(+), 35 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index bd98cddc8..ac1be8ad8 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -222,20 +222,20 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l def _labor_files() -> list[tuple[str, Path]]: """모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json` - — (어디, 파일).""" + · 사전조합 `precombo/<키>.json`(51-11) — (어디, 파일).""" from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 return [ (f"{what} {where}", path) - for kind, what in (("labor", "일위대가"), ("promote", "단가승격")) + for kind, what in (("labor", "일위대가"), ("promote", "단가승격"), ("precombo", "사전조합")) for where, path in layers.layer_files(kind) ] def labor_errors(whole) -> list[str]: - """일위대가 · 단가승격 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — 로직 지우기를 막음 · - 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 `save` 가 고치기 전 검사와 - 견주어 뺌.""" + """일위대가 · 단가승격 · 사전조합 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — + 로직 지우기를 막음 · 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 + `save` 가 고치기 전 검사와 견주어 뺌.""" out = [] for where, path in _labor_files(): try: diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 5c5715cbd..2479ef3d8 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -5,8 +5,9 @@ `{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` — 줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때) precombo `{층}/precombo/SC-######.json` 사전조합 - `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}]}` — - 승격 · 사전조합만 · 수량은 손(기본 1) · 값 = Σ 안 줄 값 × 수량 + `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} + | {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) · + 수량은 손(기본 1) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) 참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) · 위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) · 같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭. @@ -162,27 +163,21 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: + """참조 줄 `{참조, 수량, 메모}` · 로직 줄 `{로직, 입력값, 메모, 수량}`(51-11) — 검사는 + 일위대가와 같은 길(`labor.clean` · 같은 로직 · 같은 참조 두 번 · 입력값) · 수량 기본 {손: 1}.""" from M02_MasterTemplete import M02_Labor_Doc as labor - at_, seen, lines = here(), set(), [] - for n, one in enumerate(doc.get("담은로직") or [], start=1): - at = f"사전조합 {n}째" - if not isinstance(one, dict) or one.get("로직") or one.get("참조") is None: - raise _bad(f"{at} · 사전조합은 단가승격 · 사전조합만 담음") - ref = check_ref(one["참조"], at, at_) - if ref_id(ref) in seen: - raise _bad(f"{at} · 같은 참조를 두 번 담음 「{ref['키']}」") - seen.add(ref_id(ref)) - memo = one.get("메모") - lines.append( - { - "참조": ref, - "수량": labor._qty(one.get("수량"), at) or {"손": 1}, - "메모": str(memo) if memo else None, - } - ) + lines = [] + for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: + head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} + if "로직" in one: + head["입력값"] = one["입력값"] + lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) + at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} for one in lines: + if "참조" not in one: + continue if ref_id(one["참조"]) == ref_id(me): raise _bad(f"순환 — 자기 자신을 담음 「{key}」", 422) _no_loop(one["참조"], [me, one["참조"]]) @@ -365,19 +360,44 @@ class Expand: ], } - def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]: + 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("입력값")) + 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)] + by_logic = { + code: pill + for pill in combo_store.combo_pills({"담은로직": lines})["물량"] + for code in pill["로직"] + } inner, rows, why = [], [], None - for one in doc.get("담은로직") or []: - ref = one.get("참조") if isinstance(one, dict) else None - if not isinstance(ref, dict): + 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["로직"])) + else: continue - sub = self(ref, trail) qty = labor._number((one.get("수량") or {}).get("손")) qty = Decimal(1) if qty is None else qty keep = ("이름", "단위", "기준", "까닭") - inner.append({"참조": ref, "수량": qty, **{k: sub.get(k) for k in 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 @@ -424,11 +444,10 @@ def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]: def frozen_logics(copies: dict[str, Any]) -> list[str]: - """굳힌 단가승격이 담은 로직 키 — 프로젝트 단가 묶음에 넣음.""" + """굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음.""" return [ str(line["로직"]) - for rid, doc in copies.items() - if kind_of(rid.rsplit("|", 1)[-1]) == "promote" - for line in (doc.get("담은로직") or [])[:1] + for doc in copies.values() + for line in doc.get("담은로직") or [] if isinstance(line, dict) and line.get("로직") ] diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index d2951479c..56f074b61 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -95,7 +95,9 @@ def test_사전조합_틀린_줄_400_순환_422(world): sp = _promote(world["system"]) one = {"참조": {**SYS, "키": sp}} for lines in ( - [{"로직": TEN}], # 로직은 못 담음(승격 · 사전조합만) + [{"로직": "GF999999"}], # 모르는 로직 + [{"로직": TEN}, {"로직": TEN}], # 같은 로직 두 번(51-11) + [{"로직": TEN, "입력값": [1]}], # 입력값 꼴 [one, one], # 같은 참조 두 번 [{"참조": {**SYS, "키": "SP-000009"}}], # 없는 참조 [{"참조": {**SYS, "키": "SU-000001"}}], # 서고 키 아님 @@ -342,6 +344,52 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): assert "기준금액" not in rows[2] +def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world): + from M01_MasterData import M01_MasterData_Store_Cache as cache + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + + sp = _promote(world["system"]) + lines = [ + {"참조": {**SYS, "키": sp}, "수량": {"손": 2}}, + {"로직": STONE, "입력값": {"없는칸": 1}, "수량": {"손": 3}, "메모": "돌"}, + {"로직": TEN}, + ] + sc = _combo(world["system"], lines, "섞음") + saved = _read(world["system"], "precombo", sc)["담은로직"] + assert saved[1:] == [ + {"로직": STONE, "입력값": {}, "수량": {"손": 3}, "메모": "돌"}, + {"로직": TEN, "입력값": {}, "수량": {"손": 1}, "메모": None}, + ] + ten, stone = (Decimal(str(_base(c)["계"])) for c in (TEN, STONE)) + want = ten / 10 * 2 + stone * 3 + ten / 10 * 1 # 손셈 — 승격 · TEN 은 원/10㎡(배 10) + ref = {**SYS, "키": sc} + with store.at(world["system"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) + one = labor.calc(doc, {})["줄"][0] + assert one["기준"]["계"] == pytest.approx(float(want)) + assert one["반영"]["계"] == pytest.approx(float(want * 5)) + assert [x.get("로직") or x["참조"]["키"] for x in one["안"]] == [sp, STONE, TEN] + with store.at(world["system"]), pytest.raises(store.StoreError) as e: + store.delete("promote", sp) + assert e.value.status == 409 and "사전조합 마스터 「섞음」" in e.value.detail + + class Gone: # STONE 을 지운 마스터 + def logic(self, code): + if code == STONE: + raise cache.cm.mf.FormulaError("없음") + + got = cache.labor_errors(Gone()) + assert f"사전조합 마스터 「{sc}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌" in got + rows = source.labor_doc_rows(doc) + assert sorted(refs.frozen_logics(rows["참조사본"])) == sorted([TEN, STONE, TEN]) + bundle = prices.to_json(prices.collect([TEN, STONE])) + for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값 + (world["system"] / refs.kind_of(key) / f"{key}.json").unlink() + got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] + assert got["계"] == pytest.approx(float(want), rel=1e-9) + + def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): sp = _promote(world["system"]) a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가")