Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
309 lines
16 KiB
Python
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"
|
|
) # 이름표 전엔 영문
|