Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
604 lines
28 KiB
Python
604 lines
28 KiB
Python
"""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)
|
|
assert rate["total"] == 218 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
|
|
)
|