"""M01 마스터 데이터 API — 계약 `resources/master_data/_화면_계약.md`. 임시 폴더에 master_data 첫 층 JSON 사본을 두고 읽기 · 시험 계산 · 저장 · 409 · 422. """ from __future__ import annotations import json import shutil from decimal import Decimal from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store REAL = store.FOLDER LABOR = "인력_건설노임.json" LOGIC_BOOK, LOGIC_KEY = ( "건설품셈", "3-3-1 암발파(미진동굴착 TYPE-Ⅰ)", ) # 화약취급공 1016 · 보통인부 1002 @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: for path in REAL.glob("*.json"): if not path.name.startswith("_"): shutil.copy(path, tmp_path / path.name) monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) return TestClient(app) def _get(client: TestClient, url: str, **params) -> dict: res = client.get(url, params=params) assert res.status_code == 200, res.text return res.json() def _calc(client: TestClient, **inputs) -> dict: res = client.post( "/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": inputs} ) assert res.status_code == 200, res.text return res.json() def _labor_row(client: TestClient, key: str) -> tuple[dict, str]: got = _get(client, "/api/m01/rows", file=LABOR, q="", size=500) return next(r for r in got["rows"] if r["열쇠"] == key), got["version"] def test_그룹_파일_줄_쪽나눔_찾기(client: TestClient) -> None: groups = {g["group"]: g for g in _get(client, "/api/m01/groups")["groups"]} assert list(groups) == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"] files = _get(client, "/api/m01/groups/인력/files")["files"] assert LABOR in [f["file"] for f in files] assert groups["인력"]["files"] == len(files) page2 = _get(client, "/api/m01/rows", file=LABOR, page=2, size=10) assert page2["page"] == 2 and len(page2["rows"]) == 10 and page2["total"] > 20 hit = _get(client, "/api/m01/rows", file=LABOR, q="보통인부") assert [r["열쇠"] for r in hit["rows"]] == ["1002"] assert client.get("/api/m01/rows", params={"file": "없음.json"}).status_code == 404 assert client.get("/api/m01/rows", params={"file": "../main.py"}).status_code == 404 def test_표형_요소_표_목록과_표_하나(client: TestClient) -> None: file = "소요량_건설품셈_공통3장.json" listed = _get(client, "/api/m01/tables", file=file, q="암발파") one = next(t for t in listed["tables"] if t["열쇠"] == LOGIC_KEY) assert one["count"] >= 1 and "줄" not in one table = _get(client, "/api/m01/table", file=file, key=LOGIC_KEY)["table"] assert len(table["줄"]) == one["count"] and table["값칸"] == one["값칸"] assert client.get("/api/m01/tables", params={"file": LABOR}).status_code == 400 def test_로직_목록_하나_시험계산(client: TestClient) -> None: listed = _get(client, "/api/m01/logics", book=LOGIC_BOOK, chapter="공통3장", q="암발파")[ "logics" ] assert LOGIC_KEY in [x["열쇠"] for x in listed] assert all(x["chapter"] == "공통3장" and not x["blocked"] for x in listed) assert _get(client, "/api/m01/logics", blocked=1)["logics"] == [] one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY) assert one["file"] == "로직_건설품셈_공통3장.json" and one["blocked"] is False got = _calc(client) assert got["ok"] is True lines = {x["이름"]: x for x in got["lines"]} assert Decimal(str(lines["화약취급공"]["금액"])) == Decimal( str(lines["화약취급공"]["수량"]) ) * Decimal(str(lines["화약취급공"]["단가"])) assert got["sums"]["계"] == pytest.approx(sum(x["금액"] for x in got["lines"])) stop = _calc(client, 없는입력=1) assert stop["ok"] is False and "모르는 입력" in stop["reason"] def test_저장_고침은_파일에_쓰고_낡은_판본은_409(client: TestClient) -> None: row, version = _labor_row(client, "1016") before = _calc(client)["sums"]["계"] edited = {**row, "값": row["값"] + 1000} body = { "files": [ { "file": LABOR, "version": version, "changes": [{"op": "edit", "key": "1016", "row": edited}], } ] } res = client.post("/api/m01/save", json=body) assert res.status_code == 200, res.text new_version = res.json()["files"][0]["version"] assert new_version != version assert _labor_row(client, "1016") == (edited, new_version) assert _calc(client)["sums"]["계"] == pytest.approx(before + 1000 * 0.04) # 화약취급공 0.040 인 data = json.loads((store.FOLDER / LABOR).read_text(encoding="utf-8")) assert ( data["그룹"] == "인력" and len(data["줄"]) == _get(client, "/api/m01/rows", file=LABOR)["total"] ) again = client.post("/api/m01/save", json=body) # 옛 판본 그대로 assert again.status_code == 409 and again.json()["detail"]["stale"] == [LABOR] def test_저장_검사에_걸리면_아무것도_안_씀(client: TestClient) -> None: _, version = _labor_row(client, "1016") raw = (store.FOLDER / LABOR).read_bytes() gone = {"file": LABOR, "version": version, "changes": [{"op": "delete", "key": "1016"}]} res = client.post("/api/m01/save", json={"files": [gone]}) assert res.status_code == 422 and any("1016" in x for x in res.json()["detail"]["errors"]) bad_form = {**_labor_row(client, "1002")[0], "열쇠": "9999"} del bad_form["출처"] res = client.post( "/api/m01/save", json={"files": [{**gone, "changes": [{"op": "add", "row": bad_form}]}]} ) assert res.status_code == 422 and any("출처" in x for x in res.json()["detail"]["errors"]) dup = {**gone, "changes": [{"op": "add", "row": _labor_row(client, "1002")[0]}]} assert client.post("/api/m01/save", json={"files": [dup]}).status_code == 400 assert (store.FOLDER / LABOR).read_bytes() == raw def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None: one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY) copy = {**one["logic"], "열쇠": LOGIC_KEY + " 사본"} add = {"file": one["file"], "version": one["version"], "changes": [{"op": "add", "row": copy}]} res = client.post("/api/m01/save", json={"files": [add]}) assert res.status_code == 200, res.text assert _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=copy["열쇠"])["blocked"] is False drop = { "file": one["file"], "version": res.json()["files"][0]["version"], "changes": [{"op": "delete", "key": copy["열쇠"]}], } assert client.post("/api/m01/save", json={"files": [drop]}).status_code == 200 assert ( client.get("/api/m01/logic", params={"book": LOGIC_BOOK, "key": copy["열쇠"]}).status_code == 404 ) def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None: one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY) labor = one["prices"]["인력:건설노임:1016"] assert labor["이름"] == "화약취급공" and labor["값"] > 0 found = _get(client, "/api/m01/elements", group="인력", q="보통인부") assert "인력:건설노임:1002" in [x["ref"] for x in found["items"]] tables = _get(client, "/api/m01/elements", group="소요량", q="암발파", limit=5) assert tables["items"] and all(x["값칸"] for x in tables["items"]) raw = (store.FOLDER / one["file"]).read_bytes() before = _calc(client)["sums"]["계"] half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌 res = client.post( "/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {}, "row": half} ) lines = res.json()["lines"] assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"]["계"] < before new = {**half, "열쇠": "새 로직"} body = {"book": LOGIC_BOOK, "key": "새 로직", "inputs": {}, "row": new, "file": one["file"]} assert client.post("/api/m01/calc", json=body).json()["ok"] is True assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀 def test_쓰기_모양은_읽은_값과_같음() -> None: for name in ("소요량_건설품셈_기계설비13장.json", "로직_건설품셈_토목2장.json", LABOR): data = json.loads((REAL / name).read_text(encoding="utf-8"), parse_float=Decimal) text = store.dump(data) assert json.loads(text, parse_float=Decimal) == data assert "\r" not in text and text.endswith("}\n") def test_main_은_시스템관리자만() -> None: main = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main