"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄. 펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 · 프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage). """ import json from decimal import Decimal import pytest 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_Template_Layers as layers from resources.tester.test_m02_labor_calc_mirror import _same, _ts from resources.tester.test_m02_structure import write_sample_table TEN = "GC000421" # 원/10㎡ — 배 10 STONE = "GF000219" # 원/㎡ PLAIN = "GC000422" # 돈 아닌 로직 SYS = {"층": "system", "주인": None} COMPANY = {"층": "company", "주인": "7"} MINE = {"층": "personal", "주인": "7/42"} @pytest.fixture() def world(tmp_path, monkeypatch): from config import config_system system = tmp_path / "system" system.mkdir() write_sample_table(system) monkeypatch.setattr(store, "FOLDER", system) monkeypatch.setattr(layers, "SYSTEM_ROOT", system) (tmp_path / "storage").mkdir() monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) with store.at(system): store.create_structure("rv") return { "system": system, "company": layers.company_dir(7), "personal": layers.personal_dir(7, 42), } def _promote(folder, code=TEN, **extra): with store.at(folder): return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"] def _combo(folder, lines, name="조합"): with store.at(folder): return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] def _read(folder, kind, key): return json.loads((folder / kind / f"{key}.json").read_bytes()) def _base(code): import M01_MasterData.M01_MasterData_Store as m01 return labor.run_line(m01.loaded()[1], code, {})["기준"] # ── 자료 틀 ──────────────────────────────────────────────────────────── def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world): assert _promote(world["system"]) == "SP-000001" assert _promote(world["system"], STONE, 이름="돌") == "SP-000002" assert _promote(world["personal"]) == "SP-000001" # 층마다 셈 doc = _read(world["system"], "promote", "SP-000001") assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10) assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}] assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌" @pytest.mark.parametrize( "body", [ {"담은로직": []}, {"담은로직": [{"로직": TEN}, {"로직": STONE}]}, {"담은로직": [{"로직": "GF999999"}]}, {"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]}, ], ) def test_틀린_승격은_400(world, body): _promote(world["system"]) with store.at(world["system"]), pytest.raises(store.StoreError) as e: refs.create("promote", body) assert e.value.status == 400 def test_사전조합_틀린_줄_400_순환_422(world): sp = _promote(world["system"]) one = {"참조": {**SYS, "키": sp}} for lines in ( [{"로직": "GF999999"}], # 모르는 로직 [{"로직": TEN}, {"로직": TEN}], # 같은 로직 두 번(51-11) [{"로직": TEN, "입력값": [1]}], # 입력값 꼴 [one, one], # 같은 참조 두 번 [{"참조": {**SYS, "키": "SP-000009"}}], # 없는 참조 [{"참조": {**SYS, "키": "SU-000001"}}], # 서고 키 아님 ): with pytest.raises(store.StoreError) as e: _combo(world["system"], lines) assert e.value.status == 400 a = _combo(world["system"], [one], "가") b = _combo(world["system"], [{"참조": {**SYS, "키": a}}], "나") with store.at(world["system"]): have = store.read("precombo", a) with pytest.raises(store.StoreError) as e: store.write( "precombo", a, have["판"], {**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]}, ) assert e.value.status == 422 and f"{a} → {b} → {a}" in e.value.detail def test_층_규칙은_위_층만(world): _promote(world["system"]) _promote(world["company"]) _promote(world["personal"]) ok = [{"참조": {**s, "키": "SP-000001"}} for s in (SYS, COMPANY, MINE)] assert _combo(world["personal"], ok) == "SC-000001" # 개인 → 개인 · 회사 · 마스터 for folder, bad in ( (world["company"], MINE), # 회사 → 개인 X (world["system"], COMPANY), # 마스터 → 회사 X (world["personal"], {"층": "personal", "주인": "7/43"}), # 남의 개인 X (world["personal"], {"층": "company", "주인": "8"}), # 남의 회사 X ): with pytest.raises(store.StoreError) as e: _combo(folder, [{"참조": {**bad, "키": "SP-000001"}}]) assert e.value.status == 400 and "위 층만" in e.value.detail def test_일위대가_참조_줄_정리_같은_참조_두_번_400(world): sp = _promote(world["system"]) line = {"참조": {**SYS, "키": sp}, "별칭": " 승격 ", "수량": {"손": 3}} with store.at(world["system"]): got = labor.clean({"열": "rv", "담은로직": [line]})["담은로직"] assert got == [{"참조": {**SYS, "키": sp}, "별칭": "승격", "메모": None, "수량": {"손": 3}}] with pytest.raises(store.StoreError) as e: labor.clean({"열": "rv", "담은로직": [line, line]}) assert e.value.status == 400 # ── 펼침(계산) ───────────────────────────────────────────────────────── def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합은_수량_곱(world): sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}], "둘") ref = {**SYS, "키": sc} with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) got = labor.calc(doc, {}) 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["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "하나") with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": k}} for k in (sp, sc)]}) promote, combo = labor.calc(doc, {})["줄"] assert promote["원로직"] == TEN and promote["줄"] assert sum(x["금액"] for x in promote["줄"]) == pytest.approx( promote["기준"]["계"] ) # 1 단위(51-2) assert "줄" not in combo and [x["참조"]["키"] for x in combo["안"]] == [sp] def test_펼침은_순환_깊이_없는_참조를_까닭으로_멈춤(world): folder = world["system"] / "precombo" for key, child in (("SC-000001", "SC-000002"), ("SC-000002", "SC-000001")): body = {"이름": key, "담은로직": [{"참조": {**SYS, "키": child}}]} (folder / f"{key}.json").write_text(json.dumps(body), encoding="utf-8") import M01_MasterData.M01_MasterData_Store as m01 expand = refs.Expand(m01.loaded()[1]) got = expand({**SYS, "키": "SC-000001"}) assert got["기준"] is None and "순환" in got["까닭"] assert "없는 참조" in expand({**SYS, "키": "SC-000009"})["까닭"] deep = refs.Expand( m01.loaded()[1], lambda r: {"담은로직": [{"참조": {**SYS, "키": "SC-000001"}}]} ) assert deep.__call__({**SYS, "키": "SC-000001"}, ())["기준"] is None def test_돈_아닌_승격은_까닭(world): sp = _promote(world["system"], PLAIN) import M01_MasterData.M01_MasterData_Store as m01 got = refs.Expand(m01.loaded()[1])({**SYS, "키": sp}) assert got["기준"] is None and got["까닭"] def test_참조_줄_settle_파이썬과_TS_가_같은_답(world): sp = _promote(world["system"]) ref = {**SYS, "키": sp} with store.at(world["system"]): doc = labor.clean( {"열": "rv", "담은로직": [{"로직": STONE, "수량": None}, {"참조": ref, "수량": None}]} ) answer = labor.calc(doc, {}) values = {refs.ref_id(ref): 4} py = labor.settle(answer, doc, values) assert py["줄"][1]["수량출처"] == "칸" _same(py, _ts(answer, doc, values)) # ── 지우기 · 복사 · 굳힘 · 로직 지우기 ───────────────────────────────── def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world): sp = _promote(world["system"]) with store.at(world["personal"]): line = {"참조": {**SYS, "키": sp}} store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) with store.at(world["system"]), pytest.raises(store.StoreError) as e: store.delete("promote", sp) assert e.value.status == 409 and "개인 7/42" in e.value.detail (world["personal"] / "labor" / "x1.json").unlink() with store.at(world["system"]): store.delete("promote", sp) assert not (world["system"] / "promote" / f"{sp}.json").exists() def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): sp = _promote(world["personal"]) sys_sp = _promote(world["system"]) sc = _combo( world["personal"], [{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}], ) with store.at(world["personal"]): doc = store.read("precombo", sc)["문서"] with store.at(world["company"]): made = refs.create( "precombo", refs.carry({**doc, "본뜬": {**MINE, "키": sc}}, ("personal", "7/42")) ) got = _read(world["company"], "precombo", made["이름"]) assert got["본뜬"] == {**MINE, "키": sc} assert [one["참조"] for one in got["담은로직"]] == [ {**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키 {**SYS, "키": sys_sp}, # 마스터는 그대로 ] assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp} def test_길_새로_복사_권한(world): from fastapi import FastAPI from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Labor_Router as labor_router app = FastAPI() app.include_router(labor_router.router) app.dependency_overrides[verify_session] = lambda: { "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" body = {"문서": {"담은로직": [{"로직": TEN}]}} got = client.post(url.format("personal", "promote"), json=body).json() assert got["이름"] == "SP-000001" assert client.post(url.format("system", "promote"), json=body).status_code == 403 copy = {"본뜬": {**MINE, "키": "SP-000001"}} assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002" other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}} assert client.post(url.format("personal", "promote"), json=other).status_code == 403 def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world): from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Item_Source as source sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}]) ref = {**SYS, "키": sc} with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]}) live = labor.calc(doc, {})["줄"][0]["기준"]["계"] rows = source.labor_doc_rows(doc) assert set(rows["참조사본"]) == {refs.ref_id(ref), refs.ref_id({**SYS, "키": sp})} assert refs.frozen_logics(rows["참조사본"]) == [TEN] bundle = prices.to_json(prices.collect([TEN])) for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄 kind = refs.kind_of(key) (world["system"] / kind / f"{key}.json").unlink() got = prices.calc(rows, bundle, {}, {}, None) line = got["줄"][0] assert line["ok"] and line["참조"] == ref assert line["계"] == pytest.approx(live, rel=1e-9) assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9) def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world): from M01_MasterData import M01_MasterData_Store_Cache as cache sp = _promote(world["system"], STONE) with store.at(world["system"]): have = store.read("labor", "rv") line = {"참조": {**SYS, "키": sp}} store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]}) class Gone: # STONE 을 지운 마스터 def logic(self, code): if code == STONE: raise cache.cm.mf.FormulaError("없음") got = cache.labor_errors(Gone()) assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"] def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): _promote(world["system"]) got = layers.seed_project(tmp_path / "p1") assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"]) def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): sp = _promote(world["system"]) sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋") rows = [ {"종류": "promote", "이름": sp}, {"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}, ] refs.summaries(world["system"], rows) one = float(Decimal(str(_base(TEN)["계"])) / 10) assert rows[0]["기준금액"] == pytest.approx(one) assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) 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}}], "가") with store.at(world["system"]): have = store.read("precombo", a) assert have["문서"]["담은로직"][0]["수량"] == {"손": 1} with pytest.raises(store.StoreError) as e: store.write( "precombo", a, have["판"], { **have["문서"], "담은로직": [*have["문서"]["담은로직"], {"참조": {**SYS, "키": a}}], }, ) assert e.value.status == 422 and "자기 자신" in e.value.detail def test_서고_구분_상세구분_연결_승격은_원_로직_사전조합은_고른_값_옛_문서는_읽을_때(world): import M01_MasterData.M01_MasterData_Store as m01 row = m01.loaded()[1].logic(TEN) sp = _promote(world["system"], 구분="틀림", 상세구분="틀림", 연결={"로직": "x"}) doc = _read(world["system"], "promote", sp) assert row["구분"] and row["상세구분"] assert (doc["구분"], doc["상세구분"]) == (row["구분"], row["상세구분"]) # 화면 값 안 받음 assert doc["연결"] == {"로직": TEN, "출처": row.get("출처") or ""} sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}}]) assert (_read(world["system"], "precombo", sc)["구분"], "") == ("", "") with store.at(world["system"]): have = store.read("precombo", sc) store.write("precombo", sc, have["판"], {**have["문서"], "구분": " 가 ", "상세구분": "나"}) got = _read(world["system"], "precombo", sc) assert (got["구분"], got["상세구분"]) == ("가", "나") # 옛 문서(칸 없음) — 승격은 원 로직 것 · 사전조합은 빈 글 old = {k: v for k, v in doc.items() if k not in ("구분", "상세구분", "연결")} (world["system"] / "promote" / f"{sp}.json").write_text(json.dumps(old), encoding="utf-8") assert refs.filled("promote", old)["연결"] == doc["연결"] assert refs.filled("precombo", {"담은로직": []})["구분"] == "" rows = [{"종류": "promote", "이름": sp}, {"종류": "precombo", "이름": sc}] refs.summaries(world["system"], rows) assert (rows[0]["구분"], rows[0]["상세구분"]) == (row["구분"], row["상세구분"]) assert (rows[1]["구분"], rows[1]["상세구분"], rows[1]["연결"]) == ("가", "나", None)