Files
Aislo/resources/tester/test_m02_labor_ref.py
T
eomsangdonandClaude Opus 5.5 0b11333942 feat(M02): 단가승격 · 사전조합 문서 구분 · 상세구분 · 연결 칸 — 51-12 서버
- 단가승격 = 저장 때 원 로직의 구분 · 상세구분 · 연결 {로직 키, 출처 글} 적음(화면 값 안 받음)
- 사전조합 = 화면이 고른 구분 · 상세구분 저장(빈 글 허용 · 처음 값은 화면 몫)
- 서고 목록 길 · 고르기 길(층 양식 목록) 줄에 구분 · 상세구분 · 연결 실음 · 양식 읽기 길도
- 옛 문서(칸 없음) = 읽을 때 채움 — 승격은 원 로직 것 · 사전조합은 빈 글
- 시험 test_m02_labor_ref 23 통과 · 층 읽기 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 20:48:54 +09:00

437 lines
20 KiB
Python

"""일위대가 조합 서고(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)