Files
Aislo/resources/tester/test_z01_base_prices.py
T
eomsangdonandClaude Opus 5 6fe4056d98 feat(Z01): 기초단가 정본을 DB 로 — 저장소 신설 · 일괄 저장·줄 추가/삭제·초기값 되돌리기 API · 덮개 층 삭제
- Z01_MasterData_Repository_BasePrices.py 신설 — master_base_price·master_revision Raw SQL(읽기·줄 수·판번호·저장·되돌리기) · 판번호 줄을 잠그고 다시 봄 · 틀리면 통째로 되돌림
- GET /base-prices/{kind} 가 DB 를 읽음 · 응답에 revision · 줄마다 changed_columns·is_added 추가
- PUT /base-prices/{kind} {base_revision, edits, added, deleted} — 판 다르면 409 · 하나라도 틀리면 아무것도 안 씀 · edits 500 상한 · 모르는 칸 거절
- POST /base-prices/{kind}/reset {row_keys} — 주입 줄은 data := seed(지운 줄도 되살림) · 추가 줄은 지움
- 기계 시간당 단가는 DB 노임·유가로 읽을 때마다 셈 · 제원은 줄 값에서(추가한 기종도 섬)
- 옛 덮개 층 삭제 — Z01_MasterData_Overrides.py · 칸 단위 PUT · /overrides·/overrides/clear
- 새 줄 열쇠는 서버가 줌(added/{난수}) · 주입 data 에 '@' 칸이 없어도 축 잠금은 파일 원본으로 섬
- 시험: DB 가짜(helper_z01_fake_repo.py) 로 API 손질 · 저장소 SQL 흐름은 가짜 커넥션으로 잼

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-18 23:53:50 +09:00

585 lines
28 KiB
Python

