From 4a6de21552ca8c45aaf8efae8077cc9949e94d8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 16 Sep 2026 13:08:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=B4=88=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=95=84=ED=99=89=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=8B=A8=EA=B0=80=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B8=B8=EB=A1=9C=20=E2=80=94=20kind=20=EC=97=B4=EB=84=B7(coef?= =?UTF-8?q?=20=ED=86=A0=EB=9F=89=ED=99=98=EC=82=B0=20=C2=B7=20material=5Fs?= =?UTF-8?q?urcharge=20=C2=B7=20formwork=5Freuse=20=C2=B7=20rebar=5Fcomplex?= =?UTF-8?q?ity=20=C2=B7=20timber=5Fstructure=5Fclass=20=C2=B7=20masonry=5F?= =?UTF-8?q?slope=20=C2=B7=20masonry=5Fback=5Flength=20=C2=B7=20stone=5Fkin?= =?UTF-8?q?d=20=C2=B7=20masonry=5Fclass)=20=C2=B7=20=EC=97=B4=EC=87=A0?= =?UTF-8?q?=EB=8A=94=20=EC=B6=95=EC=9D=84=20=EC=8A=AC=EB=9E=98=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C(=EA=B5=AC=EA=B0=84=20=EC=9D=B4=EB=A6=84=EC=9D=80=20?= =?UTF-8?q?=ED=92=88=EC=85=88=20=EC=9B=90=EB=AC=B8=20=ED=91=9C=EA=B8=B0)?= =?UTF-8?q?=20=C2=B7=20=EC=B6=95=20=EC=B9=B8=EC=9D=80=20=EB=AA=BB=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8(=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EA=B0=92=C2=B7?= =?UTF-8?q?=EC=B6=95=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EB=8F=84=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C)=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=91=90=20=EC=A4=84=EC=9D=80=20=EA=B0=92=EC=9D=B4?= =?UTF-8?q?=20=EA=B0=99=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20+=20=EC=82=AC=EC=9C=A0(=EA=B0=88=EB=A6=AC=EB=A9=B4?= =?UTF-8?q?=20=EB=A9=88=EC=B6=A4)=20=C2=B7=20=EC=9D=B4=EC=9D=8C=ED=91=9C?= =?UTF-8?q?=C2=B7=ED=92=88=EC=85=88=20=EC=9B=90=EB=AC=B8=20=ED=91=9C=C2=B7?= =?UTF-8?q?=EA=B8=80=C2=B7=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=C2=B7=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EA=B5=90=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9D=80=20=EC=95=88=20=ED=83=9C=EC=9B=80(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9E=A3=EB=8C=80=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EC=8A=B9=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- Z01_MasterData/Z01_MasterData_BasePrices.py | 55 ++- .../Z01_MasterData_BasePrices_Tables.py | 323 ++++++++++++++++++ .../tester/test_master_labels_base_prices.py | 3 +- resources/tester/test_z01_base_tables.py | 176 ++++++++++ 4 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_BasePrices_Tables.py create mode 100644 resources/tester/test_z01_base_tables.py diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 615730c1..1157d334 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -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}"} diff --git a/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py new file mode 100644 index 00000000..da07b770 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_BasePrices_Tables.py @@ -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"], + }, +} diff --git a/resources/tester/test_master_labels_base_prices.py b/resources/tester/test_master_labels_base_prices.py index 0e455f08..801bd9b0 100644 --- a/resources/tester/test_master_labels_base_prices.py +++ b/resources/tester/test_master_labels_base_prices.py @@ -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} 에 열이 하나도 없다" diff --git a/resources/tester/test_z01_base_tables.py b/resources/tester/test_z01_base_tables.py new file mode 100644 index 00000000..429320b9 --- /dev/null +++ b/resources/tester/test_z01_base_tables.py @@ -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