PLAN 4-2. 조합 = 관리자가 이름을 붙이고 단가산출 로직을 골라 담은 묶음. 마스터에는 담은 로직 목록만 두고 수량·비율·계산은 두지 않음(4-0 확정 · 계산은 프로젝트 몫). - 새 파일 `일위대가조합.json`(빈 채 · 줄은 사용자가 화면에서 만듦) · 테이블ID `UA` · 원문이 없어 머리 `원문` 과 줄 `원문번호` 를 두지 않음(`mk.NO_NUMBER` 에 `UA`) - 엔진 `scripts/master_combo.py` — 검사 넷(없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음) · 「이 로직을 쓰는 조합」 거꾸로 찾기 · 미리 보기 계산 - `check_master.py` 에 「조합」 갈래 더함 · 저장 전 검사(`check_saved`)에도 얹어 422 로 막음 - 서버 `M01_MasterData_Store_Combo.py` — `GET /combos`·`/combo`·`/logic/combos` · `POST /combo/new`·`/edit`·`/delete`·`/preview`(저장 없음) - 조합은 요소 그룹이 아님 — `/groups` 는 여덟 그대로 두고 제 길로만 다룸 - 틀 `_틀.md` 10장 · 계약 `_화면_계약.md` 9장 · 시험 `test_m01_combo.py` 17 검증 — 견본 조합(13-4-1 · 13-4-2 · 13-2-1) 미리 보기 합계가 그 로직 셋의 시험 계산을 비목별로 더한 값과 같음 · 못 도는 줄은 그 줄만 멈추고 나머지 합계는 그대로 · 저장 전 조합 줄로도 미리 보기 되고 아무것도 안 생김 · 검사 넷 다 422 · 낡은 판본 409 · 정본 로직 안 바뀜 · `check_master 틀`·`조합` 0건 · pytest 44 통과(조합 17 + api 27) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaS48Nvs26o3aFUpcfJxVv
268 lines
12 KiB
Python
268 lines
12 KiB
Python
"""M01 일위대가 조합(PLAN 4-2) — 담은 단가산출 로직 목록만 두고 미리 보기로 비목별 합계.
|
|
|
|
계약 `resources/master_data/_화면_계약.md` 9장 · 틀 `_틀.md` 10장 · 엔진 `scripts/master_combo.py`.
|
|
못박는 것
|
|
- 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음.
|
|
- 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음).
|
|
- 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음.
|
|
- 견본 조합은 시험에서만 만들고 정본 파일에는 넣지 않음(정본은 빈 채로 둠).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sys
|
|
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
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
|
|
|
|
import master_combo as mcb # noqa: E402
|
|
|
|
REAL = store.FOLDER
|
|
STACK = "GF000219" # 13-4-1 돌쌓기 메쌓기(인력)
|
|
LAYER = "GF000220" # 13-4-2 돌쌓기 메쌓기(장비)
|
|
GATHER = "GF000211" # 13-2-1 모래·자갈·약돌 채집(인력)
|
|
SAMPLE = (STACK, LAYER, GATHER) # 견본 조합 — 로직 셋
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
|
for path in REAL.glob("*.json"):
|
|
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
|
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 _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
|
res = client.post(url, json=body)
|
|
assert res.status_code == want, res.text
|
|
return res.json()
|
|
|
|
|
|
def _inputs(key: str) -> dict:
|
|
"""그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`)."""
|
|
row = store.cm.master(REAL).logic(key)
|
|
return {k: str(v) for k, v in store.cm.시험입력(row).items()}
|
|
|
|
|
|
def _sums(client: TestClient, key: str, inputs: dict) -> dict:
|
|
got = _post(client, "/api/m01/calc", {"key": key, "inputs": inputs})
|
|
assert got["ok"] is True, got
|
|
return got["sums"]
|
|
|
|
|
|
def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: str = "현장") -> dict:
|
|
body = {
|
|
"이름": 이름,
|
|
"구분": "자체",
|
|
"상세구분": "13장 구조물",
|
|
"단위": "㎡",
|
|
"담은로직": [{"로직": k, "메모": None} for k in keys],
|
|
}
|
|
return _post(client, "/api/m01/combo/new", {"combo": body, "owner": owner})
|
|
|
|
|
|
# ── 정본 파일 ─────────────────────────────────────────────────────────
|
|
def test_정본_조합_파일은_빈_채로_있다() -> None:
|
|
"""줄은 사용자가 화면에서 만듦 — 견본은 시험에서만."""
|
|
data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE]
|
|
assert data["그룹"] == mcb.GROUP and data["줄"] == []
|
|
assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음
|
|
|
|
|
|
def test_조합_테이블ID_는_UA_이고_줄에_원문번호를_두지_않는다() -> None:
|
|
assert store.mk.table_id(mcb.GROUP, None) == "UA"
|
|
assert store.mk.GROUP_OF["UA"] == mcb.GROUP
|
|
assert "UA" in store.mk.NO_NUMBER
|
|
|
|
|
|
def test_조합은_요소_그룹이_아니다(client: TestClient) -> None:
|
|
"""조합은 제 길(`/combos`·`/combo`)로만 다룸 — 요소 길(`/groups`·`/rows`)에 끼지 않음.
|
|
|
|
끼우면 화면이 조합을 여느 요소 표처럼 `/rows` 로 펴 버림(줄 모양이 아주 다름).
|
|
"""
|
|
assert mcb.GROUP not in store.cm.mf.GROUPS
|
|
groups = [g["group"] for g in _get(client, "/api/m01/groups")["groups"]]
|
|
assert groups == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"]
|
|
assert client.get("/api/m01/groups/일위대가조합/files").status_code == 404
|
|
|
|
|
|
# ── 만들기 · 읽기 ─────────────────────────────────────────────────────
|
|
def test_조합을_만들면_키를_서버가_주고_담은_로직에_이름이_붙는다(client: TestClient) -> None:
|
|
made = _make(client)
|
|
key = made["key"]
|
|
assert key.startswith("UA"), made
|
|
assert made["combo"]["소유"] == "현장"
|
|
assert tuple(made["combo"]) == mcb.COLS # 열 한 벌 · 차례까지
|
|
assert "원문번호" not in made["combo"]
|
|
|
|
got = _get(client, "/api/m01/combo", key=key)
|
|
assert [one["로직"] for one in got["줄"]] == list(SAMPLE) # 담은 차례 그대로
|
|
assert got["줄"][0]["이름"] == "돌쌓기 메쌓기(인력)"
|
|
assert got["줄"][0]["결과단위"] == "원/㎡"
|
|
assert got["blocked"] is False and got["reasons"] == []
|
|
# 수량·비율·계산은 조합에 두지 않음
|
|
assert set(got["combo"]["담은로직"][0]) == {"로직", "메모"}
|
|
|
|
|
|
def test_목록은_구분_소유_찾기로_거른다(client: TestClient) -> None:
|
|
_make(client, 이름="돌쌓기 한 벌")
|
|
_make(client, keys=(GATHER,), 이름="채집만", owner="공용")
|
|
assert len(_get(client, "/api/m01/combos")["combos"]) == 2
|
|
assert [c["이름"] for c in _get(client, "/api/m01/combos", q="채집")["combos"]] == ["채집만"]
|
|
only = _get(client, "/api/m01/combos", owner="공용")["combos"]
|
|
assert [c["이름"] for c in only] == ["채집만"] and only[0]["count"] == 1
|
|
|
|
|
|
def test_이_로직을_쓰는_조합을_거꾸로_본다(client: TestClient) -> None:
|
|
first = _make(client, 이름="돌쌓기 한 벌")["key"]
|
|
second = _make(client, keys=(STACK,), 이름="메쌓기만")["key"]
|
|
got = _get(client, "/api/m01/logic/combos", key=STACK)
|
|
assert [c["키"] for c in got["combos"]] == [first, second]
|
|
assert got["combos"][0]["차례"] == [1] # 그 조합 안 몇 번째인지
|
|
assert _get(client, "/api/m01/logic/combos", key="GF000001")["combos"] == []
|
|
|
|
|
|
# ── 고치기 · 지우기 ───────────────────────────────────────────────────
|
|
def test_로직을_빼고_더하고_지울_수_있다(client: TestClient) -> None:
|
|
key = _make(client)["key"]
|
|
got = _get(client, "/api/m01/combo", key=key)
|
|
row = got["combo"]
|
|
row["담은로직"] = [{"로직": LAYER, "메모": "장비만 남김"}]
|
|
done = _post(
|
|
client, "/api/m01/combo/edit", {"key": key, "version": got["version"], "combo": row}
|
|
)
|
|
assert [one["로직"] for one in done["combo"]["담은로직"]] == [LAYER]
|
|
assert done["combo"]["담은로직"][0]["메모"] == "장비만 남김"
|
|
assert done["combo"]["키"] == key # 키는 안 바뀜
|
|
|
|
now = _get(client, "/api/m01/combo", key=key)
|
|
_post(client, "/api/m01/combo/delete", {"key": key, "version": now["version"]})
|
|
assert _get(client, "/api/m01/combos")["combos"] == []
|
|
|
|
|
|
def test_낡은_판본으로_고치면_409(client: TestClient) -> None:
|
|
key = _make(client)["key"]
|
|
row = _get(client, "/api/m01/combo", key=key)["combo"]
|
|
got = _post(
|
|
client,
|
|
"/api/m01/combo/edit",
|
|
{"key": key, "version": "낡은판본", "combo": row},
|
|
want=409,
|
|
)
|
|
assert got["detail"] == {"stale": [mcb.FILE]}
|
|
|
|
|
|
# ── 검사 ──────────────────────────────────────────────────────────────
|
|
def test_없는_로직_키는_저장을_막는다(client: TestClient) -> None:
|
|
got = _post(
|
|
client,
|
|
"/api/m01/combo/new",
|
|
{"combo": {"이름": "엉터리", "담은로직": [{"로직": "GF999999", "메모": None}]}},
|
|
want=422,
|
|
)
|
|
assert any("GF999999" in x for x in got["detail"]["errors"]), got
|
|
|
|
|
|
def test_같은_로직을_두_번_담으면_막는다(client: TestClient) -> None:
|
|
got = _post(
|
|
client,
|
|
"/api/m01/combo/new",
|
|
{"combo": {"이름": "두 번", "담은로직": [{"로직": STACK}, {"로직": STACK}]}},
|
|
want=422,
|
|
)
|
|
assert any("두 번 담음" in x for x in got["detail"]["errors"]), got
|
|
|
|
|
|
def test_빈_조합은_막는다(client: TestClient) -> None:
|
|
got = _post(client, "/api/m01/combo/new", {"combo": {"이름": "빈 것", "담은로직": []}}, 422)
|
|
assert any("빈 조합" in x for x in got["detail"]["errors"]), got
|
|
|
|
|
|
def test_조합은_조합을_담지_못한다_1단만(client: TestClient) -> None:
|
|
key = _make(client)["key"]
|
|
got = _post(
|
|
client,
|
|
"/api/m01/combo/new",
|
|
{"combo": {"이름": "겹친 것", "담은로직": [{"로직": key}]}},
|
|
want=422,
|
|
)
|
|
assert any("조합을 담지 못함" in x for x in got["detail"]["errors"]), got
|
|
|
|
|
|
def test_이름_없는_조합과_모르는_소유는_400(client: TestClient) -> None:
|
|
_post(client, "/api/m01/combo/new", {"combo": {"담은로직": [{"로직": STACK}]}}, want=400)
|
|
_post(
|
|
client,
|
|
"/api/m01/combo/new",
|
|
{"combo": {"이름": "가", "담은로직": [{"로직": STACK}]}, "owner": "아무개"},
|
|
want=400,
|
|
)
|
|
|
|
|
|
# ── 미리 보기 ─────────────────────────────────────────────────────────
|
|
def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None:
|
|
key = _make(client)["key"]
|
|
each = {k: _inputs(k) for k in SAMPLE}
|
|
want = {
|
|
name: sum(Decimal(str(_sums(client, k, each[k])[name])) for k in SAMPLE)
|
|
for name in ("노무비", "재료비", "경비", "계")
|
|
}
|
|
|
|
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": each})
|
|
assert got["멈춤"] == [], got["멈춤"]
|
|
assert [one["로직"] for one in got["줄"]] == list(SAMPLE)
|
|
for name in ("노무비", "재료비", "경비", "계"):
|
|
assert Decimal(str(got[name])) == want[name], name
|
|
assert Decimal(str(got["계"])) > 0
|
|
# 줄마다도 그 로직 하나의 시험 계산과 같음
|
|
for one in got["줄"]:
|
|
mine = _sums(client, one["로직"], each[one["로직"]])
|
|
assert Decimal(str(one["계"])) == Decimal(str(mine["계"]))
|
|
|
|
|
|
def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None:
|
|
before = _get(client, "/api/m01/combos")["combos"]
|
|
row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]}
|
|
got = _post(client, "/api/m01/combo/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}})
|
|
assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0
|
|
assert _get(client, "/api/m01/combos")["combos"] == before # 아무것도 안 생김
|
|
|
|
|
|
def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None:
|
|
key = _make(client, keys=(STACK, LAYER))["key"]
|
|
only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤
|
|
got = _post(client, "/api/m01/combo/preview", {"combo": key, "inputs": only})
|
|
stuck = [one for one in got["줄"] if not one["ok"]]
|
|
assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"]
|
|
assert len(got["멈춤"]) == 1
|
|
alone = _sums(client, STACK, only[STACK])
|
|
assert Decimal(str(got["계"])) == Decimal(str(alone["계"])) # 선 줄만 더함
|
|
|
|
|
|
def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None:
|
|
before = _get(client, "/api/m01/logic", key=STACK)
|
|
key = _make(client)["key"]
|
|
_post(
|
|
client, "/api/m01/combo/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}
|
|
)
|
|
after = _get(client, "/api/m01/logic", key=STACK)
|
|
assert after["version"] == before["version"] and after["logic"] == before["logic"]
|