- 새 요소 파일(우리 md 에서만 · 판 2026-01-01): 엔지니어링노임 56 · 측량노임 20 · 건설사업관리노임 4 · SW노임 17 · 산림노임 2 — 빌더 build_인력_노임.py · 줄 수·값 md 기계 대조
- 준용대상 49 → 48: 측량중급기술자 옮김(로직 참조 8) · 원문 통합 목록 5 직종 통합 칸(목도 · 특수비계공 · 함석공 · 갱부 · 스레이트공) · 절단공 갈림 · 비슷한 이름 21 후보
- 건설노임 미공표 14 산정{값 · 근거 · 마지막공표} — 원문 회차 평균 그대로 이음 · 석조각공·드잡이공편수 다산소프트와 이음 계열 다름 비고 · 2026-09-01 적용분 확보됨 비고
- 엔진 차례 값 → 준용 → 통합 → 산정 · 호표 출처 · M01 준용 후보 7 노임 파일 · 통합·산정·후보 칸 읽기 전용
- ref/_비교_estx.md 10절: ESTX 378 줄 중 371 짝 · 짝 없는 직종 42 · 일괄 시험 계산 통과 1104 → 1115
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
274 lines
13 KiB
Python
274 lines
13 KiB
Python
"""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
|
||
LOGIC_INPUTS = {"지역": "전국평균", "보정작업": "해당 없음"}
|
||
|
||
|
||
@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": {**LOGIC_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_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None:
|
||
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
|
||
labor = one["prices"]["인력:건설노임:1016"]
|
||
assert labor["이름"] == "화약취급공" and labor["값"] > 0
|
||
found = _get(client, "/api/m01/elements", group="인력", q="보통인부")
|
||
assert "인력:건설노임:1002" in [x["ref"] for x in found["items"]]
|
||
tables = _get(client, "/api/m01/elements", group="소요량", q="암발파", limit=5)
|
||
assert tables["items"] and all(x["값칸"] for x in tables["items"])
|
||
raw = (store.FOLDER / one["file"]).read_bytes()
|
||
before = _calc(client)["sums"]["계"]
|
||
half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌
|
||
res = client.post(
|
||
"/api/m01/calc",
|
||
json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": LOGIC_INPUTS, "row": half},
|
||
)
|
||
lines = res.json()["lines"]
|
||
assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"]["계"] < before
|
||
new = {**half, "열쇠": "새 로직"}
|
||
body = {
|
||
"book": LOGIC_BOOK,
|
||
"key": "새 로직",
|
||
"inputs": LOGIC_INPUTS,
|
||
"row": new,
|
||
"file": one["file"],
|
||
}
|
||
assert client.post("/api/m01/calc", json=body).json()["ok"] is True
|
||
assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀
|
||
|
||
|
||
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_시험계산_글로_온_수도_받음(client: TestClient) -> None:
|
||
def calc(**inputs):
|
||
body = {"book": "산림품셈", "key": "13-4-1 메쌓기", "inputs": inputs}
|
||
return client.post("/api/m01/calc", json=body).json()
|
||
|
||
base = {"돌": "견치돌", "쌓기": "골쌓기", "높이": 1, "초과증가율": 80}
|
||
assert calc(뒷길이=35, **base)["sums"]["계"] == pytest.approx(203281.2)
|
||
as_text = calc(**{**base, "뒷길이": "35", "높이": "1"})
|
||
assert as_text["ok"] is True and as_text["sums"]["계"] == pytest.approx(203281.2)
|
||
assert "고르기 밖" in calc(뒷길이="36", **base)["reason"]
|
||
|
||
|
||
def test_저장은_고친_줄만_바꿈(client: TestClient) -> None:
|
||
old = (store.FOLDER / LABOR).read_text(encoding="utf-8").split("\n")
|
||
row, version = _labor_row(client, "1016")
|
||
change = {"op": "edit", "key": "1016", "row": {**row, "값": row["값"] + 1}}
|
||
body = {"files": [{"file": LABOR, "version": version, "changes": [change]}]}
|
||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||
new = (store.FOLDER / LABOR).read_text(encoding="utf-8").split("\n")
|
||
assert len(new) == len(old) and sum(a != b for a, b in zip(old, new)) == 1
|
||
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
|
||
path = store.FOLDER / one["file"]
|
||
old = path.read_text(encoding="utf-8")
|
||
edited = {**one["logic"], "비고": "고침"}
|
||
change = {"op": "edit", "key": LOGIC_KEY, "row": edited}
|
||
body = {"files": [{"file": one["file"], "version": one["version"], "changes": [change]}]}
|
||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||
new = path.read_text(encoding="utf-8")
|
||
at = old.index(f'"열쇠": "{LOGIC_KEY}"')
|
||
head = old[: old.rfind("\n", 0, at)] # 고친 요소 앞은 글자 그대로
|
||
assert new.startswith(head) and "\r" not in new
|
||
tail = old[old.index('"열쇠"', at + 1) :] # 다음 요소부터도 그대로
|
||
assert new.endswith(tail)
|
||
|
||
|
||
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
|
||
|
||
|
||
def test_고르기_나라장터_시중물가_직종(client: TestClient) -> None:
|
||
nara = _get(client, "/api/m01/pick", kind="procure", q="육각볼트 M6*20")
|
||
assert nara["total"] >= 1 and nara["items"][0]["ref"].startswith("나라장터:")
|
||
market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=5)
|
||
assert market["items"] and market["items"][0]["관급"] and market["items"][0]["값"] > 0
|
||
job = _get(client, "/api/m01/pick", kind="job", q="보통인부")
|
||
assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == "인력:건설노임:1002"
|
||
assert (
|
||
_get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"]
|
||
== "인력:제조노임:1"
|
||
)
|
||
assert all(i["값"] is not None for i in _get(client, "/api/m01/pick", kind="job")["items"])
|
||
assert (
|
||
_get(client, "/api/m01/pick", kind="job", q="기술사(건설)")["items"][0]["ref"]
|
||
== "인력:엔지니어링노임:기술사(건설)"
|
||
)
|
||
|
||
|
||
def test_못_이은_줄만_거름(client: TestClient) -> None:
|
||
every = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500)
|
||
cut = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500, unlinked=1)
|
||
assert 0 < cut["total"] < every["total"] and not any(r["연결"] for r in cut["rows"])
|
||
|
||
|
||
def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClient) -> None:
|
||
key = "기계설비 13-2-4 강판 전기아크용접 전기아크용접(V형)"
|
||
one = _get(client, "/api/m01/logic", book="건설품셈", key=key)
|
||
power = one["prices"]["재료:품셈재료:전력"]
|
||
assert power["값"] > 0 and power["출처"].startswith("재료:시중물가:M0005250064.")
|