Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
177 lines
8.9 KiB
Python
177 lines
8.9 KiB
Python
"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1 을 기초단가와 **같은 길**로(2026-09-16 사용자 지시 · 브레인).
|
|
|
|
잣대(사용자): 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.**
|
|
태움 — coef(토량환산) · material_surcharge · formwork_reuse · rebar_complexity · timber_structure_class ·
|
|
masonry_slope · masonry_back_length · stone_kind · masonry_class
|
|
안 태움 — 이음표(type_map·form_map·bond.codes) · 품셈 원문 표(coef surcharge_*) · 글·방침 ·
|
|
structure_unit_observed(울진 한 현장 관찰값) · revetment_sabang(교본값 + 글 섞임) — 트리엔 남되 고치기 없음
|
|
⭐ coef 원문 중복 — 「암괴…점토」 두 줄은 이름·값이 모두 같아 **한 줄로 합치고 사유**(브레인 ㉮) ·
|
|
⚠ 합친 줄인데 값이 갈리면 **빨강**(품셈 개정으로 두 줄이 달라지는 날 조용히 지나가면 안 됨).
|
|
"""
|
|
|
|
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_BasePrices as base_prices
|
|
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
|
from Z01_MasterData import Z01_MasterData_Router as router_module
|
|
|
|
NEW_KINDS = (
|
|
"coef",
|
|
"material_surcharge",
|
|
"formwork_reuse",
|
|
"rebar_complexity",
|
|
"timber_structure_class",
|
|
"masonry_slope",
|
|
"masonry_back_length",
|
|
"stone_kind",
|
|
"masonry_class",
|
|
)
|
|
|
|
|
|
@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 _ids(table: dict) -> list[str]:
|
|
return [r["@id"] for r in table["rows"]]
|
|
|
|
|
|
def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None:
|
|
assert base_prices.KINDS == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS
|
|
for kind in NEW_KINDS:
|
|
table = _get(client, kind, size=500)
|
|
assert table["total"] == len(table["rows"]) > 0, kind
|
|
ids = _ids(table)
|
|
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음
|
|
assert table["editable"] and set(table["editable"]) <= {
|
|
c["key"] for c in table["columns"]
|
|
}, kind
|
|
assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind
|
|
axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())}
|
|
assert {table["locked"][c] for c in axis if c in table["locked"]} == {
|
|
"자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음"
|
|
}, kind # 축 칸은 「왜 못 고치나」 가 원문 칸과 달라야 함
|
|
assert any("새로 만드는 프로젝트" in line for line in table["notice"]), kind
|
|
assert table["source"] == [], kind # 출처표엔 아직 이 아홉이 없음 — 빈 목록(모양은 그대로)
|
|
|
|
|
|
def test_값표만_태우고_이음표는_안_태움(client: TestClient) -> None:
|
|
formwork = " ".join(_ids(_get(client, "formwork_reuse", size=500)))
|
|
assert "type_map" not in formwork and "euroform_type/classes" in formwork
|
|
rebar = " ".join(_ids(_get(client, "rebar_complexity", size=500)))
|
|
assert "form_map" not in rebar and "classes/" in rebar
|
|
timber = " ".join(_ids(_get(client, "timber_structure_class", size=500)))
|
|
assert "type_map" not in timber
|
|
coef = " ".join(_ids(_get(client, "coef", size=500)))
|
|
assert "surcharge" not in coef and "rate_tool" not in coef # 품셈 원문 표·낱값은 여기 아님
|
|
masonry = " ".join(_ids(_get(client, "masonry_class", size=500)))
|
|
assert "bond" not in masonry # 공종코드 잇는 표
|
|
for gone in ("structure_unit_observed", "revetment_sabang"):
|
|
assert client.get(f"/api/master-data/base-prices/{gone}").status_code == 404
|
|
|
|
|
|
def test_원문_중복_두_줄은_한_줄로_사유와_함께(client: TestClient) -> None:
|
|
rows = {r["@id"]: r for r in _get(client, "coef", size=500)["rows"]}
|
|
# 이름 글자가 다른 쌍은 그대로 둘 줄(값도 다름)
|
|
assert rows["coef_soil_L/역(礫)이 섞인 점질토"]["min"] == 1.35
|
|
assert rows["coef_soil_L/역이 섞인 점질토"]["min"] == 1.3
|
|
merged = rows["coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토"]
|
|
assert (merged["min"], merged["max"]) == (1.4, 1.45)
|
|
assert merged["source_rows"] == 2 and "원문 두 줄" in merged["duplicate_note"]
|
|
assert [i for i in rows if i.startswith("coef_soil_L/암괴(岩塊)")] == [
|
|
"coef_soil_L/암괴(岩塊)나 호박돌이 섞인 점토"
|
|
] # 두 줄이 한 줄로
|
|
|
|
|
|
def test_합친_줄인데_값이_갈리면_빨강() -> None:
|
|
"""품셈 개정으로 두 줄 값이 갈리는 날 — 조용히 한 줄로 합치면 틀린 값이 섬(브레인 조건)."""
|
|
same = [
|
|
{"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1},
|
|
{"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1},
|
|
]
|
|
merged = base_prices.merge_same_rows(same)
|
|
assert len(merged) == 1 and merged[0]["source_rows"] == 2
|
|
differing = [
|
|
{"@id": "x", "soil_type": "가", "min": 1.0, "max": 1.1},
|
|
{"@id": "x", "soil_type": "가", "min": 1.2, "max": 1.3},
|
|
]
|
|
with pytest.raises(base_prices.DuplicateRowError):
|
|
base_prices.merge_same_rows(differing)
|
|
|
|
|
|
def test_아홉의_고칠_칸은_값_칸(client: TestClient) -> None:
|
|
rows = {r["@id"]: r for r in _get(client, "masonry_slope", size=500)["rows"]}
|
|
assert rows["table/메쌓기/성토/~1.5"]["face_slope_ratio"] == 0.3
|
|
assert rows["table/찰쌓기/절토/7이상"]["face_slope_ratio"] == 0.4
|
|
back = {r["@id"]: r for r in _get(client, "masonry_back_length", size=500)["rows"]}
|
|
assert (back["table_cm/메쌓기/~1.5"]["min_cm"], back["table_cm/메쌓기/~1.5"]["max_cm"]) == (
|
|
25,
|
|
35,
|
|
)
|
|
assert back["table_cm/메쌓기/7이상"]["max_cm"] is None # 원문 「-」 — 지어내지 않음
|
|
stone = {r["@id"]: r for r in _get(client, "stone_kind", size=500)["rows"]}
|
|
assert stone["wedge_stone_m3_per_m2/깬잡석/25"]["m3_per_m2"] == 0.09
|
|
assert stone["fill_concrete_m3_per_m2/견치돌/75"]["m3_per_m2"] == 0.34
|
|
assert stone["backfill_ratio_of_back_length/깬돌"]["ratio"] == 0.5
|
|
ratio = {r["@id"]: r for r in _get(client, "formwork_reuse", size=500)["rows"]}
|
|
assert ratio["reuse_ratio_pct/plywood/2"]["ratio_pct"] == 57.0
|
|
assert ratio["reuse_by_class/복잡한 구조"]["reuse_count"] == 2
|
|
timber = {r["@id"]: r for r in _get(client, "timber_structure_class", size=500)["rows"]}
|
|
assert timber["classes/보통구조 하"]["carpenter"] == 6.285
|
|
surcharge = {r["@id"]: r for r in _get(client, "material_surcharge", size=500)["rows"]}
|
|
assert (surcharge["rates_pct/시멘트"]["rate"], surcharge["rates_pct/시멘트"]["alt_rate"]) == (
|
|
2,
|
|
3,
|
|
)
|
|
|
|
|
|
def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None:
|
|
res = client.put(
|
|
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
|
|
json={"values": {"m3_per_m2": 0.1}},
|
|
)
|
|
assert res.status_code == 200, res.text
|
|
row = res.json()
|
|
assert row["m3_per_m2"] == 0.1
|
|
assert row["@overrides"]["m3_per_m2"]["original"] == 0.09
|
|
assert (tmp_path / "data_master_override" / "stone_kind.json").is_file()
|
|
listed = client.get("/api/master-data/overrides", params={"kind": "stone_kind"}).json()
|
|
assert [i["row_id"] for i in listed["items"]] == ["wedge_stone_m3_per_m2/깬잡석/25"]
|
|
blocked = client.put(
|
|
"/api/master-data/base-prices/stone_kind/wedge_stone_m3_per_m2/깬잡석/25",
|
|
json={"values": {"stone_kind": "딴 돌"}},
|
|
)
|
|
assert blocked.status_code == 400 and "자료 열쇠" in blocked.text # 축 칸은 못 고침
|
|
assert "@axis" not in row and "@source" not in row # 안쪽 칸은 안 내보냄
|
|
# 같은 칸이 어느 줄에선 값이고 어느 줄에선 축임(사용횟수) — 축인 줄에선 막혀야 함
|
|
axis_edit = client.put(
|
|
"/api/master-data/base-prices/formwork_reuse/reuse_ratio_pct/plywood/2",
|
|
json={"values": {"reuse_count": 3}},
|
|
)
|
|
assert axis_edit.status_code == 400 and "자료 열쇠" in axis_edit.text
|
|
value_edit = client.put(
|
|
"/api/master-data/base-prices/formwork_reuse/reuse_by_class/복잡한 구조",
|
|
json={"values": {"reuse_count": 3}},
|
|
)
|
|
assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3
|