feat(M01): 마스터 데이터 관리자 API 서버 쪽 — 읽기 · 시험 계산 · 저장
- M01_MasterData 라우터 + 파일 저장소 (/api/m01 · 시스템관리자 전용) - 저장 = 판본 대조(409) → 적용 → 틀·로직 변수 검사(새 걸림 422) → 씀 - check_master.py 에 load(folder) · check_saved · calc 드러냄 - 계약 문서 장 이름 보정(공통3장) - 시험 test_m01_api.py 8개 (임시 폴더 사본) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
|
||||
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
|
||||
- `q` = 이름 찾기 — `열쇠`·`이름` 에 든 글(대소문자 무시).
|
||||
- `chapter` = 파일 이름의 장(`13장` · `01장`) · 없으면 "".
|
||||
- `chapter` = 파일 이름의 장(`공통3장` · `13장` · `01장`) · 없으면 "".
|
||||
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
|
||||
|
||||
## 3. 시험 계산
|
||||
|
||||
@@ -59,10 +59,10 @@ ROW_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def load(names: list[str] | None = None) -> dict[str, dict]:
|
||||
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal."""
|
||||
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
|
||||
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal. `folder` = 마스터 폴더(M01 시험은 사본)."""
|
||||
out = {}
|
||||
for path in sorted(MASTER.glob("*.json")):
|
||||
for path in sorted(Path(folder).glob("*.json")):
|
||||
if path.name.startswith("_") or (
|
||||
names and path.name not in names and path.stem not in names
|
||||
):
|
||||
@@ -73,8 +73,19 @@ def load(names: list[str] | None = None) -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
def master() -> mf.Master:
|
||||
return mf.Master(load())
|
||||
def master(folder: Path = MASTER) -> mf.Master:
|
||||
return mf.Master(load(folder=folder))
|
||||
|
||||
|
||||
def check_saved(files: dict[str, dict], changed: list[str]) -> list[str]:
|
||||
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수. `files` = 폴더 전부(고친 뒤)."""
|
||||
out = [x for name in changed for x in check_form(name, files[name])]
|
||||
return out + check_logics(files, mf.Master(files))
|
||||
|
||||
|
||||
def calc(files: dict[str, dict], ref: str, given: dict) -> dict:
|
||||
"""시험 계산(M01) — `ref` = 「원문:로직 열쇠」 · 멈추면 FormulaError."""
|
||||
return mf.run(mf.Master(files), ref, given)
|
||||
|
||||
|
||||
# ── (1) 틀 ────────────────────────────────────────────────────────────
|
||||
@@ -228,7 +239,12 @@ def check_body(files: dict[str, dict]) -> list[str]:
|
||||
if not text:
|
||||
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}」")
|
||||
continue
|
||||
mine = _flat(table.get("줄")) | _flat(table.get("기준")) | _flat(table.get("주")) | _flat(table.get("값칸"))
|
||||
mine = (
|
||||
_flat(table.get("줄"))
|
||||
| _flat(table.get("기준"))
|
||||
| _flat(table.get("주"))
|
||||
| _flat(table.get("값칸"))
|
||||
)
|
||||
fake = mine - numbers_in(text)
|
||||
if fake:
|
||||
out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}")
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""M01 마스터 데이터 API — 계약 `resources/master_data/_화면_계약.md`.
|
||||
|
||||
임시 폴더에 master_data 첫 층 JSON 사본을 두고 읽기 · 시험 계산 · 저장 · 409 · 422.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
REAL = store.FOLDER
|
||||
LABOR = "인력_건설노임.json"
|
||||
LOGIC_BOOK, LOGIC_KEY = (
|
||||
"건설품셈",
|
||||
"3-3-1 암발파(미진동굴착 TYPE-Ⅰ)",
|
||||
) # 화약취급공 1016 · 보통인부 1002
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
for path in REAL.glob("*.json"):
|
||||
if not path.name.startswith("_"):
|
||||
shutil.copy(path, tmp_path / path.name)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _get(client: TestClient, url: str, **params) -> dict:
|
||||
res = client.get(url, params=params)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _calc(client: TestClient, **inputs) -> dict:
|
||||
res = client.post(
|
||||
"/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": inputs}
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _labor_row(client: TestClient, key: str) -> tuple[dict, str]:
|
||||
got = _get(client, "/api/m01/rows", file=LABOR, q="", size=500)
|
||||
return next(r for r in got["rows"] if r["열쇠"] == key), got["version"]
|
||||
|
||||
|
||||
def test_그룹_파일_줄_쪽나눔_찾기(client: TestClient) -> None:
|
||||
groups = {g["group"]: g for g in _get(client, "/api/m01/groups")["groups"]}
|
||||
assert list(groups) == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"]
|
||||
files = _get(client, "/api/m01/groups/인력/files")["files"]
|
||||
assert LABOR in [f["file"] for f in files]
|
||||
assert groups["인력"]["files"] == len(files)
|
||||
page2 = _get(client, "/api/m01/rows", file=LABOR, page=2, size=10)
|
||||
assert page2["page"] == 2 and len(page2["rows"]) == 10 and page2["total"] > 20
|
||||
hit = _get(client, "/api/m01/rows", file=LABOR, q="보통인부")
|
||||
assert [r["열쇠"] for r in hit["rows"]] == ["1002"]
|
||||
assert client.get("/api/m01/rows", params={"file": "없음.json"}).status_code == 404
|
||||
assert client.get("/api/m01/rows", params={"file": "../main.py"}).status_code == 404
|
||||
|
||||
|
||||
def test_표형_요소_표_목록과_표_하나(client: TestClient) -> None:
|
||||
file = "소요량_건설품셈_공통3장.json"
|
||||
listed = _get(client, "/api/m01/tables", file=file, q="암발파")
|
||||
one = next(t for t in listed["tables"] if t["열쇠"] == LOGIC_KEY)
|
||||
assert one["count"] >= 1 and "줄" not in one
|
||||
table = _get(client, "/api/m01/table", file=file, key=LOGIC_KEY)["table"]
|
||||
assert len(table["줄"]) == one["count"] and table["값칸"] == one["값칸"]
|
||||
assert client.get("/api/m01/tables", params={"file": LABOR}).status_code == 400
|
||||
|
||||
|
||||
def test_로직_목록_하나_시험계산(client: TestClient) -> None:
|
||||
listed = _get(client, "/api/m01/logics", book=LOGIC_BOOK, chapter="공통3장", q="암발파")[
|
||||
"logics"
|
||||
]
|
||||
assert LOGIC_KEY in [x["열쇠"] for x in listed]
|
||||
assert all(x["chapter"] == "공통3장" and not x["blocked"] for x in listed)
|
||||
assert _get(client, "/api/m01/logics", blocked=1)["logics"] == []
|
||||
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
|
||||
assert one["file"] == "로직_건설품셈_공통3장.json" and one["blocked"] is False
|
||||
got = _calc(client)
|
||||
assert got["ok"] is True
|
||||
lines = {x["이름"]: x for x in got["lines"]}
|
||||
assert Decimal(str(lines["화약취급공"]["금액"])) == Decimal(
|
||||
str(lines["화약취급공"]["수량"])
|
||||
) * Decimal(str(lines["화약취급공"]["단가"]))
|
||||
assert got["sums"]["계"] == pytest.approx(sum(x["금액"] for x in got["lines"]))
|
||||
stop = _calc(client, 없는입력=1)
|
||||
assert stop["ok"] is False and "모르는 입력" in stop["reason"]
|
||||
|
||||
|
||||
def test_저장_고침은_파일에_쓰고_낡은_판본은_409(client: TestClient) -> None:
|
||||
row, version = _labor_row(client, "1016")
|
||||
before = _calc(client)["sums"]["계"]
|
||||
edited = {**row, "값": row["값"] + 1000}
|
||||
body = {
|
||||
"files": [
|
||||
{
|
||||
"file": LABOR,
|
||||
"version": version,
|
||||
"changes": [{"op": "edit", "key": "1016", "row": edited}],
|
||||
}
|
||||
]
|
||||
}
|
||||
res = client.post("/api/m01/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
new_version = res.json()["files"][0]["version"]
|
||||
assert new_version != version
|
||||
assert _labor_row(client, "1016") == (edited, new_version)
|
||||
assert _calc(client)["sums"]["계"] == pytest.approx(before + 1000 * 0.04) # 화약취급공 0.040 인
|
||||
data = json.loads((store.FOLDER / LABOR).read_text(encoding="utf-8"))
|
||||
assert (
|
||||
data["그룹"] == "인력"
|
||||
and len(data["줄"]) == _get(client, "/api/m01/rows", file=LABOR)["total"]
|
||||
)
|
||||
again = client.post("/api/m01/save", json=body) # 옛 판본 그대로
|
||||
assert again.status_code == 409 and again.json()["detail"]["stale"] == [LABOR]
|
||||
|
||||
|
||||
def test_저장_검사에_걸리면_아무것도_안_씀(client: TestClient) -> None:
|
||||
_, version = _labor_row(client, "1016")
|
||||
raw = (store.FOLDER / LABOR).read_bytes()
|
||||
gone = {"file": LABOR, "version": version, "changes": [{"op": "delete", "key": "1016"}]}
|
||||
res = client.post("/api/m01/save", json={"files": [gone]})
|
||||
assert res.status_code == 422 and any("1016" in x for x in res.json()["detail"]["errors"])
|
||||
bad_form = {**_labor_row(client, "1002")[0], "열쇠": "9999"}
|
||||
del bad_form["출처"]
|
||||
res = client.post(
|
||||
"/api/m01/save", json={"files": [{**gone, "changes": [{"op": "add", "row": bad_form}]}]}
|
||||
)
|
||||
assert res.status_code == 422 and any("출처" in x for x in res.json()["detail"]["errors"])
|
||||
dup = {**gone, "changes": [{"op": "add", "row": _labor_row(client, "1002")[0]}]}
|
||||
assert client.post("/api/m01/save", json={"files": [dup]}).status_code == 400
|
||||
assert (store.FOLDER / LABOR).read_bytes() == raw
|
||||
|
||||
|
||||
def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
|
||||
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
|
||||
copy = {**one["logic"], "열쇠": LOGIC_KEY + " 사본"}
|
||||
add = {"file": one["file"], "version": one["version"], "changes": [{"op": "add", "row": copy}]}
|
||||
res = client.post("/api/m01/save", json={"files": [add]})
|
||||
assert res.status_code == 200, res.text
|
||||
assert _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=copy["열쇠"])["blocked"] is False
|
||||
drop = {
|
||||
"file": one["file"],
|
||||
"version": res.json()["files"][0]["version"],
|
||||
"changes": [{"op": "delete", "key": copy["열쇠"]}],
|
||||
}
|
||||
assert client.post("/api/m01/save", json={"files": [drop]}).status_code == 200
|
||||
assert (
|
||||
client.get("/api/m01/logic", params={"book": LOGIC_BOOK, "key": copy["열쇠"]}).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
def test_쓰기_모양은_읽은_값과_같음() -> None:
|
||||
for name in ("소요량_건설품셈_기계설비13장.json", "로직_건설품셈_토목2장.json", LABOR):
|
||||
data = json.loads((REAL / name).read_text(encoding="utf-8"), parse_float=Decimal)
|
||||
text = store.dump(data)
|
||||
assert json.loads(text, parse_float=Decimal) == data
|
||||
assert "\r" not in text and text.endswith("}\n")
|
||||
|
||||
|
||||
def test_main_은_시스템관리자만() -> None:
|
||||
main = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8")
|
||||
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
|
||||
Reference in New Issue
Block a user