"""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 )