Files
Aislo/resources/tester/test_z01_base_tables.py
T

309 lines
16 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
ROOT = Path(__file__).resolve().parents[2]
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
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
NEW_KINDS = (
"coef",
"material_surcharge",
"formwork_reuse",
"rebar_complexity",
"timber_structure_class",
"masonry_slope",
"masonry_back_length",
"stone_kind",
"machine_productivity", # 2026-09-16 코드에서 꺼낸 기계 작업량 밑값
"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 + work_items.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 set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind
assert table["editable"] or kind == "rebar_complexity", 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:
"""아홉 중 하나만 수량 말고 **그림**에도 닿음 — 화면이 사전을 가지면 문구가 두 벌(브레인)."""
slope = _get(client, "masonry_slope", size=1)["notice"]
assert any("횡단 도면" in line for line in slope) and len(slope) == 2
assert all(
not any("횡단 도면" in line for line in _get(client, kind, size=1)["notice"])
for kind in NEW_KINDS
if kind != "masonry_slope"
)
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
def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None:
"""사용자 원칙 — 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨**(2026-09-16).
불도저 속도·삽날(건설품셈 8-2-1)과 덤프 운반·적재 계수(산림품셈 10-12 식 서식)를 꺼낸 자리.
"""
table = _get(client, "machine_productivity", size=500)
rows = {r["@id"]: r for r in table["rows"]}
assert table["total"] == 25 + 10 + 9 + 3
assert rows["dozer_speed/무한궤도/7/2"]["forward_m_per_min"] == 67
assert rows["dozer_speed/타이어/33/2"]["reverse_m_per_min"] == 250
assert rows["dozer_blade/무한궤도/19"]["blade_m3"] == 3.2
assert rows["dump_haul/truck_ton"]["value"] == 15
assert rows["dump_material/FP-10-12-01"]["loose_factor"] == 1.3
assert "forward_m_per_min" in table["editable"] and "track" in table["locked"]
def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None:
"""옮기며 값이 바뀌면 금액이 조용히 달라짐 — 코드가 읽는 값과 품셈 원문 둘 다에 댐."""
import json
import re
from decimal import Decimal
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import _DOZER_SPEEDS
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import DUMP_MATERIALS
assert _DOZER_SPEEDS["무한궤도"][Decimal("12")][2] == (Decimal(55), Decimal(70))
assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9")
from B09_Estimation import B09_Estimation_MachineProductivity_Dump as dump
assert (dump._TRUCK_TON, dump._BUCKET_M3, dump._LOADER_CYCLE_SEC) == (
Decimal(15),
Decimal("0.7"),
Decimal(20),
)
assert (dump._V_LOADED_KMH, dump._V_EMPTY_KMH, dump._LOADING_CYCLE_SEC) == (
Decimal(5),
Decimal(6),
Decimal(22),
)
pum = json.loads(
(ROOT / "resources/data_cost_input_value/pum_const_2026.json").read_text(encoding="utf-8")
)["variables"]["pum"]["tables"]
by_id = {t["table_id"]: t for t in pum}
def source(table_id: str, gears: int) -> dict:
out = {}
for row in by_id[table_id]["rows"][1:]:
ton = re.sub(r"\(.*?\)", "", str(row[0])).strip()
if not ton.replace(".", "").isdigit():
continue
cells = [str(v).strip() for v in row[1:]]
out[Decimal(ton)] = {
i + 1: (f, r)
for i, (f, r) in enumerate(zip(cells[:gears], cells[gears : gears * 2]))
}
return out
for track, table_id, gears in (("무한궤도", "C0426", 4), ("타이어", "C0427", 3)):
original = source(table_id, gears)
for ton, speeds in _DOZER_SPEEDS[track].items():
for gear, (forward, reverse) in speeds.items():
assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear)
def test_계산값은_고칠_칸이_아니고_까닭이_보임(client: TestClient) -> None:
"""코덱스 검증 1 — 철근 단가 참고값은 **B09 계산값**인데 고칠 수 있는 마스터 값으로 앉아 있었음."""
rebar = _get(client, "rebar_complexity", size=10)
assert "price_krw_per_ton" not in rebar["editable"]
assert "계산값" in rebar["locked"]["price_krw_per_ton"]
assert "표시 전용" in rebar["formula"]["price_krw_per_ton"]
machine = _get(client, "machine", size=1) # 기계 계산 넷도 같은 꼴(잠김 + 식)
for column in ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw"):
assert "계산값" in machine["locked"][column] and column in machine["formula"]
def test_같은_기울기가_두_자리인_까닭을_표가_말함(client: TestClient) -> None:
"""코덱스 검증 2 — 값은 같은 1:0.3 이나 **축과 근거가 다름**(품셈 직고·성절토 ↔ 교본 형식별)."""
notice = _get(client, "masonry_class", size=1)["notice"]
assert any("교본 7-3" in line and "돌쌓기 표준경사" in line for line in notice)
assert any("축과 근거가 다름" in line for line in notice)
slope = _get(client, "masonry_slope", size=1)["notice"]
assert any("횡단 도면" in line for line in slope) # 표준경사 쪽 알림은 그대로
def test_kind_목록을_서버가_줌_새_kind_도_저절로(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""화면이 kind 목록을 제 코드에 들면 **새 kind 가 조용히 안 뜸**(기계 작업량이 그랬음 · 브레인).
⇒ 갈래·kind·한글 이름·줄 수를 서버가 냄. 화면은 상자만 세운다.
"""
listed = client.get("/api/master-data/base-prices")
assert listed.status_code == 200, listed.text
items = listed.json()["kinds"]
assert [i["kind"] for i in items] == list(base_prices.KINDS)
groups = {i["kind"]: i["group"] for i in items}
assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis"
assert {i["group"] for i in items} == {"base_price", "pumsem_basis", "work_item"}
labor = next(i for i in items if i["kind"] == "labor")
assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서
assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
fake = dict(base_tables.SPEC)
fake["coef_fake"] = dict(base_tables.SPEC["coef"])
monkeypatch.setattr(base_tables, "SPEC", fake)
monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "coef_fake"))
grown = client.get("/api/master-data/base-prices").json()["kinds"]
assert [i["kind"] for i in grown][-1] == "coef_fake" # 더하면 목록이 저절로 늚
assert (
next(i for i in grown if i["kind"] == "coef_fake")["label"] == "coef_fake"
) # 이름표 전엔 영문