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:
2026-09-16 13:08:09 +09:00
co-authored by Claude Opus 5
parent 630d3abd7d
commit 4a6de21552
4 changed files with 553 additions and 4 deletions
+52 -3
View File
@@ -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} 에 열이 하나도 없다"
+176
View File
@@ -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