"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4). 못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) · 불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘. """ from __future__ import annotations import json from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Router as router_module from Z01_MasterData import Z01_MasterData_WorkItems as work_items @pytest.fixture def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: from common_util.common_util_auth import verify_session monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override") app = FastAPI() app.include_router(router_module.router) app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"} return TestClient(app) def _get(client: TestClient, kind: str, **params) -> dict: res = client.get(f"/api/master-data/base-prices/{kind}", params=params) assert res.status_code == 200, res.text return res.json() def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None: listed = client.get("/api/master-data/base-prices").json()["kinds"] forest = next(i for i in listed if i["kind"] == "work_item_forest") assert forest["group"] == "work_item" and forest["rows"] == 620 # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17) # 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①) assert {i["group"]: i["group_label"] for i in listed} == { "base_price": "기초단가", "pumsem_basis": "품셈 기준", "work_item": "공종 축", } table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지 assert table["total"] == 620 and table["stats"] == {"work_items": 360, "units": 620} assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"]) rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"] assert len({r["@id"] for r in rows}) == 620 assert ( len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360 ) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1) by_code = {r["work_item_code"]: r for r in rows} assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계" modes = [r["mode"] for r in table["rules"]] assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3 blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05") assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"] # 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②) assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함" assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름" assert by_code["FP-09-05-02"]["step_weight"] == "0.9" # 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거. assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515) assert not any("불변 열쇠" in line for line in table["notice"]) found = _get(client, "work_item_forest", q="발파암 ›") assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토 def test_건설_공종_축도_같은_길(client: TestClient) -> None: """건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.""" table = _get(client, "work_item_const", size=500, axis_role="") assert table["total"] == table["stats"]["work_items"] > 900 rows = table["rows"] assert all(r["@id"].startswith("CW-") for r in rows) assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows) assert {r["mode"] for r in table["rules"]} == {"choose_one"} assert not any("불변 열쇠" in line for line in table["notice"]) found = _get(client, "work_item_const", q="굴착(인력/토사)") assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"] def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: folder = tmp_path / "data_work_item_master" folder.mkdir() fake = { "dataset_version": {"dataset_id": "pum_const"}, "effective_date": "2026-01-01", "work_items": [ {"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1", "name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps", "steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []}, {"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1", "name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}], "variant_keys": ["갑", "을"]}, ], } # fmt: skip (folder / "const_work_item_master_2026-01-01.json").write_text( json.dumps(fake, ensure_ascii=False), encoding="utf-8" ) monkeypatch.setattr(work_items, "FOLDER", folder) table = _get(client, "work_item_const") assert [r["@id"] for r in table["rows"]] == ["CW-00002#갑", "CW-00002#을"] # 갈래마다 한 줄 assert {r["@key"] for r in table["rows"]} == {"CW-00002"} assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음 assert table["rows"][0]["name"] == "토공 › 인력" assert table["stats"] == {"work_items": 1, "units": 2} assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}] assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음 res = client.put( "/api/master-data/base-prices/work_item_const/CW-00002%23갑", json={"values": {"name": "바꿈"}}, ) assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"] def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None: """화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳. 건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표).""" const = _get(client, "work_item_const", size=1, axis_role="") by_key = {f["key"]: f for f in const["filters"]} assert set(by_key) == {"link", "division", "axis_role"} division = by_key["division"] assert division["default"] == "" assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]} assert [o["value"] for o in division["options"][1:]] == [ "공통부문", "토목부문", "건축부문", "기계설비부문", "유지관리부문", ] assert sum(o["rows"] for o in division["options"][1:]) == const["total"] # 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`). assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[ "general_provision" ] == "공종 아님 · 규정·기준" assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아님 civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="") assert civil["total"] == next( o["rows"] for o in division["options"] if o["value"] == "토목부문" ) assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"]) rules = _get( client, "work_item_const", size=500, division="공통부문", axis_role="general_provision" ) assert rules["total"] > 0 assert all(r["name"].startswith("공통부문 › 적용기준") for r in rules["rows"]) role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]} # 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳 assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]} assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [ "link", "axis_role", ] assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름 def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None: """브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄. 기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음).""" from Z01_MasterData import Z01_MasterData_WorkItems as work_items link_doc = json.loads( sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8") ) confirmed = { key for link in link_doc["links"] if link["status"] == "확정" for key in (link["forest_key"], link["const_key"]) if key } unconfirmed = { key for link in link_doc["links"] if link["status"] != "확정" for key in (link["forest_key"], link["const_key"]) if key } - confirmed for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")): every = _get(client, kind, size=500, axis_role="") rows = every["rows"] + _get(client, kind, size=500, page=2, axis_role="")["rows"] assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임 marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로 listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖 assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed} assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상 assert not marked & unconfirmed only = _get(client, kind, size=500, link="연결됨", axis_role="") assert {r["@key"] for r in only["rows"]} == marked box = next(f for f in every["filters"] if f["key"] == "link") assert box["default"] == "" and box["options"][1]["rows"] == only["total"] def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None: """2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름.""" rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"] assert [r["variant"] for r in rows] == [ "1.0 이하", "1.1∼1.5", "1.6∼2.0", "2.1∼2.5", "2.6∼3.0", "3.1∼3.5", "3.6∼4.0", "4.1∼4.5", "4.6∼5.0", ] # fmt: skip assert rows[1]["@id"] == "FW-00105#1.1∼1.5" and rows[1]["@key"] == "FW-00105" # 갈래 없는 공종은 한 줄 · 열쇠에 # 없음 plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"] assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)] # 갈래 이름으로도 찾힘 assert _get(client, "work_item_forest", q="2.1∼2.5")["total"] >= 1 def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None: """2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임.""" for kind in ("work_item_forest", "work_item_const"): shown = _get(client, kind, size=500) role = next(f for f in shown["filters"] if f["key"] == "axis_role") assert role["default"] == "work_item" counts = {o["value"]: o["rows"] for o in role["options"]} assert counts["general_provision"] > 0 # 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤) assert shown["total"] == counts["work_item"] assert ( _get(client, kind, size=500, q="적용기준")["total"] < _get(client, kind, size=500, q="적용기준", axis_role="")["total"] ) # 「전부」(빈 값을 보냄) → 총칙까지 assert _get(client, kind, size=1, axis_role="")["total"] == counts[""] assert ( _get(client, kind, size=1, axis_role="general_provision")["total"] == (counts["general_provision"]) )