"""Z01 기초단가 다섯(노임·기계·자재·유가·요율) — DB 정본 + [저장] 일괄 저장(2026-09-18 PLAN 1-0 · 1-2).
틀: 정본 = DB `master_base_price`(data = 지금 값 · seed = 초기값) · 원본 파일 `resources/data_*` 는 **주입 재료로 읽기만** ·
[저장] 한 번 = {base_revision, edits, added, deleted} → 판이 다르면 409 · 하나라도 틀리면 아무것도 안 씀 ·
줄마다 changed_columns(초기값과 다른 칸)·is_added(관리자 추가 줄) · [초기값으로] = data := seed(추가 줄은 지움).
기계 시간당 단가는 계산값 — editable 에서 빼고 왜 못 고치나(locked) · 밑값(취득가·손료계수·노임·유가)을 고치면 따라 바뀜.
DB 는 `helper_z01_fake_repo` 가짜로 — 저장소 SQL 흐름(잠금·되돌림)은 맨 아래 가짜 커넥션으로 잼.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from helper_z01_fake_repo import FakeRepo, install
from Z01_MasterData import Z01_MasterData_Repository_BasePrices as repo
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 fake(monkeypatch: pytest.MonkeyPatch) -> FakeRepo:
return install(monkeypatch)
@pytest.fixture
def client(fake: FakeRepo) -> 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 _save(client: TestClient, kind: str, edits=(), added=(), deleted=(), base=None):
if base is None:
base = _get(client, kind, size=1)["revision"]
body = {
"base_revision": base,
"edits": [{"row_key": k, "column": c, "value": v} for k, c, v in edits],
"added": [{"data": d} for d in added],
"deleted": list(deleted),
}
return client.put(f"/api/master-data/base-prices/{kind}", json=body)
def _edit(client: TestClient, kind: str, row_id: str, column: str, value):
return _save(client, kind, edits=[(row_id, column, value)])
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
assert table["status"] == "success" and table["revision"] == 0, kind
ids = [r["@id"] for r in _get(client, kind, size=500)["rows"]]
assert len(ids) == len(set(ids)), kind
# 안 고친 줄 — 고친 칸 없음 · 추가 줄 아님 · 안쪽 칸(@data 따위)은 안 나감
assert all(r["changed_columns"] == [] and r["is_added"] is False for r in table["rows"])
assert not {k for r in table["rows"] for k in r if k in ("@data", "@added", "@changed")}
columns = {c["key"] for c in table["columns"]}
assert not {k for k in columns if k.startswith("@")}, kind # 줄 수준 칸은 열 목록 밖
assert not columns & {"changed_columns", "is_added"}, 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 = _edit(client, "machine", "0101-0007", "hourly_total_krw", 1)
assert res.status_code == 400 and table["locked"]["hourly_total_krw"] in res.json()["message"]
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_저장은_DB_에만_쓰고_원본_파일은_안_건드림(client: TestClient, fake: FakeRepo) -> None:
before = {p: _sha(p) for p in SOURCES}
res = _save(
client,
"labor",
edits=[("labor_const/1001", "daily_wage_krw", 230000)],
base=0,
)
assert res.status_code == 200, res.text
assert res.json() == {"status": "success", "revision": 1, "added": []}
row = _row(client, "labor", "labor_const/1001")
assert row["daily_wage_krw"] == 230000 and row["changed_columns"] == ["daily_wage_krw"]
stored = fake.by_key("labor")["labor_const/1001"]
assert stored["data"]["daily_wage_krw"] == 230000 and stored["seed"]["daily_wage_krw"] == 215907
assert fake.saved_by == [42] # 누가 고쳤나
assert _get(client, "labor", size=1)["revision"] == 1
assert _get(client, "oil", size=1)["revision"] == 0 # 판번호는 표마다
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"]
assert _edit(
client, "labor", f"labor_const/{operator}", "daily_wage_krw", wage + 80000
).is_success
assert _edit(client, "oil", "national_average/oil_diesel", "price_krw_per_l", 2000).is_success
price = base["price_thousand_krw"]
assert _edit(client, "machine", "0101-0007", "price_thousand_krw", price * 2).is_success
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)
# 노임·유가 고친 칸은 그 표 줄에 서고 기계 줄은 계산만 바뀜
assert after["changed_columns"] == ["price_thousand_krw"]
def test_계산_칸은_DB_에_안_씀(client: TestClient, fake: FakeRepo) -> None:
"""기계 줄을 고쳐도 저장 값은 입력 칸만 — 계산 칸이 굳어 들어가면 노임을 고쳐도 안 따라옴."""
_edit(client, "machine", "0101-0007", "loss_coefficient_per_hour", 0.001)
data = fake.by_key("machine")["0101-0007"]["data"]
assert data["loss_coefficient_per_hour"] == 0.001
assert not {"hourly_total_krw", "hourly_note", "fuel_price_krw_per_l"} & set(data)
def test_하나라도_틀리면_아무것도_안_씀(client: TestClient, fake: FakeRepo) -> None:
good = ("labor_const/1001", "daily_wage_krw", 230000)
for bad, status in (
(("labor_const/1002", "occupation_name", "x"), 400), # 잠긴 칸
(("labor_const/1002", "daily_wage_krw", -1), 400), # 나쁜 값
(("labor_const/0000", "daily_wage_krw", 1), 404), # 없는 줄
):
res = _save(client, "labor", edits=[good, bad])
assert res.status_code == status, (bad, res.text)
assert res.json()["status"] == "error"
res = _save(client, "labor", edits=[good], deleted=["labor_const/0000"])
assert res.status_code == 404
assert fake.revisions == {} and fake.saved_by == []
assert _row(client, "labor", "labor_const/1001")["daily_wage_krw"] == 215907
def test_낡은_판으로_저장하면_409(
client: TestClient, fake: FakeRepo, monkeypatch: pytest.MonkeyPatch
) -> None:
assert _save(
client, "labor", edits=[("labor_const/1001", "daily_wage_krw", 1)], base=0
).is_success
stale = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert stale.status_code == 409 and "다시 불러와" in stale.json()["message"]
assert _row(client, "labor", "labor_const/1002")["changed_columns"] == []
# 앞 검사를 지난 뒤 트랜잭션 안에서 판이 갈려도 409(저장소가 잠그고 다시 봄)
async def old_revision(kind: str) -> int:
return 0
monkeypatch.setattr(repo, "revision", old_revision)
raced = _save(client, "labor", edits=[("labor_const/1002", "daily_wage_krw", 2)], base=0)
assert raced.status_code == 409 and fake.revisions["labor"] == 1
def test_줄_추가_고치기_지우기(client: TestClient, fake: FakeRepo) -> None:
data = {"specification": "ZZ시험 규격, 특수", "unit": "개", "price_krw": 1234}
res = _save(client, "material", added=[data])
assert res.status_code == 200, res.text
(key,) = res.json()["added"]
assert key.startswith("added/") and res.json()["revision"] == 1
table = _get(client, "material", size=1, page=7000)
assert table["total"] == 7000
row = table["rows"][0] # 추가 줄은 맨 끝(넣은 차례)
assert (row["@id"], row["is_added"], row["changed_columns"]) == (key, True, [])
assert row["price_krw"] == 1234 and row["specification"] == "ZZ시험 규격, 특수"
assert _get(client, "material", q="ZZ시험")["total"] == 1
# 추가 줄은 글자 칸도 고침(사람이 넣은 이름) · 원래 줄의 글자 칸은 못 고침
assert _edit(client, "material", key, "specification", "시험 규격, 고침").is_success
assert _edit(client, "material", "10023392", "specification", "x").status_code == 400
for bad in ({"없는칸": 1}, {"price_krw": "비쌈"}, {"price_krw": -5}, {"unit": {"a": 1}}):
assert _save(client, "material", added=[bad]).status_code == 400, bad
assert _save(client, "material", added=[{}]).status_code == 422 # 빈 줄
gone = _save(client, "material", deleted=[key, "10023392"])
assert gone.status_code == 200
ids = {r["@id"] for r in _get(client, "material", q="육각볼트", size=500)["rows"]}
assert "10023392" not in ids and _get(client, "material", size=1)["total"] == 6998
assert fake.by_key("material")["10023392"]["deleted"] is True # 지운 표시만(되살릴 수 있게)
def test_초기값으로_되돌리기(client: TestClient, fake: FakeRepo) -> None:
_edit(client, "labor", "labor_const/1001", "daily_wage_krw", 230000)
added = _save(client, "labor", added=[{"occupation_name": "시험 직종", "daily_wage_krw": 1}])
(key,) = added.json()["added"]
_save(client, "labor", deleted=["labor_const/1002"])
res = client.post(
"/api/master-data/base-prices/labor/reset",
json={"row_keys": ["labor_const/1001", key, "labor_const/1002"]},
)
assert res.status_code == 200 and res.json() == {"status": "success", "revision": 4}
back = _row(client, "labor", "labor_const/1001")
assert back["daily_wage_krw"] == 215907 and back["changed_columns"] == []
assert _row(client, "labor", "labor_const/1002")["daily_wage_krw"] # 지운 줄도 되살림
assert _get(client, "labor", size=1)["total"] == 261 # 추가 줄은 지움
missing = client.post(
"/api/master-data/base-prices/labor/reset", json={"row_keys": ["labor_const/0000"]}
)
assert missing.status_code == 404 and "labor_const/0000" in missing.json()["message"]
empty = client.post("/api/master-data/base-prices/labor/reset", json={"row_keys": []})
assert empty.status_code == 422 # 통째로 되돌리기 막음
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 _save(client, "wage", base=0).status_code == 404
assert _save(client, "work_item_forest", base=0).status_code == 400 # 공종은 /work-items 몫
locked = _edit(client, "labor", "labor_const/1001", "hours_per_day", 9) # 숫자여도 잠긴 칸
assert locked.status_code == 400 and "공표 원문" in locked.json()["message"]
for bad in (True, -1, "220000", float("nan")):
body = {
"base_revision": 0,
"edits": [{"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": bad}],
}
res = client.put(
"/api/master-data/base-prices/labor",
content=json.dumps(body).encode(),
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 (
_edit(client, "rate", fee["@id"], "rate_percent", 1).status_code == 400
) # 그 줄엔 요율 칸 없음
assert Decimal(str(_row(client, "rate", "rate_sanjae")["rate_percent"])) == Decimal("3.56")
many = [("labor_const/1001", "daily_wage_krw", 1)] * 501
assert _save(client, "labor", edits=many).status_code == 422 # 한 번에 500 칸까지
extra = client.put(
"/api/master-data/base-prices/labor", json={"base_revision": 0, "values": {}}
)
assert extra.status_code == 422 # 모르는 칸은 거절(옛 모양으로 보내면 조용히 무시 안 함)
old = client.put(
"/api/master-data/base-prices/labor/labor_const/1001", json={"values": {"x": 1}}
)
assert old.status_code in (404, 405) # 옛 칸 단위 저장은 없어짐
assert client.get("/api/master-data/overrides").status_code == 404
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
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:
"""브레인 ② — 이름·판정은 한 곳에만. 합친 표 열 이름은 이름표 `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:
_edit(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) -> None:
"""카탈로그에 없는 기종 — 제원은 줄 값에서 · 운전경비 원단위가 없으니 손료만 서고 합계는 안 냄."""
data = {
"machine_name": "시험 굴삭기",
"price_thousand_krw": 100000,
"loss_coefficient_per_hour": 0.0001,
}
(key,) = _save(client, "machine", added=[data]).json()["added"]
row = _get(client, "machine", size=1, page=614)["rows"][0]
assert row["@id"] == key and row["hourly_loss_krw"] == pytest.approx(10000)
assert row["hourly_total_krw"] is None and row["hourly_note"]
assert _save(client, "machine", added=[{"hourly_total_krw": 1}]).status_code == 400 # 계산 칸
# ── 저장소 SQL 흐름 — 가짜 커넥션(DB 없이) ──────────────────────────────
class _Cursor:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql: str, params=()) -> None:
self.db.sql.append(" ".join(sql.split()))
async def fetchone(self):
return (self.db.revision,)
async def fetchall(self):
return [(key,) for key in self.db.keys]
class _Connection:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def cursor(self, *_):
return _Cursor(self.db)
async def begin(self) -> None:
self.db.log.append("begin")
async def commit(self) -> None:
self.db.log.append("commit")
async def rollback(self) -> None:
self.db.log.append("rollback")
class _Pool:
def __init__(self, db: SimpleNamespace) -> None:
self.db = db
def acquire(self):
return self
async def __aenter__(self):
return _Connection(self.db)
async def __aexit__(self, *_):
return False
def _writes(db: SimpleNamespace) -> list[str]:
return [
s
for s in db.sql
if s.startswith(("UPDATE master_base_price", "INSERT INTO master_base_price"))
]
def test_저장소는_판을_잠그고_보며_틀리면_되돌림(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=4, keys=[])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.RevisionConflict):
asyncio.run(repo.save("labor", 3, {"a": {"x": 1}}, {}, ["b"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.save("labor", 4, {"a": {"x": 1}}, {"added/1": {"y": 2}}, ["b"], 7)) == 5
assert db.log == ["begin", "commit"]
lock = next(i for i, s in enumerate(db.sql) if s.endswith("FOR UPDATE"))
bump = next(i for i, s in enumerate(db.sql) if s.startswith("UPDATE master_revision"))
writes = [db.sql.index(s) for s in _writes(db)]
assert len(writes) == 3 and lock < min(writes) and max(writes) < bump == len(db.sql) - 1
assert "SET is_deleted = 1" in _writes(db)[-1] # 지우기는 표시만
def test_저장소_되돌리기는_없는_줄이면_아무것도_안_씀(monkeypatch: pytest.MonkeyPatch) -> None:
db = SimpleNamespace(sql=[], log=[], revision=2, keys=["a"])
monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(db))
with pytest.raises(repo.UnknownRows):
asyncio.run(repo.reset("labor", ["a", "없음"], 7))
assert db.log == ["begin", "rollback"] and not _writes(db)
db.sql.clear()
db.log.clear()
assert asyncio.run(repo.reset("labor", ["a", "a"], 7)) == 3
assert db.log == ["begin", "commit"]
assert [s.split(" SET ")[1][:13] for s in _writes(db)] == ["data = seed, ", "is_deleted = "]