refactor(M01): Z01_MasterData 폴더째 old_code 로 옮기고 등록·문구 끊음 (PLAN 7-2)

라우터·라우트·문구에서 Z01 을 끊음 — M01 만 남김.
시험 여섯도 old_code/resources/tester 로 같이 옮김.
Z01 이 읽던 옛 자료 셋은 디스크에 이미 없음 · master_data/old 는 B08·B09 도 읽어 남김.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 11:59:59 +09:00
co-authored by Claude Sonnet 5
parent 3e89bf4987
commit 9c5c1d846b
24 changed files with 2 additions and 94 deletions
@@ -0,0 +1,604 @@
"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — 고칠 수 있게 · 2026-09-15 브레인 계약(사용자 지시).
틀(브레인 못박음): 원본 `resources/data_*` 는 **읽기만** · 고친 값은 덮개
`resources/data_master_override/{kind}.json`(주기가 달라 kind 마다 한 파일)에만 · 읽을 때 원본 위에 얹음 ·
덮개 줄마다 original(원래 값)·source(원본 파일) — 원본이 바뀌면 「원본 바뀜」 · 줄이 사라지면 「주인 없음」(버리지 않음) ·
기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜.
"""
from __future__ import annotations
import hashlib
import json
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from Z01_MasterData import Z01_MasterData_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
ROOT = Path(__file__).resolve().parents[2]
SOURCES = [
ROOT / "resources/data_cost_input_value" / name
for name in (
"labor_const_2026-01-01.json",
"labor_mfg_2026-07-01.json",
"mach_base_2026.json",
"mat_price_public_2026-08-14.json",
"oil_2026-08-14.json",
"oil_regional_2026-09-09.json",
"rates_2026.json",
)
]
@pytest.fixture
def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
folder = tmp_path / "data_master_override"
monkeypatch.setattr(overrides, "OVERRIDE_DIR", folder)
return folder
@pytest.fixture
def client(store: Path) -> TestClient:
from common_util.common_util_auth import verify_session
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "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 _row(client: TestClient, kind: str, row_id: str) -> dict:
rows = _get(client, kind, q=row_id.rsplit("/", 1)[-1], size=500)["rows"]
return next(r for r in rows if r["@id"] == row_id)
def _put(client: TestClient, kind: str, row_id: str, values: dict):
return client.put(f"/api/master-data/base-prices/{kind}/{row_id}", json={"values": values})
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_다섯_표는_제_열쇠와_고칠_칸을_냄(client: TestClient) -> None:
expected = {
"labor": (261, ["daily_wage_krw"]),
"material": (6999, ["price_krw"]),
"oil": (36, ["price_krw_per_l"]),
"machine": (613, ["price_thousand_krw", "loss_coefficient_per_hour"]),
}
for kind, (total, editable) in expected.items():
table = _get(client, kind, size=500)
assert table["total"] == total and table["editable"] == editable, kind
ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]]
assert len(ids) == len(set(ids)), kind
assert all("@overrides" not in r for r in table["rows"]), (
kind
) # 안 고친 줄엔 칸 자체가 없음
columns = {c["key"] for c in table["columns"]}
assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖
assert set(table["locked"]) <= columns and not set(table["locked"]) & set(editable), kind
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
assert _row(client, "labor", "labor_mfg/1")["occupation_name"] == "CAD설계사(기계)"
assert _row(client, "oil", "national_average/oil_diesel")["price_krw_per_l"] == 1846.39
assert _row(client, "oil", "regional/oil_diesel/01")["region_name"] == "서울"
rate = _get(client, "rate", size=500)
# 2026-09-18 218 → 220 — 전문공사 일반관리비 300억~1000억·1000억 이상 두 구간이 빠져 있던 것을 채움
assert rate["total"] == 220 and "rate_percent" in rate["editable"]
by_id = {r["@id"]: r for r in rate["rows"]}
assert by_id["rate_sanjae"]["rate_percent"] == 3.56
goyong = (
"rate_goyong/brackets/estimated_amount_bracket=gte_140_billion;grade=1" # 구간 칸 이름 차례
)
assert by_id[goyong]["rate_percent"] == 1.57 and by_id[goyong]["base"] == "total_labor_cost"
material = _get(client, "material", q="육각볼트", size=5)
assert 0 < material["total"] < 6999 and len(material["rows"]) == 5
def test_기계는_시간당_단가를_계산값으로_잠그고_산출근거를_실음(client: TestClient) -> None:
table = _get(client, "machine", q="0101-0007", size=5)
assert "hourly_total_krw" in table["locked"] and "hourly_total_krw" in table["formula"]
row = next(r for r in table["rows"] if r["@id"] == "0101-0007")
parts = row["hourly_loss_krw"] + row["hourly_fuel_krw"] + row["hourly_operator_krw"]
assert row["hourly_total_krw"] == pytest.approx(parts)
assert (
"hourly_total_krw" in row["@formula"]
and f"{int(row['hourly_total_krw']):,}" in row["@formula"]["hourly_total_krw"]
)
res = _put(client, "machine", "0101-0007", {"hourly_total_krw": 1})
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.text
def test_기계_계산값은_B09_시간당_사용료와_같음(client: TestClient) -> None:
from B09_Estimation.B09_Estimation_MachineOperating import (
hourly_cost_of,
load_operating_records,
)
codes = sorted(r.machine_code for r in load_operating_records().records)[::9]
rows = {r["@id"]: r for r in _get(client, "machine", size=500, page=1)["rows"]}
rows |= {r["@id"]: r for r in _get(client, "machine", size=500, page=2)["rows"]}
checked = 0
from B09_Estimation.B09_Estimation_MachineCost import MachineCostError
for code in codes:
try:
cost = hourly_cost_of(code)
except MachineCostError: # 손료계수 없는 기종 — B09 도 못 셈
assert rows[code]["hourly_total_krw"] is None and rows[code]["hourly_note"], code
continue
if cost.gaps:
assert rows[code]["hourly_total_krw"] is None, code # 반만 선 값은 안 냄
continue
assert rows[code]["hourly_total_krw"] == pytest.approx(float(cost.money.total)), code
checked += 1
assert checked >= 5
def test_고친_값은_덮개에만_쓰고_원본은_안_건드림(client: TestClient, store: Path) -> None:
before = {p: _sha(p) for p in SOURCES}
res = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
assert res.status_code == 200, res.text
row = res.json()
assert row["daily_wage_krw"] == 230000
assert row["@overrides"] == {
"daily_wage_krw": {
"original": 215907,
"value": 230000,
"state": overrides.EDITED,
"current": 215907,
}
}
assert (
_row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]["value"] == 230000
)
assert (
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900}).status_code
== 200
)
assert sorted(p.name for p in store.iterdir()) == [
"labor.json",
"oil.json",
] # kind 마다 한 파일
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
entry = saved["overrides"]["labor_const/1001"]["daily_wage_krw"]
assert (
entry["original"] == 215907
and entry["source"] == "labor_const_2026-01-01.json"
and entry["by"] == 42
)
assert {p: _sha(p) for p in SOURCES} == before
def test_밑값을_고치면_기계_계산값이_따라_바뀜(client: TestClient) -> None:
base = _row(client, "machine", "0101-0007")
operator = base["operator_occupation_code"]
wage = _row(client, "labor", f"labor_const/{operator}")["daily_wage_krw"]
_put(client, "labor", f"labor_const/{operator}", {"daily_wage_krw": wage + 80000})
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 2000})
_put(client, "machine", "0101-0007", {"price_thousand_krw": base["price_thousand_krw"] * 2})
after = _row(client, "machine", "0101-0007")
assert after["hourly_operator_krw"] > base["hourly_operator_krw"]
assert after["hourly_fuel_krw"] > base["hourly_fuel_krw"]
assert after["hourly_loss_krw"] == pytest.approx(base["hourly_loss_krw"] * 2)
price = base["price_thousand_krw"]
assert after["@overrides"] == {
"price_thousand_krw": {
"original": price,
"value": price * 2,
"state": overrides.EDITED,
"current": price,
}
} # 노임·유가 덮개는 그 표 줄에 서고 기계 줄은 계산만 바뀜
def test_null_은_되돌리기_같은_값도_덮개를_안_남김(client: TestClient, store: Path) -> None:
_put(client, "material", "10023392", {"price_krw": 30})
reverted = _put(client, "material", "10023392", {"price_krw": None}).json()
assert reverted["price_krw"] == 22 and "@overrides" not in reverted
assert _put(client, "material", "10023392", {"price_krw": 22}).status_code == 200
saved = json.loads((store / "material.json").read_text(encoding="utf-8"))
assert saved["overrides"] == {}
def test_원본이_바뀐_덮개는_덮개_값을_쓰고_세게_드러냄_주인_없는_덮개도_남김(
client: TestClient, store: Path
) -> None:
store.mkdir()
(store / "labor.json").write_text(
json.dumps(
{
"schema_version": "1.0",
"kind": "labor",
"overrides": {
"labor_const/1001": {
"daily_wage_krw": {
"value": 199000,
"original": 200000,
"source": "labor_const_2025-07-01.json",
}
},
"labor_const/9999": {
"daily_wage_krw": {
"value": 1,
"original": 1,
"source": "labor_const_2025-07-01.json",
}
},
},
}
),
encoding="utf-8",
)
# 브레인 ③ — 자동으로 원본을 쓰면 금액이 말없이 바뀜 → 사람이 고친 값 유지 · 갈렸다는 것만 또렷이
row = _row(client, "labor", "labor_const/1001")
assert row["daily_wage_krw"] == 199000
mark = row["@overrides"]["daily_wage_krw"]
assert mark == {
"original": 200000,
"value": 199000,
"state": overrides.SOURCE_CHANGED,
"current": 215907,
}
_put(client, "labor", "labor_const/1002", {"daily_wage_krw": 170000})
listed = client.get("/api/master-data/overrides").json()
assert [
(i["row_id"], i["state"]) for i in listed["items"]
] == [ # 서버가 정렬 — 원본 바뀜이 맨 위
("labor_const/1001", overrides.SOURCE_CHANGED),
("labor_const/9999", overrides.ORPHAN),
("labor_const/1002", overrides.EDITED),
]
first = listed["items"][0]
assert set(first) == {
"kind",
"row_id",
"row_label",
"column",
"column_label",
"original",
"current",
"value",
"state",
"unit",
}
assert first["row_label"] == "작업반장" and (
first["original"],
first["current"],
first["value"],
) == (200000, 215907, 199000)
assert listed["items"][1]["current"] is None
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
assert "labor_const/9999" in saved # 다른 줄을 고쳐도 주인 없는 덮개는 안 지움
# 같은 값을 다시 넣으면 「지금 원본을 보고 고침」 으로 굳음 · null 이면 새 원본 값으로 받기
kept = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 199000}).json()
assert kept["@overrides"]["daily_wage_krw"] == {
"original": 215907,
"value": 199000,
"state": overrides.EDITED,
"current": 215907,
}
taken = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": None}).json()
assert taken["daily_wage_krw"] == 215907 and "@overrides" not in taken
def test_빈_칸에_새로_넣은_값도_덮개_원본에_값이_생기면_원본_바뀜(
client: TestClient, store: Path
) -> None:
blank = next(r for r in _get(client, "labor", size=500)["rows"] if r["daily_wage_krw"] is None)
row = _put(client, "labor", blank["@id"], {"daily_wage_krw": 150000}).json()
assert row["@overrides"]["daily_wage_krw"] == {
"original": None,
"value": 150000,
"state": overrides.EDITED,
"current": None,
}
saved = json.loads((store / "labor.json").read_text(encoding="utf-8"))
saved["overrides"][blank["@id"]]["daily_wage_krw"]["original"] = (
140000 # 옛 판엔 값이 있었다고 치면
)
(store / "labor.json").write_text(json.dumps(saved), encoding="utf-8")
assert (
_row(client, "labor", blank["@id"])["@overrides"]["daily_wage_krw"]["state"]
== overrides.SOURCE_CHANGED
)
def test_요율_표는_법정값_경고를_실음(client: TestClient) -> None:
rate = _get(client, "rate", size=1)
assert any("갱신" in line and "주인" in line for line in rate["notice"])
for kind in ("labor", "machine", "material", "oil", "rate"): # 브레인 ③ — 문구는 서버가 한 벌로
notice = _get(client, kind, size=1)["notice"]
assert any("새로 만드는 프로젝트" in line and "이미 만든" in line for line in notice), kind
assert len(rate["notice"]) == 2 and len(_get(client, "material", size=1)["notice"]) == 1
def test_막는_자리(client: TestClient) -> None:
assert _put(client, "wage", "x", {"daily_wage_krw": 1}).status_code == 404
assert _put(client, "labor", "labor_const/0000", {"daily_wage_krw": 1}).status_code == 404
assert _put(client, "labor", "labor_const/1001", {"occupation_name": "x"}).status_code == 400
locked = _put(client, "labor", "labor_const/1001", {"hours_per_day": 9}) # 숫자여도 잠긴 칸
assert locked.status_code == 400 and "공표 원문" in locked.text
for bad in (True, -1, "220000", float("nan")):
body = json.dumps({"values": {"daily_wage_krw": bad}}).encode()
res = client.put(
"/api/master-data/base-prices/labor/labor_const/1001",
content=body,
headers={"content-type": "application/json"},
)
assert res.status_code in (400, 422), bad
fee = next(
r
for r in _get(client, "rate", size=500)["rows"]
if r["@id"].startswith("rate_performance_guarantee_fee/brackets/")
)
assert (
_put(client, "rate", fee["@id"], {"rate_percent": 1}).status_code == 400
) # 그 줄엔 요율 칸이 없음
assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56")
def test_합친_표는_줄마다_판_기준일을_실음(client: TestClient) -> None:
"""브레인 ① — 노임 두 판(건설 01-01 · 제조 07-01)·유가 두 판(전국 08-14 · 지역 09-09)이 한 표에 섞임."""
assert _row(client, "labor", "labor_const/1001")["effective_date"] == "2026-01-01"
assert _row(client, "labor", "labor_mfg/1")["effective_date"] == "2026-07-01"
assert _row(client, "oil", "national_average/oil_diesel")["effective_date"] == "2026-08-14"
assert _row(client, "oil", "regional/oil_diesel/01")["effective_date"] == "2026-09-09"
assert _row(client, "material", "10023392")["effective_date"] == "2026-08-14"
assert _row(client, "rate", "rate_sanjae")["effective_date"] == "2026-04-13"
m = _row(client, "machine", "0101-0007")
assert (m["effective_date"], m["operating_effective_date"]) == ("2026-01-01", "2026-01-01")
assert (m["fuel_price_date"], m["operator_wage_date"]) == ("2026-08-14", "2026-01-01")
no_record = next(r for r in _get(client, "machine", size=500)["rows"] if r["fuel_type"] is None)
assert no_record["operating_effective_date"] is None and no_record["hourly_total_krw"] is None
def test_주인_없는_덮개는_모아서_한꺼번에_다룸(client: TestClient, store: Path) -> None:
"""브레인 ② — 자재·기계는 품목 퇴출이 상시라 갱신 한 번에 여럿이 주인을 잃음."""
store.mkdir()
gone = {
f"9999000{i}": {"price_krw": {"value": 1, "original": 1, "source": "old.json"}}
for i in range(3)
}
(store / "material.json").write_text(
json.dumps({"schema_version": "1.0", "kind": "material", "overrides": gone}),
encoding="utf-8",
)
_put(client, "material", "10023392", {"price_krw": 30})
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
listed = client.get(
"/api/master-data/overrides", params={"state": overrides.ORPHAN, "kind": "material"}
).json()
assert listed["total"] == 3 and {i["state"] for i in listed["items"]} == {overrides.ORPHAN}
assert listed["counts"] == {
"material": {overrides.ORPHAN: 3, overrides.EDITED: 1},
"labor": {overrides.EDITED: 1},
}
paged = client.get("/api/master-data/overrides", params={"size": 2, "page": 2}).json()
assert paged["total"] == 5 and len(paged["items"]) == 2
everything = client.get("/api/master-data/overrides").json()[
"items"
] # labor 가 kind 차례로 앞이어도
assert [i["state"] for i in everything] == [overrides.ORPHAN] * 3 + [overrides.EDITED] * 2
res = client.post(
"/api/master-data/overrides/clear", json={"kind": "material", "state": overrides.ORPHAN}
)
assert res.status_code == 200 and res.json()["removed"] == 3
left = client.get("/api/master-data/overrides").json()
assert [(i["kind"], i["state"]) for i in left["items"]] == [ # 같은 state 안은 kind 차례
("labor", overrides.EDITED),
("material", overrides.EDITED),
]
assert (
client.post("/api/master-data/overrides/clear", json={"kind": "material"}).status_code
== 422
) # 거름 없이 통째로 못 비움
def test_줄은_있는데_칸이_사라진_덮개도_주인_없음(client: TestClient, store: Path) -> None:
rates = _get(client, "rate", size=500)["rows"]
fee = next(r for r in rates if r["@id"].startswith("rate_performance_guarantee_fee/brackets/"))
store.mkdir()
entry = {"rate_percent": {"value": 1, "original": 0.5, "source": "old.json"}}
(store / "rate.json").write_text(
json.dumps({"overrides": {fee["@id"]: entry}}), encoding="utf-8"
)
items = client.get("/api/master-data/overrides", params={"kind": "rate"}).json()["items"]
assert [(i["row_id"], i["state"]) for i in items] == [(fee["@id"], overrides.ORPHAN)]
assert "@overrides" not in _row(client, "rate", fee["@id"]) # 없는 칸을 새로 만들지 않음
SOURCE_KEYS = {
"source_id",
"name",
"publisher",
"where",
"cycle",
"latest_published",
"our_edition",
"checked_at",
"outdated",
}
def test_표마다_출처와_뒤처짐을_서버가_판정(client: TestClient) -> None:
"""브레인 — 건설 노임이 한 판 뒤처진 것을 반 년간 아무도 몰랐음 → 표 수준에 출처 · outdated 는 서버 판정."""
labor = {s["source_id"]: s for s in _get(client, "labor", size=1)["source"]}
const = labor["labor_const"]
assert set(const) == SOURCE_KEYS
assert (const["our_edition"], const["latest_published"], const["outdated"]) == (
"2026-01-01",
"2026-09-01",
True,
)
assert "cak.or.kr" in const["where"] and const["publisher"] == "대한건설협회"
assert const["checked_at"] == "2026-09-15"
assert labor["labor_mfg"]["outdated"] is None # 최신 공표일 모름
machine = {s["source_id"]: s for s in _get(client, "machine", size=1)["source"]}
assert (
machine["machine_operating"]["our_edition"] == "2026-01-01"
) # derived_from.effective_date
assert machine["mach_base"]["outdated"] is False
assert [s["source_id"] for s in _get(client, "oil", size=1)["source"]] == [
"oil",
"oil_regional",
]
def test_우리_판은_출처표_글자가_아니라_파일에서_읽음(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
real = sorted((ROOT / "resources/data_master_sources").glob("sources_*.json"))[-1]
doc = json.loads(real.read_text(encoding="utf-8"))
for s in doc["sources"]:
if s["source_id"] == "rates": # 표 글자가 굳어 틀려도
s.update(our_edition="1999-01-01", latest_published="2027-01-01", status="current")
folder = tmp_path / "sources"
folder.mkdir()
(folder / "sources_2099-01-01.json").write_text(json.dumps(doc), encoding="utf-8")
monkeypatch.setattr(base_prices, "SOURCES_DIR", folder)
rate = _get(client, "rate", size=1)["source"]
assert [(s["our_edition"], s["outdated"]) for s in rate] == [("2026-04-13", True)]
monkeypatch.setattr(base_prices, "SOURCES_DIR", tmp_path / "없음")
assert _get(client, "rate", size=1)["source"] == [] # 출처표가 없으면 빈 목록(모양은 그대로)
def test_모아_보기_항목에_단위(client: TestClient) -> None:
_put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000})
(item,) = client.get("/api/master-data/overrides").json()["items"]
assert item["unit"] == "원" and item["column_label"] == "일 노임"
def test_열_이름은_이름표에서만_옴(client: TestClient) -> None:
"""브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `merged_tables` 가 정본."""
labels = json.loads(
(ROOT / "resources/data_master_labels/labels_2026-01-01.json").read_text(encoding="utf-8")
)
merged = {t["key"]: t for t in labels["merged_tables"]}
for kind in ("labor", "material", "oil"):
named = {c["key"]: c for c in merged[kind]["columns"]}
for column in _get(client, kind, size=1)["columns"]:
if column["key"] in named:
assert column["label"] == named[column["key"]]["name_ko"], (kind, column["key"])
assert column["unit"] == named[column["key"]]["unit"]
assert not [c for c in _get(client, kind, size=1)["columns"] if c["label"] == c["key"]], (
kind
)
def _sorted_rows(client: TestClient, kind: str, column: str, desc: int = 0, **params) -> list:
table = _get(client, kind, sort=column, desc=desc, size=500, **params)
return [r.get(column) for r in table["rows"]]
def test_차례_세우기는_서버가_전체_줄로(client: TestClient) -> None:
"""브레인 — 자재 140 쪽이라 화면이 받은 50 줄만 세우면 1 쪽 안에서만 맞는 가짜 차례."""
table = _get(client, "material", size=1)
assert set(table["sortable"]) == {c["key"] for c in table["columns"]}
first = _get(client, "material", sort="price_krw", size=1)
last = _get(client, "material", sort="price_krw", desc=1, size=1)
assert first["total"] == last["total"] == 6999
assert first["rows"][0]["price_krw"] < last["rows"][0]["price_krw"]
page2 = _get(client, "material", sort="price_krw", size=50, page=2)["rows"]
assert first["rows"][0]["price_krw"] <= page2[0]["price_krw"] # 쪽을 넘어도 이어짐
values = _sorted_rows(client, "material", "price_krw")
assert values == sorted(values)
def test_거른_뒤에_세움_모르는_열은_거절(client: TestClient) -> None:
hits = _get(client, "material", q="육각볼트", sort="price_krw", desc=1, size=500)
values = [r["price_krw"] for r in hits["rows"]]
assert values == sorted(values, reverse=True) and hits["total"] == len(values)
assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in hits["rows"])
bad = client.get("/api/master-data/base-prices/material", params={"sort": "없는열"})
assert bad.status_code == 400 and "없는열" in bad.text # 조용히 원본 차례로 안 돌아감
def test_빈_칸은_어느_쪽으로_세워도_끝(client: TestClient) -> None:
"""미공표 노임 14 · 손료계수 없는 기계 226 이 첫 쪽을 채우면 표를 못 씀."""
for desc in (0, 1):
wages = _sorted_rows(client, "labor", "daily_wage_krw", desc)
filled = [v for v in wages if v is not None]
assert wages[: len(filled)] == sorted(filled, reverse=bool(desc))
assert wages[len(filled) :] == [None] * (len(wages) - len(filled)) and len(filled) < len(
wages
)
hourly = _sorted_rows(client, "machine", "hourly_total_krw", 1)
assert hourly[0] is not None and hourly[-1] is None # 계산 열도 세워짐
def test_덮개_얹은_값으로_세움(client: TestClient) -> None:
_put(client, "labor", "labor_const/1003", {"daily_wage_krw": 9_000_000})
top = _get(client, "labor", sort="daily_wage_krw", desc=1, size=1)["rows"][0]
assert top["@id"] == "labor_const/1003" and top["daily_wage_krw"] == 9_000_000
def test_sort_가_없으면_원본_차례(client: TestClient) -> None:
assert _get(client, "rate", size=1)["rows"][0]["@id"] == "rate_sanjae"
assert _get(client, "machine", size=1)["rows"][0]["@id"] == "0101-0007"
def test_줄의_뜻이_바뀌면_넷째_낱말(client: TestClient, store: Path) -> None:
"""브레인 — 같은 코드에 다른 기준이 붙음(노임 3001~ 문화재→국가유산 · 자재 KS 개정 규격).
값만 보면 안 잡힘 → 덮개를 쓸 때 **값이 아닌 글자 칸**(이름·규격·단위…)을 함께 적어 두고 견줌.
"""
saved = _put(client, "labor", "labor_const/1001", {"daily_wage_krw": 230000}).json()
assert saved["@overrides"]["daily_wage_krw"]["state"] == overrides.EDITED
entry = json.loads((store / "labor.json").read_text(encoding="utf-8"))["overrides"]
meaning = entry["labor_const/1001"]["daily_wage_krw"]["meaning"]
assert meaning["occupation_name"] == "작업반장" # 이름·규격 같은 글자 칸만
assert "daily_wage_krw" not in meaning and "hours_per_day" not in meaning # 값 칸은 안 적음
assert "effective_date" not in meaning # 판 날짜는 갱신마다 바뀜 — 뜻 바뀜과 구별이 안 됨
_put(client, "oil", "national_average/oil_diesel", {"price_krw_per_l": 1900})
oil = json.loads((store / "oil.json").read_text(encoding="utf-8"))["overrides"]
assert "date" not in oil["national_average/oil_diesel"]["price_krw_per_l"]["meaning"]
entry["labor_const/1001"]["daily_wage_krw"]["meaning"]["occupation_name"] = "옛 직종 이름"
(store / "labor.json").write_text(
json.dumps({"schema_version": "1.0", "kind": "labor", "overrides": entry}), encoding="utf-8"
)
mark = _row(client, "labor", "labor_const/1001")["@overrides"]["daily_wage_krw"]
assert mark["state"] == overrides.MEANING_CHANGED # 값은 그대로인데 줄의 뜻이 바뀜
# 이름 칸이 여럿인 자료가 있어 **서버가 한 줄로 합쳐** 냄(화면이 합치면 판정이 두 벌)
assert mark["was"] == "labor_const · 1001 · 옛 직종 이름 · KRW/day · published"
assert mark["now"] == "labor_const · 1001 · 작업반장 · KRW/day · published"
listed = client.get("/api/master-data/overrides").json()
top = listed["items"][0]
assert top["state"] == overrides.MEANING_CHANGED # 값 틀림보다 더 세게
assert (top["was"], top["now"]) == (mark["was"], mark["now"]) # 모아 보기에도 같은 둘
assert overrides.STATE_ORDER[0] == overrides.MEANING_CHANGED
assert all(i.get("was") for i in listed["items"] if i["state"] == overrides.MEANING_CHANGED)
def test_뜻_칸은_이름표가_가림_옛_덮개는_그대로(client: TestClient, store: Path) -> None:
_put(client, "machine", "0101-0007", {"price_thousand_krw": 80000})
meaning = json.loads((store / "machine.json").read_text(encoding="utf-8"))["overrides"][
"0101-0007"
]["price_thousand_krw"]["meaning"]
assert meaning["machine_name"] == "불도저(무한궤도)" and meaning["specification"] == "7"
assert not [k for k in meaning if k in ("price_thousand_krw", "loss_coefficient_per_hour")]
store.mkdir(
exist_ok=True
) # 뜻 칸이 없던 옛 덮개 — 그대로 edited 로 둠(없는 것을 지어내지 않음)
(store / "material.json").write_text(
json.dumps(
{"overrides": {"10023392": {"price_krw": {"value": 30, "original": 22, "source": "x"}}}}
),
encoding="utf-8",
)
assert (
_row(client, "material", "10023392")["@overrides"]["price_krw"]["state"] == overrides.EDITED
)
@@ -0,0 +1,311 @@
"""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
# ⚠ 지금 상태를 먼저 — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 브레인 「없으면 없다고」)
assert any(line.startswith("⚠ **B06 횡단설계가 아직") for line in slope)
assert slope[0].startswith("⚠ **아직 어디에도 안 쓰임**")
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_material_pct/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_material_pct/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"
) # 이름표 전엔 영문
@@ -0,0 +1,351 @@
"""Z01 마스터 데이터 표 칸 — 숨김 열 · 열 제목(한글 label + 단위) · 칸 글자 · 쪽 수 (2026-09-15 브레인 Z01).
① 한글 이름은 API 가 `label` 로 줌 — 화면은 사전을 안 가짐(받은 대로 보임)
② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임
③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로
④ 쪽 수는 줄이 없어도 1
⑤ columns 차례가 열 차례 — rows 에 columns 에 없는 열이 섞여도 안 그림 · columns 에 있는데
줄에 그 열이 없으면 「—」(값이 null 이면 빈칸) (2026-09-15 브레인)
⑥ 큰 표(40,000줄 = 800쪽) — 쪽 번호 칸에 적은 값은 1~끝 쪽으로 조임(끝 쪽으로 바로 감)
⑦ 검색·쪽 요청이 겹치면 최신 응답만 — 늦게 온 옛 응답·옛 오류는 버림 (2026-09-15 브레인)
TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
import {
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
boldSegments, filterValue, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
} from "./Z01_MasterData_UI_Cells.js";
// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인)
const served = [
{ group: "base_price", group_label: "기초단가", kind: "labor", label: "노임", rows: 261 },
{ group: "base_price", group_label: "기초단가", kind: "oil", label: "유가", rows: 36 },
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "coef", label: "토량환산계수", rows: 32 },
// 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
{ group: "work_axis", group_label: "공종 축", kind: "forest", label: "산림품셈 기준", rows: 358 },
// 갈래 이름이 안 오면 키가 그대로 떠 빠진 것이 드러남(때우지 않음)
{ group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 },
];
const grouped = groupKinds(served).map((g) => [g.label, g.items.map((i) => i.kind)]);
// 거르기 — 안 골랐으면 서버가 이미 건 기본값을 보임(보이는 것 = 걸린 것) · 고르면 고른 값
const roadFilter = {
key: "use",
label: "보기",
options: [{ value: "all", label: "전부", rows: 995 }, { value: "road", label: "임도가 쓰는 것", rows: 120 }],
default: "road",
};
const filtered = [filterValue(roadFilter, {}), filterValue(roadFilter, { use: "all" })];
// 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터
const sorts = [
nextSort(null, "price_krw"),
nextSort({ key: "price_krw", desc: false }, "price_krw"),
nextSort({ key: "price_krw", desc: true }, "price_krw"),
nextSort({ key: "price_krw", desc: true }, "item_code"),
];
// 표 수준 알림 — 서버가 한 벌로 목록을 줌(첫 줄 「새로 만드는 프로젝트부터」) · 글자에 **굵게** 가 섞임
const notices = [
noticeLines(["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"]),
noticeLines("한 줄짜리"),
noticeLines(undefined),
];
const bolds = [
boldSegments("여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임"),
boldSegments("굵게 없음"),
];
// 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인)
// ⚠ 한 표가 원본 여럿을 모은 것이라 출처는 목록으로 옴(노임 = 건설업 + 제조업).
const laborSources = [
{
source_id: "labor_const",
name: "건설업 시중노임단가",
publisher: "대한건설협회",
where: "cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]",
cycle: "해마다 두 번",
latest_published: "2026-09-01",
our_edition: "2026-01-01",
checked_at: "2026-09-15",
outdated: true,
},
{
source_id: "labor_mfg",
name: "중소제조업 직종별 임금",
publisher: "중소기업중앙회",
where: "",
latest_published: null,
our_edition: "2026-07-01",
outdated: null,
},
];
const banners = [
outdatedBanner(laborSources),
outdatedBanner([{ ...laborSources[0], outdated: false }]),
outdatedBanner(undefined),
];
// 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①)
const dated = [
{ key: "occupation_name", label: "직종명", unit: null, hidden: false },
{ key: "effective_date", label: "기준일", unit: null, hidden: false },
];
const editions = [
rowCells({ occupation_name: "보통인부", effective_date: "2026-01-01" }, dated),
rowCells({ occupation_name: "기계운전", effective_date: null }, dated),
rowCells({ occupation_name: "굴착기" }, dated),
];
// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인)
const meta = {
editable: ["daily_wage_krw"],
locked: { hourly_krw: "계산으로 나옴" },
formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" },
};
const machineRow = {
"@id": "M-001",
daily_wage_krw: 300000,
hourly_krw: 12340,
"@overrides": { daily_wage_krw: { original: 250000, value: 300000 } },
"@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" },
};
// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈)
const withState = (state, current) => ({
"@id": "M-002",
daily_wage_krw: 300000,
"@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } },
});
// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited
const changedRow = withState("source_changed", 250000);
const sameRow = withState("edited", 270000);
const orphanRow = withState("orphan", undefined);
// 뜻 바뀜 — 번호는 그대로인데 줄이 딴 물건이 됨(노임 3001 문화재목공 → 한식목공)
const meaningRow = {
"@id": "labor_const/3001",
daily_wage_krw: 300000,
"@overrides": {
daily_wage_krw: {
original: 250000,
value: 300000,
current: 260000,
state: "meaning_changed",
was: "문화재목공",
now: "한식목공",
},
},
};
const wait = (ms, value, fail) =>
new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms));
const fetchLatest = latestOnly(wait);
// 옛 검색(느림) → 새 검색(빠름) · 옛 오류(느림) → 새 성공(빠름)
const race = await Promise.all([fetchLatest(60, "old"), fetchLatest(10, "new")]);
const raceError = await Promise.all([
fetchLatest(60, "old error", true).catch((e) => "thrown " + e.message),
fetchLatest(10, "new"),
]);
const latestError = await fetchLatest(5, "boom", true).catch((e) => "thrown " + e.message);
const columns = [
{ key: "id", label: "id", unit: null, hidden: true },
{ key: "occupation_name", label: "직종명", unit: null, hidden: false },
{ key: "daily_wage_krw", label: "일 노임", unit: "원", hidden: false },
];
writeFileSync(process.argv[2], JSON.stringify({
shown: shownColumns(columns, false).map((c) => c.key),
shownAll: shownColumns(columns, true).map((c) => c.key),
titles: columns.map(columnTitle),
cells: [
cellText(null, null),
cellText(250000, "원"),
cellText(1.23456, "㎥"),
cellText(2026, null),
cellText({ a: 1 }, null),
cellText(true, null),
cellText("보통인부", null),
// 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것
cellText(0.00018109999999999998, null),
// 공종 축 목록 칸 — 괄호·따옴표 글자로 뜨던 것(2026-09-17 브레인 ④)
cellText(["연암", "보통암", "경암"], null),
cellText([], null),
],
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
clamp: [clampPage(801, 800), clampPage(0, 800), clampPage(NaN, 800), clampPage(12.6, 800)],
race: race.map((v) => v ?? null),
raceError: raceError.map((v) => v ?? null),
latestError,
info: [
cellInfo(meta, machineRow, "daily_wage_krw"),
cellInfo(meta, machineRow, "hourly_krw"),
cellInfo({}, { name: "x" }, "name"),
cellInfo(meta, changedRow, "daily_wage_krw"),
cellInfo(meta, sameRow, "daily_wage_krw"),
cellInfo(meta, orphanRow, "daily_wage_krw"),
cellInfo(meta, meaningRow, "daily_wage_krw"),
],
parsed: [
parseCellInput("12,340", 100),
parseCellInput("abc", 100),
parseCellInput("", 100),
parseCellInput("보통인부2", "보통인부"),
parseCellInput("3.5", null),
].map((v) => (v === undefined ? "invalid" : v)),
editions,
withUnit: [
valueWithUnit(17, "원"),
valueWithUnit(41500000, "원"),
valueWithUnit(0.17, ""),
valueWithUnit(12345.678, null),
valueWithUnit(null, "원"),
valueWithUnit("5억 미만", ""),
],
banners,
notices,
bolds,
sorts,
grouped,
filtered,
}));
"""
def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(SOURCE),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--ignoreConfig",
"--noCheck",
"--noResolve",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "output.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
got = json.loads(result.read_text(encoding="utf-8"))
assert got["shown"] == ["occupation_name", "daily_wage_krw"]
assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"]
assert got["titles"] == ["id", "직종명", "일 노임 (원)"]
assert got["cells"] == [
"",
"250,000",
"1.23456",
"2026",
'{"a":1}',
"예",
"보통인부",
"0.0001811",
"연암, 보통암, 경암",
"",
]
assert got["pages"] == [1, 140, 1]
# columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림
assert got["row"] == ["—", "", "250,000"]
assert got["clamp"] == [800, 1, 1, 13]
# 옛 응답은 늦게 와도 버림(null) · 옛 오류도 안 던짐 · 최신 오류는 던짐
assert got["race"] == [None, "new"]
assert got["raceError"] == [None, "new"]
assert got["latestError"] == "thrown boom"
# 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식
wage, hourly, plain, changed, same, orphan, meaning = got["info"]
# state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그)
assert wage == {
"editable": True,
"reason": None,
"overridden": True,
"original": 250000,
"state": "missing",
"rule": None,
"filled": None,
}
# 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인)
assert changed["state"] == "source_changed" and changed["current"] == 250000
assert same["state"] == "edited" and "current" not in same
assert orphan["state"] == "orphan"
# 뜻 바뀜 — 옛 이름·지금 이름을 나란히 보여야 사람이 딴 물건인지 가림
assert meaning["state"] == "meaning_changed" and meaning["current"] == 260000
assert meaning["was"] == "문화재목공" and meaning["now"] == "한식목공"
assert hourly["state"] is None and plain["state"] is None
assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴"
assert hourly["overridden"] is False and "original" not in hourly
assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무"
assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000"
assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None
# 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수
assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5]
# 기준일이 없으면 빈칸·「—」가 아니라 「판 모름」
assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"]
# 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만
assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"]
# 뒤처진 원본만 한 줄씩 · 판정을 모르는 것(outdated null)은 안 세움 · 없으면 빈 목록
assert got["banners"] == [
[
"건설업 시중노임단가 — 이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · "
"대한건설협회 cak.or.kr [지원·사업] > [건설적산기준] > [건설임금]"
],
[],
[],
]
# 알림은 목록도 한 줄도 받음 · 없으면 빈 목록
assert got["notices"] == [
["여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임", "법이 정한 값 — 고치면…"],
["한 줄짜리"],
[],
]
# **…** 는 굵게 조각으로 갈라 보임 — 별표가 글자로 남으면 안 됨
assert got["bolds"] == [
[
{"text": "여기서 고친 값은 ", "bold": False},
{"text": "새로 만드는 프로젝트부터", "bold": True},
{"text": " 쓰임", "bold": False},
],
[{"text": "굵게 없음", "bold": False}],
]
# 세 번째 누르면 원본 차례로 — 누르기 전에는 차례 표시가 아예 없어야 함
assert got["sorts"] == [
{"key": "price_krw", "desc": False},
{"key": "price_krw", "desc": True},
None,
{"key": "item_code", "desc": False},
]
# 서버 차례 그대로 갈래별로 묶임 · 상자 이름은 서버 group_label · 새 kind·새 갈래도 저절로 섬
assert got["grouped"] == [
["기초단가", ["labor", "oil"]],
["품셈 기준", ["coef", "machine_productivity"]],
["공종 축", ["forest"]],
["later_group", ["brand_new"]],
]
# 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸)
assert got["filtered"] == ["road", "all"]
@@ -0,0 +1,275 @@
"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판.
약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 ·
표 = 화면이 표로 그릴 마디(이름표 92 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
열 이름 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · 줄 한 줄 = {열key: 값} · file·table 은 트리의 id.
"""
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_Router as router_module
from Z01_MasterData import Z01_MasterData_Tables as tables
LABELS = json.loads(
(tables.LABEL_DIR / "labels_2026-01-01.json").read_text(encoding="utf-8")
) # 서브 이름표 판
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _tree(client: TestClient) -> dict:
res = client.get("/api/master-data/tree")
assert res.status_code == 200, res.text
return res.json()
def _rows(client: TestClient, **params) -> dict:
res = client.get("/api/master-data/rows", params=params)
assert res.status_code == 200, res.text
return res.json()
def _files(client: TestClient) -> dict[str, dict]:
return {f["id"]: f for g in _tree(client)["groups"] for f in g["files"]}
def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: TestClient) -> None:
groups = _tree(client)["groups"]
assert [g["key"] for g in groups] == [k for k in LABELS["kinds"]]
counts = {g["key"]: len(g["files"]) for g in groups}
# 2026-09-16 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다.
# 2026-09-17 공종 축 산출 일곱을 올림 — logic 15 → 19 · byproduct 4 → 6 · fingerprint 3 → 4
assert counts == {
"logic": 19,
"base_value": 9,
"byproduct": 6,
"reference": 2,
"fingerprint": 4,
"seed": 1,
}
assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값"
by_id = {f["file_id"]: f for f in LABELS["files"]}
for f in (f for g in groups for f in g["files"]):
assert (
f["label"] == by_id[f["id"]]["name_ko"]
and f["key"] == Path(by_id[f["id"]]["path"]).name
)
assert not [
f for f in _files(client).values() if "rainfall" in f["key"] or f["key"][:3] == "002"
]
def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: TestClient) -> None:
files = { # 낱값 표 `@values` 는 예약어 — 이름표 표 목록 밖(아래 시험이 따로 셈)
fid: [t for t in f["tables"] if t["id"] != tables.VALUES_TABLE]
for fid, f in _files(client).items()
}
not_labelled, label_only = [], []
for entry in LABELS["files"]:
tree_ids = {t["id"] for t in files[entry["file_id"]]}
label_ids = {t["key"] for t in entry["tables"]}
not_labelled += [f"{entry['file_id']}::{i}" for i in sorted(tree_ids - label_ids)]
label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)]
assert not_labelled == [], not_labelled
assert label_only == [], label_only
# 2026-09-17 92 → 101 — 공종 축 산출 일곱 + 갈래 열쇠 장부 표.
# 2026-09-17 101 → 102 — 건설 열쇠 장부에도 갈래 표(건설 갈래 펴기 ④).
# 2026-09-17 102 → 101 — 산림 형태 미판정이 0 이 되어 그 표가 빠짐(값 묶음으로 내려감).
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 101
for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음
named = {t["key"]: t for t in entry["tables"]}
for t in files[entry["file_id"]]:
assert t["label"] == named[t["id"]]["name_ko"], (entry["file_id"], t["id"])
assert t["row_count"] == named[t["id"]]["rows"], (entry["file_id"], t["id"])
def test_자재_단가는_쪽으로_나눠_주고_검색으로_좁힘(client: TestClient) -> None:
table = "variables/mat_price/records"
first = _rows(client, file="mat_price_public", table=table, page=1, size=50)
second = _rows(client, file="mat_price_public", table=table, page=2, size=50)
assert first["total"] == 6999 and len(first["rows"]) == 50 and first["rows"] != second["rows"]
raw = json.loads(
Path(
tables.ROOT / "resources/data_cost_input_value/mat_price_public_2026-08-14.json"
).read_text(encoding="utf-8")
)["variables"]["mat_price"]["records"]
assert first["rows"][0] == raw[0] and second["rows"][0] == raw[50] # 한 줄 = {열key: 값} 그대로
found = _rows(client, file="mat_price_public", table=table, q="육각볼트", size=500)
assert 0 < found["total"] < 6999
assert all("육각볼트" in json.dumps(r, ensure_ascii=False) for r in found["rows"])
capped = _rows(client, file="mat_price_public", table=table, size=100000)
assert len(capped["rows"]) == tables.MAX_PAGE_SIZE
def test_묶음_표는_줄마다_이름_열(client: TestClient) -> None:
slope = _rows(client, file="masonry_slope", table="table")
assert [r["@key"] for r in slope["rows"]] == ["메쌓기", "찰쌓기"]
assert slope["rows"][0]["성토"] == [0.3, 0.35, 0.4, 0.45, 0.5]
cols = {c["key"]: c for c in slope["columns"]}
assert cols["성토"]["label"] == "성토부 경사" and cols["성토"]["unit"] == "1:n"
assert cols["@key"]["label"] == "쌓기 방식" # 줄 이름 열은 표마다 제 이름(이름표 표 열 목록)
fx = {c["key"]: c for c in _rows(client, file="fx", table="variables")["columns"]}
assert fx["value"]["label"] == "환율" and fx["value"]["unit"] == "원" # 「fx/value」 덮어쓰기
assert fx["@key"]["label"] == "통화"
def test_숨김은_이름표의_보임에서(client: TestClient) -> None:
cols = {c["key"]: c for c in _rows(client, file="pum_forest", table="sources")["columns"]}
assert cols["sha256"]["hidden"] is True and cols["path"]["hidden"] is False
hit = _rows(client, file="pum_forest", table="variables/pum/tables", q="F0155")
assert [r["table_id"] for r in hit["rows"]] == ["F0155"]
def test_찾는_차례와_없는_이름은_영문_key(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "labels_2026-01-01.json").write_text(
json.dumps(
{
"kinds": {"base_value": {"name_ko": "기초값"}},
"files": [
{
"file_id": "rates",
"path": "resources/data_cost_input_value/rates_2026.json",
"kind": "base_value",
"name_ko": "",
"tables": [],
},
{"file_id": "outside", "path": "config/config_db.py", "kind": "base_value"},
],
"columns": {
"rate_percent": {"name_ko": "딴 이름", "unit": "딴 단위", "visible": True},
"grade": {"name_ko": "등급", "unit": "", "visible": False},
},
"column_overrides": {"rates/rate_percent": {"name_ko": "요율", "unit": "%"}},
},
ensure_ascii=False,
),
encoding="utf-8",
)
monkeypatch.setattr(tables, "LABEL_DIR", tmp_path)
groups = _tree(client)["groups"]
assert [f["id"] for g in groups for f in g["files"]] == ["rates"] # resources 밖은 안 읽음
rates = groups[0]["files"][0]
assert rates["label"] == "rates"
goyong = next(t for t in rates["tables"] if t["id"] == "variables/rate_goyong/brackets")
assert goyong["label"] == goyong["id"]
cols = {c["key"]: c for c in _rows(client, file="rates", table=goyong["id"])["columns"]}
assert cols["rate_percent"]["label"] == "요율" and cols["rate_percent"]["unit"] == "%"
assert cols["grade"]["label"] == "등급" and cols["grade"]["hidden"] is True
assert cols["estimated_amount_bracket"] == {
"key": "estimated_amount_bracket",
"label": "estimated_amount_bracket",
"unit": "",
"hidden": False,
}
assert (
client.get("/api/master-data/rows", params={"file": "outside", "table": "x"}).status_code
== 404
)
def test_낱값은_파일마다_표_하나_설명은_뺌(client: TestClient) -> None:
"""브레인 ④ — 표 아닌 마디(값 묶음)도 트리에 `@values` 한 마디로 · 값/설명 가름은 이름표 `kind`."""
files = _files(client)
rates = files["rates"]["tables"]
assert rates[-1]["id"] == "@values" and rates[-1]["label"] == "낱값" # 이름표 synthetic_tables
values = _rows(client, file="rates", table="@values", size=500)
assert values["total"] == rates[-1]["row_count"]
by_path = {r["@path"]: r for r in values["rows"]}
sanjae = by_path["variables/rate_sanjae"]
assert sanjae["@value"] == {"rate_percent": 3.56, "base": "total_labor_cost"}
assert sanjae["@name"] == "산재보험료"
assert {c["key"]: (c["label"], c["hidden"]) for c in values["columns"]} == {
"@name": ("항목", False),
"@value": ("값", False),
"@path": ("원문 자리", True),
}
assert "@values" not in {
t["id"] for t in files["work_item_master"]["tables"]
} # 방침·집계 = 설명
assert "@values" not in {t["id"] for t in files["fx"]["tables"]} # 값 묶음 없음 — 줄이 곧 값
for entry in LABELS[
"files"
]: # 보이는 것 = 이름표가 「값」 이라 한 것 그대로(수로 박지 않음 — 이름표가 늘어도 맞음)
want = [g["key"] for g in entry["value_groups"] if g["kind"] == "value"]
got = (
_rows(client, file=entry["file_id"], table="@values", size=500)["rows"]
if "@values" in {t["id"] for t in files[entry["file_id"]]["tables"]}
else []
)
assert sorted(r["@path"] for r in got) == sorted(want), entry["file_id"]
hit = _rows(client, file="rates", table="@values", q="산재")
assert [r["@path"] for r in hit["rows"]] == ["variables/rate_sanjae"]
def test_이름표가_값_설명을_안_가른_마디는_미판정으로_보임(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""브레인 — 조용히 값으로 보이면 설명이 섞이고, 조용히 빼면 원가에 드는 값이 사라짐 → 값으로 보이되 드러나게."""
entry = next(f for f in LABELS["files"] if f["file_id"] == "rates")
lagging = [dict(g) for g in entry["value_groups"]]
for g in lagging:
if g["key"] in ("variables/rate_vat", "variables/rate_goyong/base"):
del g["kind"] # 이름표가 뒤처짐
elif g["key"] == "variables/rate_care":
g["kind"] = "formula" # 코드가 모르는 낱말도 미판정
(tmp_path / "labels_2026-01-01.json").write_text(
json.dumps({**LABELS, "files": [{**entry, "value_groups": lagging}]}, ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(tables, "LABEL_DIR", tmp_path)
values = _rows(client, file="rates", table="@values", size=500)
marks = {r["@path"]: r.get(tables.UNDECIDED_COLUMN) for r in values["rows"]}
undecided = {"variables/rate_vat", "variables/rate_goyong/base", "variables/rate_care"}
assert {p for p, m in marks.items() if m} == undecided
assert {marks[p] for p in undecided} == {tables.UNDECIDED}
assert tables.UNDECIDED_COLUMN in {c["key"] for c in values["columns"]}
assert not next(c for c in values["columns"] if c["key"] == tables.UNDECIDED_COLUMN)["hidden"]
def test_진짜_이름표엔_미판정이_없음() -> None:
undecided = [
f"{entry['file_id']}::{r['@path']}"
for entry in LABELS["files"]
for r in tables._value_rows(entry, tables.ROOT / entry["path"], LABELS)
if r.get(tables.UNDECIDED_COLUMN)
]
assert undecided == [], undecided
def test_값_묶음_자리는_이름표와_양쪽으로_같음() -> None:
not_labelled, label_only = [], []
for entry in LABELS["files"]:
ours = set(tables.values_of(tables.ROOT / entry["path"]))
theirs = {g["key"] for g in entry["value_groups"]}
not_labelled += [f"{entry['file_id']}::{k}" for k in sorted(ours - theirs)]
label_only += [f"{entry['file_id']}::{k}" for k in sorted(theirs - ours)]
assert not_labelled == [] and label_only == [], (not_labelled, label_only)
for entry in LABELS["files"]: # 이름도 이름표가 푼 그대로(찾는 차례 「파일id::key」 → 끝 조각)
named = {g["key"]: g["name_ko"] for g in entry["value_groups"]}
rows = tables._value_rows(entry, tables.ROOT / entry["path"], LABELS)
assert [r["@name"] for r in rows] == [named[r["@path"]] for r in rows], entry["file_id"]
def test_없는_파일_표는_404(client: TestClient) -> None:
for params in (
{"file": "nope", "table": "rows"},
{"file": "rates", "table": "nope"},
{"file": "rates", "table": "variables/rate_sanjae"}, # 값 묶음 — 표 아님
{"file": "../../config/config_db", "table": "rows"},
{"file": "002yr_01hr", "table": "features"}, # 강우 IDF 캐시 — 이름표 밖
):
assert client.get("/api/master-data/rows", params=params).status_code == 404, params
@@ -0,0 +1,32 @@
"""Z01 마스터 데이터 라우터 등록 — `main.py` 에 붙었고 시스템 관리자만 통과하는지.
(2026-09-15 브레인 Z01)
① 경로 둘(`/api/master-data/tree` · `/rows`)이 앱에 붙어 있음
② 보호 = 로그인 + 시스템 관리자 — 회사·프로젝트 보호는 안 붙음
(시스템 관리자는 회사가 없을 수 있음)
서버를 띄우지 않고 경로의 의존성만 훑음(`test_app_imports` 와 같은 방식).
"""
import importlib
from fastapi.routing import APIRoute
from common_util.common_util_auth import require_company, require_system_admin, verify_session
def _calls(dependant) -> set:
found = set()
for sub in dependant.dependencies:
found.add(sub.call)
found |= _calls(sub)
return found
def test_마스터_데이터_경로는_시스템_관리자만() -> None:
app = importlib.import_module("main").app
routes = {r.path: r for r in app.routes if isinstance(r, APIRoute)}
for path in ("/api/master-data/tree", "/api/master-data/rows"):
assert path in routes, f"라우터 경로가 없습니다: {path}"
calls = _calls(routes[path].dependant)
assert {verify_session, require_system_admin} <= calls
assert require_company not in calls
@@ -0,0 +1,315 @@
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘.
"""
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_Overrides as overrides
from Z01_MasterData import Z01_MasterData_Router as router_module
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
@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 test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
listed = client.get("/api/master-data/base-prices").json()["kinds"]
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert (
forest["group"] == "work_item" and forest["rows"] == 624
) # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
# 2026-09-18 합침 620 → 624 — 원문이 풀리며 6-7-1 교목 시비 갈래 다섯이 새로 섬(사라진 것 0)
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
assert {i["group"]: i["group_label"] for i in listed} == {
"base_price": "기초단가",
"pumsem_basis": "품셈 기준",
"work_item": "공종 축",
}
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
assert table["total"] == 624 and table["stats"] == {"work_items": 360, "units": 624}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
assert len({r["@id"] for r in rows}) == 624
assert (
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계"
modes = [r["mode"] for r in table["rules"]]
assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3
blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05")
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
# 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②)
assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함"
assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름"
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
# 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거.
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_forest", q="발파암 ›")
assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.
갈래도 산림과 같은 표 읽기로 폄(④ · 공종 997 → 줄 1,293 · 갈래 열쇠 `CW-00063#01`)."""
table = _get(client, "work_item_const", size=500, axis_role="")
# 2026-09-18 997/1,293 그대로 — 앞서 991/1,287 로 보였던 것은 **산출 파일이 옛 것**이어서였다
# (기계 다리가 선 뒤 다시 지으니 제자리로 돌아옴).
# 2026-09-18 랩탑 메인 — 건설 표 모양 판정층으로 미판정 891 → 276 이 되며 품 표가 늘어 줄도 늘었다.
# 2026-09-18 합침 — 다른 창이 원문 md 의 뭉친 줄·표를 더 풀어(건설 표 2,202 → 2,270) 공종·줄이 또 늘었다.
# 값이 바뀐 것이 아니라 **원문에 있던 표가 더 읽힌 것**이다(직접공사비 5,983,724 그대로).
assert table["stats"] == {"work_items": 1046, "units": 1503} and table["total"] == 1503
assert sum(1 for r in table["rows"] if "#" in r["@id"]) > 0
rows = table["rows"]
assert all(r["@id"].startswith("CW-") for r in rows)
assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows)
assert {r["mode"] for r in table["rules"]} == {"choose_one"}
assert not any("불변 열쇠" in line for line in table["notice"])
found = _get(client, "work_item_const", q="굴착(인력/토사)")
assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"]
def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
folder = tmp_path / "data_work_item_master"
folder.mkdir()
fake = {
"dataset_version": {"dataset_id": "pum_const"},
"effective_date": "2026-01-01",
"work_items": [
{"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1",
"name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps",
"steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []},
{"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1",
"name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}],
"variant_keys": ["갑", "을"],
"variants": [{"variant_key": "01", "name": "갑", "name_clean": "갑"},
{"variant_key": "02", "name": "을", "name_clean": "을"}]},
],
} # fmt: skip
(folder / "const_work_item_master_2026-01-01.json").write_text(
json.dumps(fake, ensure_ascii=False), encoding="utf-8"
)
monkeypatch.setattr(work_items, "FOLDER", folder)
table = _get(client, "work_item_const")
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
assert table["rows"][0]["name"] == "토공 › 인력"
assert table["stats"] == {"work_items": 1, "units": 2}
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
res = client.put(
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
json={"values": {"name": "바꿈"}},
)
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표)."""
const = _get(client, "work_item_const", size=1, axis_role="")
by_key = {f["key"]: f for f in const["filters"]}
assert set(by_key) == {"link", "division", "axis_role"}
division = by_key["division"]
assert division["default"] == ""
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
assert [o["value"] for o in division["options"][1:]] == [
"공통부문",
"토목부문",
"건축부문",
"기계설비부문",
"유지관리부문",
]
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`).
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
"general_provision"
] == "공종 아님 · 규정·기준"
assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아님
civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="")
assert civil["total"] == next(
o["rows"] for o in division["options"] if o["value"] == "토목부문"
)
assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"])
rules = _get(
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
)
assert rules["total"] > 0
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
assert all(
r["name"].startswith(("공통부문 › 적용기준", "공통부문 › 건설기계")) for r in rules["rows"]
)
role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}
# 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳
assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]}
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
"link",
"axis_role",
]
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None:
"""브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄.
기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음)."""
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
link_doc = json.loads(
sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8")
)
confirmed = {
key
for link in link_doc["links"]
if link["status"] == "확정"
for key in (link["forest_key"], link["const_key"])
if key
}
unconfirmed = {
key
for link in link_doc["links"]
if link["status"] != "확정"
for key in (link["forest_key"], link["const_key"])
if key
} - confirmed
for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")):
every = _get(client, kind, size=500, axis_role="")
rows = [
row
for page in range(1, every["total"] // 500 + 2)
for row in _get(client, kind, size=500, page=page, axis_role="")["rows"]
]
assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임
marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로
listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖
assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed}
assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상
assert not marked & unconfirmed
only = _get(client, kind, size=500, link="연결됨", axis_role="")
assert {r["@key"] for r in only["rows"]} == marked
box = next(f for f in every["filters"] if f["key"] == "link")
assert box["default"] == "" and box["options"][1]["rows"] == only["total"]
def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None:
"""2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름."""
rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"]
assert [r["variant"] for r in rows] == [
"1.0 이하", "1.1∼1.5", "1.6∼2.0", "2.1∼2.5", "2.6∼3.0",
"3.1∼3.5", "3.6∼4.0", "4.1∼4.5", "4.6∼5.0",
] # fmt: skip
# 열쇠에 갈래 **글자**를 담지 않는다 — 원문 물결표·자간이 다듬어지면 열쇠가 갈린다(브레인).
assert rows[1]["@id"] == "FW-00105#02" and rows[1]["@key"] == "FW-00105"
assert rows[1]["variant"] == "1.1∼1.5" # 글자는 보일 이름 칸으로
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]
# 갈래 이름으로도 찾힘
assert _get(client, "work_item_forest", q="2.1∼2.5")["total"] >= 1
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
for kind in ("work_item_forest", "work_item_const"):
shown = _get(client, kind, size=500)
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
assert role["default"] == "work_item"
counts = {o["value"]: o["rows"] for o in role["options"]}
assert counts["general_provision"] > 0
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
assert shown["total"] == counts["work_item"]
assert (
_get(client, kind, size=500, q="적용기준")["total"]
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
)
# 「전부」(빈 값을 보냄) → 총칙까지
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
assert (
_get(client, kind, size=1, axis_role="general_provision")["total"]
== (counts["general_provision"])
)
def test_찾기는_물결표_가운뎃점_띄어쓰기를_같게_봄(client: TestClient) -> None:
"""원문이 물결표를 둘(∼·~)로 쓰고 자간 공백이 들쭉날쭉 — 사람이 친 「~」·붙여 쓴 이름으로도 찾힘.
⚠ 찾기만 — 줄 값·열쇠는 원문 그대로(서브 훑기 · 브레인 2026-09-17)."""
wavy = _get(client, "work_item_forest", size=50, q="2.1∼2.5")["total"]
assert wavy >= 1
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
rows = _get(client, "work_item_forest", size=50, q="2.1~2.5")["rows"]
assert all("~" not in r["@id"] for r in rows) # 열쇠는 손질 안 함
spaced = _get(client, "work_item_forest", size=50, q="토사 깍기", axis_role="")["total"]
assert (
spaced == _get(client, "work_item_forest", size=50, q="토사깍기", axis_role="")["total"] > 0
)
def test_이름_다듬기는_보일_이름만_열쇠는_그대로(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""⭐ 갈래 불변 열쇠를 세운 까닭(브레인 2026-09-17) — 자간 공백(「측 량」·「평 규 준 틀」)을 다듬어도
줄 열쇠(`FW-…#01`)가 한 글자도 안 흔들림. 다듬기를 끈 목록과 켠 목록의 열쇠를 맞댐."""
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Keys as keys
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
for kind in work_items.KINDS:
tidy = work_items.base_rows(kind)
with monkeypatch.context() as patch:
patch.setattr(keys, "tidy_spacing", lambda text: " ".join(str(text or "").split()))
raw = work_items.base_rows(kind)
assert [r["@id"] for r in tidy] == [r["@id"] for r in raw]
if kind == "work_item_const":
assert (
sum(a["name"] != b["name"] for a, b in zip(tidy, raw)) > 0
) # 실제로 다듬어진 줄이 있음
shown = _get(client, "work_item_const", size=50, q="기준점 측량", axis_role="")["rows"]
assert shown and all("측 량" not in r["name"] and "› 측량 ›" in r["name"] for r in shown)
def test_품셈_번호는_마디마다_수로_세움(client: TestClient) -> None:
"""글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기) — 마디마다 수로."""
rows = _get(client, "work_item_forest", size=500, sort="number", axis_role="")["rows"]
numbers = [tuple(int(x) for x in r["number"].split("-")) for r in rows if "-" in r["number"]]
assert len(numbers) > 300 and numbers == sorted(numbers)
def test_건설_늘_빈_칸은_감춤(client: TestClient) -> None:
"""건설은 합산형 선언이 없어 「단계 무게」가 늘 빈칸 — 감춤(산림은 보임)."""
hidden = lambda kind: {c["key"]: c["hidden"] for c in _get(client, kind, size=1)["columns"]} # noqa: E731
assert hidden("work_item_const")["step_weight"] is True
assert hidden("work_item_forest")["step_weight"] is False