코덱스·랩탑 서브가 푼 원문(건설 8-2 시공능력 · 8-4-9 해상기계 · 8장 손료 이름 · 작업효율 E · 산림 13-4-4 비고)을 받아 원천 벌·마스터를 다시 뽑음. 손으로 고친 값 없음. - 건설 표 2,270 → 2,272 · 줄만 밀린 표 517 · 글자 바뀐 표 31(원문 손질분) - 건설 공종 축 1,484 → 1,499 줄(공종 1,044 → 1,045) · 건설 갈래 576 → 588 · 밑수 미확보 437 → 439 - 산림은 그대로 — 갈래 337 · 형태 미판정 0 · 밑수 확보 251 - 못박은 수·이름표 줄 수·열쇠 지문 갱신 - ⭐ 금액 불변 — 직접공사비 5,983,724 · 합계 10,124,000 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
316 lines
18 KiB
Python
316 lines
18 KiB
Python
"""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"] == 624
|
||
) # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
|
||
# 2026-09-18 합침 620 → 624 — 원문이 풀리며 6-7-1 교목 시비 갈래 다섯이 새로 섬(사라진 것 0)
|
||
# 상자 이름은 이름표 것 — 영문 키(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"] == 624 and table["stats"] == {"work_items": 360, "units": 624}
|
||
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}) == 624
|
||
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.
|
||
갈래도 산림과 같은 표 읽기로 폄(④ · 공종 997 → 줄 1,293 · 갈래 열쇠 `CW-00063#01`)."""
|
||
table = _get(client, "work_item_const", size=500, axis_role="")
|
||
# 2026-09-18 997/1,293 그대로 — 앞서 991/1,287 로 보였던 것은 **산출 파일이 옛 것**이어서였다
|
||
# (기계 다리가 선 뒤 다시 지으니 제자리로 돌아옴).
|
||
# 2026-09-18 랩탑 메인 — 건설 표 모양 판정층으로 미판정 891 → 276 이 되며 품 표가 늘어 줄도 늘었다.
|
||
# 2026-09-18 합침 — 다른 창이 원문 md 의 뭉친 줄·표를 더 풀어(건설 표 2,202 → 2,270) 공종·줄이 또 늘었다.
|
||
# 값이 바뀐 것이 아니라 **원문에 있던 표가 더 읽힌 것**이다(직접공사비 5,983,724 그대로).
|
||
assert table["stats"] == {"work_items": 1045, "units": 1499} and table["total"] == 1499
|
||
assert sum(1 for r in table["rows"] if "#" in r["@id"]) > 0
|
||
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": ["갑", "을"],
|
||
"variants": [{"variant_key": "01", "name": "갑", "name_clean": "갑"},
|
||
{"variant_key": "02", "name": "을", "name_clean": "을"}]},
|
||
],
|
||
} # 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")
|
||
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
|
||
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
|
||
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%2301",
|
||
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
|
||
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
|
||
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 = [
|
||
row
|
||
for page in range(1, every["total"] // 500 + 2)
|
||
for row in _get(client, kind, size=500, page=page, 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#02" and rows[1]["@key"] == "FW-00105"
|
||
assert rows[1]["variant"] == "1.1∼1.5" # 글자는 보일 이름 칸으로
|
||
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
|
||
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"])
|
||
)
|
||
|
||
|
||
def test_찾기는_물결표_가운뎃점_띄어쓰기를_같게_봄(client: TestClient) -> None:
|
||
"""원문이 물결표를 둘(∼·~)로 쓰고 자간 공백이 들쭉날쭉 — 사람이 친 「~」·붙여 쓴 이름으로도 찾힘.
|
||
⚠ 찾기만 — 줄 값·열쇠는 원문 그대로(서브 훑기 · 브레인 2026-09-17)."""
|
||
wavy = _get(client, "work_item_forest", size=50, q="2.1∼2.5")["total"]
|
||
assert wavy >= 1
|
||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||
rows = _get(client, "work_item_forest", size=50, q="2.1~2.5")["rows"]
|
||
assert all("~" not in r["@id"] for r in rows) # 열쇠는 손질 안 함
|
||
spaced = _get(client, "work_item_forest", size=50, q="토사 깍기", axis_role="")["total"]
|
||
assert (
|
||
spaced == _get(client, "work_item_forest", size=50, q="토사깍기", axis_role="")["total"] > 0
|
||
)
|
||
|
||
|
||
def test_이름_다듬기는_보일_이름만_열쇠는_그대로(
|
||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""⭐ 갈래 불변 열쇠를 세운 까닭(브레인 2026-09-17) — 자간 공백(「측 량」·「평 규 준 틀」)을 다듬어도
|
||
줄 열쇠(`FW-…#01`)가 한 글자도 안 흔들림. 다듬기를 끈 목록과 켠 목록의 열쇠를 맞댐."""
|
||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Keys as keys
|
||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||
|
||
for kind in work_items.KINDS:
|
||
tidy = work_items.base_rows(kind)
|
||
with monkeypatch.context() as patch:
|
||
patch.setattr(keys, "tidy_spacing", lambda text: " ".join(str(text or "").split()))
|
||
raw = work_items.base_rows(kind)
|
||
assert [r["@id"] for r in tidy] == [r["@id"] for r in raw]
|
||
if kind == "work_item_const":
|
||
assert (
|
||
sum(a["name"] != b["name"] for a, b in zip(tidy, raw)) > 0
|
||
) # 실제로 다듬어진 줄이 있음
|
||
shown = _get(client, "work_item_const", size=50, q="기준점 측량", axis_role="")["rows"]
|
||
assert shown and all("측 량" not in r["name"] and "› 측량 ›" in r["name"] for r in shown)
|
||
|
||
|
||
def test_품셈_번호는_마디마다_수로_세움(client: TestClient) -> None:
|
||
"""글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기) — 마디마다 수로."""
|
||
rows = _get(client, "work_item_forest", size=500, sort="number", axis_role="")["rows"]
|
||
numbers = [tuple(int(x) for x in r["number"].split("-")) for r in rows if "-" in r["number"]]
|
||
assert len(numbers) > 300 and numbers == sorted(numbers)
|
||
|
||
|
||
def test_건설_늘_빈_칸은_감춤(client: TestClient) -> None:
|
||
"""건설은 합산형 선언이 없어 「단계 무게」가 늘 빈칸 — 감춤(산림은 보임)."""
|
||
hidden = lambda kind: {c["key"]: c["hidden"] for c in _get(client, kind, size=1)["columns"]} # noqa: E731
|
||
assert hidden("work_item_const")["step_weight"] is True
|
||
assert hidden("work_item_forest")["step_weight"] is False
|