feat(z01): 기초데이터 아홉을 기초단가와 같은 길로 — kind 열넷(coef 토량환산 · material_surcharge · formwork_reuse · rebar_complexity · timber_structure_class · masonry_slope · masonry_back_length · stone_kind · masonry_class) · 열쇠는 축을 슬래시로(구간 이름은 품셈 원문 표기) · 축 칸은 못 고침(같은 이름이 줄마다 값·축으로 갈리는 자리도 줄마다 막음) · 원문 중복 두 줄은 값이 같을 때만 한 줄 + 사유(갈리면 멈춤) · 이음표·품셈 원문 표·글·구조물 실측·기슭막이 교본값은 안 태움(사용자 잣대 · 브레인 승인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -17,10 +17,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Tables as tables
|
||||
|
||||
KINDS = ("labor", "machine", "material", "oil", "rate")
|
||||
#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로
|
||||
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC)
|
||||
SOURCES_DIR = (
|
||||
tables.RESOURCES / "data_master_sources"
|
||||
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
|
||||
@@ -234,11 +236,46 @@ def _rate() -> list[dict[str, Any]]:
|
||||
_BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate}
|
||||
|
||||
|
||||
class DuplicateRowError(ValueError):
|
||||
"""열쇠가 같은 줄인데 값이 다름 — 합치면 틀린 값이 섬(브레인 조건 · 품셈 개정으로 갈리는 날)."""
|
||||
|
||||
|
||||
def merge_same_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""열쇠가 같고 **값도 같은** 줄은 한 줄로 — 원문 두 줄(coef 「암괴…점토」)이 그 자리.
|
||||
|
||||
값이 갈리면 멈춤: 원문이 개정돼 두 줄이 달라지는 날 조용히 한 줄로 합치면 안 됨.
|
||||
"""
|
||||
body = lambda r: { # noqa: E731 — 견줄 알맹이(붙인 사유·줄 수는 뺌)
|
||||
k: v
|
||||
for k, v in r.items()
|
||||
if not k.startswith("@") and k not in ("source_rows", "duplicate_note")
|
||||
}
|
||||
first: dict[str, dict[str, Any]] = {}
|
||||
out = []
|
||||
for row in rows:
|
||||
kept = first.get(row["@id"])
|
||||
if kept is None:
|
||||
first[row["@id"]] = row
|
||||
out.append(row)
|
||||
continue
|
||||
if body(kept) != body(row):
|
||||
raise DuplicateRowError(f"열쇠가 같은데 값이 다름: {row['@id']}")
|
||||
kept["source_rows"] = kept.get("source_rows", 1) + 1
|
||||
kept["duplicate_note"] = (
|
||||
"원문 두 줄 · 값 같음 — 한 줄로 보임(원문 오기 가능성 · 임의 보정 없음)"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def base_rows(kind: str) -> list[dict[str, Any]]:
|
||||
"""덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈)."""
|
||||
if kind == "machine":
|
||||
doc, name = _doc("mach_base")
|
||||
return machine.base_rows(name, edition_of(doc))
|
||||
spec_of = base_tables.SPEC.get(kind)
|
||||
if spec_of:
|
||||
doc, name = _doc(spec_of["file"])
|
||||
return merge_same_rows(spec_of["build"](doc, name))
|
||||
return _BUILDERS[kind]()
|
||||
|
||||
|
||||
@@ -310,6 +347,14 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]:
|
||||
"""표 수준 — editable · locked · formula."""
|
||||
if kind == "machine":
|
||||
return machine.spec(columns)
|
||||
if kind in base_tables.SPEC:
|
||||
editable = [c for c in base_tables.SPEC[kind]["editable"] if c in columns]
|
||||
axis = {a for r in base_rows(kind) for a in r.get("@axis", ())}
|
||||
return {
|
||||
"editable": editable,
|
||||
"locked": {c: _KEY if c in axis else _SOURCE for c in columns if c not in editable},
|
||||
"formula": {},
|
||||
}
|
||||
editable = {
|
||||
"labor": ["daily_wage_krw"],
|
||||
"material": ["price_krw"],
|
||||
@@ -343,6 +388,8 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str:
|
||||
return str(row.get("specification") or row.get("classification_name") or "")
|
||||
if kind == "oil":
|
||||
return f"{row.get('source_product_name') or row.get('fuel')} {row.get('region_name') or '전국평균'}"
|
||||
if kind in base_tables.SPEC: # 기초데이터 아홉 — 축 칸을 이어 사람이 읽을 한 줄로
|
||||
return " · ".join(str(row[c]) for c in row.get("@axis", ()) if row.get(c) is not None)
|
||||
named = (tables.load_labels().get("value_overrides") or {}).get(
|
||||
f"rates::variables/{row['variable']}"
|
||||
) or {}
|
||||
@@ -385,8 +432,8 @@ def meaning_of(kind: str, row: dict[str, Any], editable: list[str]) -> dict[str,
|
||||
|
||||
|
||||
def public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""내보낼 줄 — 안쪽 칸(`@source`)은 뺌."""
|
||||
return {k: v for k, v in row.items() if k != "@source"}
|
||||
"""내보낼 줄 — 안쪽 칸(`@source`·`@axis`)은 뺌."""
|
||||
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
|
||||
|
||||
|
||||
def table(
|
||||
@@ -434,6 +481,8 @@ def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int,
|
||||
return 404, {"message": f"없는 줄: {row_id}"}
|
||||
info = spec(kind, _columns(kind, rows(kind)))
|
||||
for column, value in values.items():
|
||||
if column in (base.get("@axis") or ()):
|
||||
return 400, {"message": f"못 고치는 칸 {column} — {_KEY}"}
|
||||
if column not in info["editable"] or column not in base:
|
||||
reason = info["locked"].get(column) or "이 줄에 없는 칸"
|
||||
return 400, {"message": f"못 고치는 칸 {column} — {reason}"}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Z01 기초데이터 아홉 — 품셈 근거 8 + 돌쌓기 갈래 1(2026-09-16 사용자 지시 · 브레인 승인).
|
||||
|
||||
사용자 잣대: 기초데이터 = **로직으로 계산되는 값이 아니라 값만으로 정의되는 데이터셋.**
|
||||
태움 — 토량환산 · 자재 할증 · 거푸집 전용 · 철근 갈래 · 목재 갈래 · 돌쌓기 경사·뒷길이·돌 종류·갈래
|
||||
안 태움 — 이음표(`type_map`·`form_map`·`bond.codes`) · 품셈 원문 표(coef `surcharge_*`) · 글·방침 ·
|
||||
구조물 실측(울진 한 현장 관찰값) · 기슭막이 교본값(값과 글이 한 줄에 섞임)
|
||||
⚠ 축 칸(`@axis`)은 못 고침 — 그 칸이 `@id` 를 이루므로 바꾸면 덮개가 줄을 못 찾음.
|
||||
⚠ 원문 중복(coef 「암괴…점토」 두 줄)은 **값이 같을 때만** 한 줄로 합침 — 갈리면 `DuplicateRowError`(브레인 조건).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_ROW = dict[str, Any]
|
||||
|
||||
|
||||
def _brackets(steps: list[float]) -> list[str]:
|
||||
"""직고 구간 이름 — 원문 표기(`∼1.5 · ∼3 · ∼5 · ∼7 · 7이상`)를 그대로 씀."""
|
||||
return [f"~{s:g}" for s in steps] + [f"{steps[-1]:g}이상"]
|
||||
|
||||
|
||||
def _row(row_id: str, source: str, axis: tuple[str, ...], **cells: Any) -> _ROW:
|
||||
return {"@id": row_id, "@source": source, "@axis": axis, **cells}
|
||||
|
||||
|
||||
def _coef(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
"""토량환산계수 L·C — 품셈 체적변화율. 원문 표(`surcharge_*`)·공구손료율은 여기 아님."""
|
||||
rows = []
|
||||
for table in ("coef_soil_L", "coef_soil_C"):
|
||||
for record in doc["variables"][table]["records"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"{table}/{record['soil_type']}",
|
||||
name,
|
||||
("table", "soil_type"),
|
||||
table=table,
|
||||
soil_type=record["soil_type"],
|
||||
min=record.get("min"),
|
||||
max=record.get("max"),
|
||||
rule=record.get("rule"),
|
||||
selection=record.get("selection"),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _material_surcharge(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
return [
|
||||
_row(
|
||||
f"rates_pct/{r['material']}",
|
||||
name,
|
||||
("material",),
|
||||
material=r["material"],
|
||||
rate=r.get("rate"),
|
||||
condition=r.get("condition"),
|
||||
alt_rate=r.get("alt_rate"),
|
||||
alt_condition=r.get("alt_condition"),
|
||||
pumsem=r.get("pumsem"),
|
||||
)
|
||||
for r in doc["rates_pct"]
|
||||
]
|
||||
|
||||
|
||||
def _formwork_reuse(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
rows = []
|
||||
for r in doc["reuse_by_class"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"reuse_by_class/{r['class']}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="reuse_by_class",
|
||||
**{"class": r["class"]},
|
||||
reuse_count=r.get("reuse_count"),
|
||||
examples=r.get("examples"),
|
||||
)
|
||||
)
|
||||
ratios = doc["reuse_ratio_pct"]
|
||||
for material in ("plywood", "timber"):
|
||||
for count, percent in (ratios.get(material) or {}).items():
|
||||
rows.append(
|
||||
_row(
|
||||
f"reuse_ratio_pct/{material}/{count}",
|
||||
name,
|
||||
("table", "material", "reuse_count"),
|
||||
table="reuse_ratio_pct",
|
||||
material=material,
|
||||
reuse_count=int(count),
|
||||
ratio_pct=percent,
|
||||
)
|
||||
)
|
||||
for r in doc["euroform_type"]["classes"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"euroform_type/classes/{r['key']}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="euroform_type/classes",
|
||||
**{"class": r["key"]},
|
||||
daily_area_m2=r.get("daily_area_m2"),
|
||||
examples=r.get("examples"),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _rebar_complexity(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
rows = [
|
||||
_row(
|
||||
f"classes/{r['key']}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="classes",
|
||||
**{"class": r["key"]},
|
||||
examples=r.get("examples"),
|
||||
)
|
||||
for r in doc["classes"]
|
||||
]
|
||||
for key, price in (doc["price_hint_krw_per_ton"].get("values") or {}).items():
|
||||
rows.append(
|
||||
_row(
|
||||
f"price_hint_krw_per_ton/{key}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="price_hint_krw_per_ton",
|
||||
**{"class": key},
|
||||
price_krw_per_ton=price,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _timber_structure_class(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
return [
|
||||
_row(
|
||||
f"classes/{r['key']}",
|
||||
name,
|
||||
("class",),
|
||||
**{"class": r["key"]},
|
||||
carpenter=r.get("carpenter"),
|
||||
laborer=r.get("laborer"),
|
||||
examples=r.get("examples"),
|
||||
)
|
||||
for r in doc["classes"]
|
||||
]
|
||||
|
||||
|
||||
def _masonry_slope(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
brackets = _brackets(doc["steps_m"])
|
||||
rows = []
|
||||
for bond, faces in doc["table"].items():
|
||||
for face, values in faces.items():
|
||||
for bracket, ratio in zip(brackets, values):
|
||||
rows.append(
|
||||
_row(
|
||||
f"table/{bond}/{face}/{bracket}",
|
||||
name,
|
||||
("bond", "face", "height_bracket"),
|
||||
bond=bond,
|
||||
face=face,
|
||||
height_bracket=bracket,
|
||||
face_slope_ratio=ratio,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _masonry_back_length(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
brackets = _brackets(doc["steps_m"])
|
||||
rows = []
|
||||
for bond, pairs in doc["table_cm"].items():
|
||||
for bracket, pair in zip(brackets, pairs):
|
||||
rows.append(
|
||||
_row(
|
||||
f"table_cm/{bond}/{bracket}",
|
||||
name,
|
||||
("bond", "height_bracket"),
|
||||
bond=bond,
|
||||
height_bracket=bracket,
|
||||
min_cm=pair[0],
|
||||
max_cm=pair[1], # 원문 「-」 는 그대로 빈 칸
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _stone_kind(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
rows = []
|
||||
|
||||
def unit_rows(table: str, holder: dict[str, Any]) -> None:
|
||||
for kind, lengths in holder.items():
|
||||
if not isinstance(lengths, dict):
|
||||
continue
|
||||
for cm, value in lengths.items():
|
||||
rows.append(
|
||||
_row(
|
||||
f"{table}/{kind}/{cm}",
|
||||
name,
|
||||
("table", "stone_kind", "back_length_cm"),
|
||||
table=table,
|
||||
stone_kind=kind,
|
||||
back_length_cm=int(cm),
|
||||
m3_per_m2=value,
|
||||
)
|
||||
)
|
||||
|
||||
for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
unit_rows(table, doc[table])
|
||||
for kind, ratio in doc["backfill_ratio_of_back_length"].items():
|
||||
if isinstance(ratio, (int, float)):
|
||||
rows.append(
|
||||
_row(
|
||||
f"backfill_ratio_of_back_length/{kind}",
|
||||
name,
|
||||
("table", "stone_kind"),
|
||||
table="backfill_ratio_of_back_length",
|
||||
stone_kind=kind,
|
||||
ratio=ratio,
|
||||
)
|
||||
)
|
||||
fallback = doc["fallback"] # 돌 종류 미지정일 때 쓰는 한 벌(건설품셈 참고자료)
|
||||
for table in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||
for cm, value in (fallback.get(table) or {}).items():
|
||||
rows.append(
|
||||
_row(
|
||||
f"fallback/{table}/{cm}",
|
||||
name,
|
||||
("table", "back_length_cm"),
|
||||
table=f"fallback/{table}",
|
||||
back_length_cm=int(cm),
|
||||
m3_per_m2=value,
|
||||
)
|
||||
)
|
||||
if isinstance(fallback.get("backfill_ratio_of_back_length"), (int, float)):
|
||||
rows.append(
|
||||
_row(
|
||||
"fallback/backfill_ratio_of_back_length",
|
||||
name,
|
||||
("table",),
|
||||
table="fallback/backfill_ratio_of_back_length",
|
||||
ratio=fallback["backfill_ratio_of_back_length"],
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
rows = [
|
||||
_row(
|
||||
f"back_length/classes/{r['key']}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="back_length/classes",
|
||||
**{"class": r["key"]},
|
||||
max_cm=r.get("max_cm"),
|
||||
)
|
||||
for r in doc["back_length"]["classes"]
|
||||
]
|
||||
for value in doc["boulder_diameter"]["classes"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"boulder_diameter/{value}",
|
||||
name,
|
||||
("table", "class"),
|
||||
table="boulder_diameter",
|
||||
**{"class": value},
|
||||
)
|
||||
)
|
||||
for type_id, ratio in doc["face_slope"]["by_type"].items():
|
||||
rows.append(
|
||||
_row(
|
||||
f"face_slope/{type_id}",
|
||||
name,
|
||||
("table", "type_id"),
|
||||
table="face_slope",
|
||||
type_id=type_id,
|
||||
face_slope_ratio=ratio,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
#: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인).
|
||||
SPEC: dict[str, dict[str, Any]] = {
|
||||
"coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]},
|
||||
"material_surcharge": {
|
||||
"file": "material_surcharge",
|
||||
"build": _material_surcharge,
|
||||
"editable": ["rate", "alt_rate"],
|
||||
},
|
||||
"formwork_reuse": {
|
||||
"file": "formwork_reuse",
|
||||
"build": _formwork_reuse,
|
||||
"editable": ["reuse_count", "ratio_pct", "daily_area_m2"],
|
||||
},
|
||||
"rebar_complexity": {
|
||||
"file": "rebar_complexity",
|
||||
"build": _rebar_complexity,
|
||||
"editable": ["price_krw_per_ton"],
|
||||
},
|
||||
"timber_structure_class": {
|
||||
"file": "timber_structure_class",
|
||||
"build": _timber_structure_class,
|
||||
"editable": ["carpenter", "laborer"],
|
||||
},
|
||||
"masonry_slope": {
|
||||
"file": "masonry_slope",
|
||||
"build": _masonry_slope,
|
||||
"editable": ["face_slope_ratio"],
|
||||
},
|
||||
"masonry_back_length": {
|
||||
"file": "masonry_back_length",
|
||||
"build": _masonry_back_length,
|
||||
"editable": ["min_cm", "max_cm"],
|
||||
},
|
||||
"stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]},
|
||||
"masonry_class": {
|
||||
"file": "masonry_class",
|
||||
"build": _masonry_class,
|
||||
"editable": ["max_cm", "face_slope_ratio"],
|
||||
},
|
||||
}
|
||||
@@ -37,7 +37,8 @@ def shown() -> dict[str, list[dict]]:
|
||||
|
||||
|
||||
def test_다섯_표가_다_선다(shown):
|
||||
assert set(shown) == {"labor", "machine", "material", "oil", "rate"}
|
||||
# 2026-09-16 기초데이터 아홉이 더해져 열넷(사용자 지시 · 브레인) — 다섯은 그 안에 그대로
|
||||
assert {"labor", "machine", "material", "oil", "rate"} <= set(shown) == set(base_prices.KINDS)
|
||||
for kind, columns in shown.items():
|
||||
assert columns, f"{kind} 에 열이 하나도 없다"
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user