"""일위대가 사전조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 사전조합 · 참조 줄 · 옛 단가승격(134-2). 펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 · 프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage). 옛 단가승격(SP) = 새로 못 만듦 · 남은 파일은 읽기 · 셈 · 굳힘만(`_promote` = 옛 파일을 바로 씀) · 층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + 승격(`unpromote` · 같은 로직이 있으면 그대로). """ 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, values=None, **extra): """옛 단가승격 파일(134-2 앞 꼴) — 층마다 다음 번호 · 배 = 로직 물량 알약 배.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store line = {"로직": code, "입력값": values or {}, **extra} pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0] folder = folder / "promote" folder.mkdir(parents=True, exist_ok=True) key = f"SP-{len(list(folder.glob('SP-*.json'))) + 1:06d}" doc = { "종류": "단가승격", "키": key, "이름": pill.get("이름") or code, "단위": pill.get("단위"), "배": pill.get("배") or 1, "담은로직": [line], "본뜬": None, } (folder / f"{key}.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") return key 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" not in layers.KINDS and layers.LIBRARY_KINDS == ("precombo",) with store.at(world["system"]), pytest.raises(KeyError): refs.create("promote", {"담은로직": [{"로직": TEN}]}) # ── 옛 단가승격 참조 줄 = 읽을 때 새 꼴(134-2) ─────────────────────────── def test_옛_참조_줄은_로직_줄_승격으로_보이고_값_같음(world): sp = _promote(world["system"], TEN) old = { "열": "rv", "담은로직": [ {"참조": {**SYS, "키": sp}, "별칭": "승", "메모": "m", "수량": {"손": 4}}, {"로직": STONE, "수량": {"손": 1}}, ], "자재": [], } got = refs.unpromote("labor", old) assert got["담은로직"][0] == { "로직": TEN, "별칭": "승", "메모": "m", "입력값": {}, "수량": {"손": 4}, "승격": True, } assert old["담은로직"][0]["참조"]["키"] == sp # 받은 문서는 그대로 held = _promote(world["system"], PLAIN, {"규격": "NX"}, 켜쌓기율=90) # 입력값 · 율도 옮김 one = refs.unpromote("labor", {"담은로직": [{"참조": {**SYS, "키": held}}]})["담은로직"][0] assert (one["입력값"], one["켜쌓기율"]) == ({"규격": "NX"}, 90) with store.at(world["system"]): before = labor.calc(labor.clean(old), {})["반영합"]["계"] after = labor.clean(got) assert after["담은로직"][0]["승격"] is True # [저장] 때 새 꼴로 굳음 assert labor.calc(after, {})["반영합"]["계"] == pytest.approx(before, rel=1e-9) def test_옛_참조_줄은_같은_로직이_있으면_그대로_사전조합은_승격_없이(world): sp = _promote(world["system"], STONE) lines = [{"로직": STONE, "수량": None}, {"참조": {**SYS, "키": sp}, "수량": None}] doc = {"열": "rv", "담은로직": lines, "자재": []} assert refs.unpromote("labor", doc) is doc # 겹침 = 옛 참조 줄 그대로(저장 됨) with store.at(world["system"]): assert labor.clean(doc)["담은로직"][1]["참조"]["키"] == sp combo = {"담은로직": [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}, "메모": None}]} one = refs.unpromote("precombo", combo)["담은로직"][0] assert one["로직"] == STONE and one["수량"] == {"손": 2} and "승격" not in one assert refs.unpromote("area", combo) is combo # 다른 종류는 안 봄 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_MasterTemplete_Router_Layers as layer_router sp = _promote(world["system"]) with store.at(world["personal"]): line = {"참조": {**SYS, "키": sp}, "수량": {"손": 2}} store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) app = FastAPI() app.include_router(layer_router.router) app.dependency_overrides[verify_session] = lambda: { "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, } got = TestClient(app).get("/api/m02/layers/personal/templates/labor/x1").json() assert got["문서"]["담은로직"][0]["로직"] == TEN and got["문서"]["담은로직"][0]["승격"] is True assert _read(world["personal"], "labor", "x1")["담은로직"][0]["참조"]["키"] == sp 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): sc = _combo(world["system"], [{"로직": TEN}]) with store.at(world["personal"]): line = {"참조": {**SYS, "키": sc}} store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) with store.at(world["system"]), pytest.raises(store.StoreError) as e: store.delete("precombo", sc) 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("precombo", sc) assert not (world["system"] / "precombo" / f"{sc}.json").exists() def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): inner = _combo(world["personal"], [{"로직": TEN}], "안") sys_sp = _promote(world["system"]) sc = _combo( world["personal"], [{"참조": {**MINE, "키": inner}}, {"참조": {**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, "키": "SC-000001"}, # 회사가 못 읽는 개인 사전조합 → 회사 층 새 키 {**SYS, "키": sys_sp}, # 마스터는 그대로(옛 승격도) ] assert _read(world["company"], "precombo", "SC-000001")["본뜬"] == {**MINE, "키": inner} # 못 읽는 옛 개인 승격은 따라 복사 안 함(134-2 · 승격 서고 없음) mine_sp = _promote(world["personal"]) with store.at(world["company"]), pytest.raises(store.StoreError) as e: refs.carry({"담은로직": [{"참조": {**MINE, "키": mine_sp}}]}, ("personal", "7/42")) assert e.value.status == 400 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", "precombo"), json=body).json() assert got["이름"] == "SC-000001" assert client.post(url.format("system", "precombo"), json=body).status_code == 403 copy = {"본뜬": {**MINE, "키": "SC-000001"}} assert client.post(url.format("personal", "precombo"), json=copy).json()["이름"] == "SC-000002" other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SC-000001"}} assert client.post(url.format("personal", "precombo"), json=other).status_code == 403 assert client.post(url.format("personal", "promote"), json=body).status_code == 422 # 134-2 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 not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1) 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("없음") assert cache.labor_errors(Gone()) == [] # 옛 승격 서고는 안 봄(134-2) · 참조 줄 건너뜀 with store.at(world["system"]): have = store.read("labor", "rv") line = {"로직": STONE, "승격": True} store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]}) number = store.read("labor", "rv")["문서"]["일위대가번"] got = cache.labor_errors(Gone()) assert got == [f"일위대가 마스터 「{number}」 이 로직 「{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 = [{"종류": "precombo", "이름": sc}, {"종류": "labor", "이름": "rv"}] refs.summaries(world["system"], rows) one = float(Decimal(str(_base(TEN)["계"])) / 10) assert (rows[0]["제목"], rows[0]["기준금액"]) == ("셋", pytest.approx(one * 3)) assert "기준금액" not in rows[1] 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] 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): sp = _promote(world["system"]) 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 = _read(world["system"], "promote", sp) assert refs.filled("promote", old) is old assert refs.filled("precombo", {"담은로직": []})["구분"] == "" rows = [{"종류": "precombo", "이름": sc}] refs.summaries(world["system"], rows) assert (rows[0]["구분"], rows[0]["상세구분"], rows[0]["연결"]) == ("가", "나", None) def test_사전조합_굳힘은_안_로직_알약도_사본_값_142_2(world, monkeypatch): """프로젝트 참조사본 사전조합 = 가져온 때 알약(배 10) — 그 뒤 M01 이 바뀌어도 사본 셈 그대로 · 층 문서(알약 없음)는 지금 M01 을 읽음.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Item_Source as source sc = _combo(world["system"], [{"로직": TEN}], "열배") ref = {**SYS, "키": sc} with store.at(world["system"]): doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]}) rows = source.labor_doc_rows(doc) kept = rows["참조사본"][refs.ref_id(ref)]["알약"] assert [(p["로직"], p.get("배")) for p in kept] == [([TEN], 10)] assert "알약" not in _read(world["system"], "precombo", sc) # 층 문서는 그대로 bundle = prices.to_json(prices.collect([TEN])) before = prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] changed = {"물량": [{"키": "x|㎡", "이름": "x", "단위": "㎡", "로직": [TEN]}]} # 배 1 로 바뀜 monkeypatch.setattr(combo_store, "combo_pills", lambda row: changed) assert prices.calc(rows, bundle, {}, {}, None)["줄"][0]["기준"]["계"] == pytest.approx(before) with store.at(world["system"]): live = labor.calc(doc, {})["줄"][0]["기준"]["계"] assert live == pytest.approx(before * 10) # 층 셈은 지금 M01(배 1)