- M01_SL_Router.py — /api/m01/sl/list · get · save · delete · outside · 자리 SheetLogic/_data(git 밖) · 키 SL-0001 꼴 - 저장 때 바깥 값(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 common_util_spreadsheet(Node)로 다시 셈 · 브라우저 계산값 버림 - 단가 · 장비 · 기존 로직은 /calc(Store.calc)와 같은 셈 · 표는 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」) - main.py 등록 한 문장(시스템 관리자 · 폴더째 지우면 이 줄도 지움) - M01_SL_Api.ts 몸 — slList · slGet · slSave · slDelete · slOutside · slPlain(EasyCalc plainError) - 시험 test_m01_sl_router.py — 저장 · 열기 · 목록 · 지우기 · 서버 다시 셈 · 바깥 값 · 403 · 마스터 사본 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
219 lines
8.7 KiB
Python
219 lines
8.7 KiB
Python
"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5.
|
||
|
||
· 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴
|
||
· 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림
|
||
· 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류
|
||
· 권한 없음 403 · 마스터 사본 파일은 안 바뀜
|
||
견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import shutil
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi import Depends, FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from common_util.common_util_auth import require_system_admin, verify_session
|
||
from M01_MasterData import M01_MasterData_Router as router_module
|
||
from M01_MasterData import M01_MasterData_Store as store
|
||
from M01_MasterData.SheetLogic import M01_SL_Router as sl
|
||
|
||
REAL = store.FOLDER
|
||
OWN = "로직_자체.json"
|
||
SAMPLE = "QF999997"
|
||
TABLE = {
|
||
"키": SAMPLE,
|
||
"원문번호": "",
|
||
"구분": "",
|
||
"상세구분": "",
|
||
"이름": "견본 높이 표",
|
||
"기준": "1㎡",
|
||
"출처": "자체",
|
||
"조건": {"돌": "고르기", "높이": "범위"},
|
||
"범위규칙": "초과·이하",
|
||
"값칸": {"석공": "인"},
|
||
"줄": [
|
||
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5},
|
||
{"돌": "깬돌", "높이": [3, None], "석공": 0.7},
|
||
],
|
||
"주": [],
|
||
}
|
||
NO_NODE = shutil.which("node") is None
|
||
|
||
|
||
def _labor() -> tuple[str, Decimal]:
|
||
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
|
||
row = next(
|
||
r
|
||
for r in rows
|
||
if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float))
|
||
)
|
||
return row["키"], Decimal(str(row["값"]))
|
||
|
||
|
||
@pytest.fixture
|
||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||
master = tmp_path / "master"
|
||
master.mkdir()
|
||
for path in REAL.glob("*.json"):
|
||
if path.name == OWN:
|
||
continue
|
||
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
||
shutil.copy(path, master / path.name)
|
||
host = sorted(master.glob("소요량_*.json"))[0]
|
||
data = json.loads(host.read_text(encoding="utf-8"))
|
||
data["표"].append(TABLE)
|
||
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
monkeypatch.setattr(store, "FOLDER", master)
|
||
monkeypatch.setattr(sl, "DATA", tmp_path / "_data")
|
||
return master
|
||
|
||
|
||
@pytest.fixture
|
||
def client(folder: Path) -> TestClient:
|
||
app = FastAPI()
|
||
app.include_router(router_module.router)
|
||
app.include_router(sl.router)
|
||
return TestClient(app)
|
||
|
||
|
||
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 _logic(labor: str, key: str | None = None) -> dict:
|
||
"""노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리."""
|
||
return {
|
||
"key": key,
|
||
"이름": "견본 시트 로직",
|
||
"결과단위": "원/㎡",
|
||
"book": {
|
||
"종류": "통합문서",
|
||
"판": 1,
|
||
"서식": [{}],
|
||
"시트": [
|
||
{"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||
{"id": "s2", "이름": "표", "칸": {}},
|
||
],
|
||
"계산값": {"s1": {"B1": {"수": "999"}}},
|
||
},
|
||
"outside": [
|
||
{"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"},
|
||
{"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"},
|
||
],
|
||
"상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌
|
||
}
|
||
|
||
|
||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||
def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None:
|
||
labor, price = _labor()
|
||
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
|
||
|
||
saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||
assert saved["key"] == "SL-0001"
|
||
book = saved["logic"]["book"]
|
||
assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음
|
||
assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림)
|
||
table = book["시트"][1]["칸"]
|
||
assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [
|
||
"돌",
|
||
"높이·아래",
|
||
"높이·위",
|
||
"석공",
|
||
]
|
||
assert (
|
||
"B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7
|
||
) # 빈 끝은 빈 칸
|
||
assert saved["logic"]["상자"] == [{"id": "x", "종류": "계산"}]
|
||
assert [v["id"] for v in saved["values"]] == ["a", "b"]
|
||
|
||
second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||
assert second["key"] == "SL-0002"
|
||
renamed = {**saved["logic"], "이름": "고친 이름"}
|
||
assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001"
|
||
|
||
listed = client.get("/api/m01/sl/list").json()["logics"]
|
||
assert [(x["key"], x["이름"]) for x in listed] == [
|
||
("SL-0001", "고친 이름"),
|
||
("SL-0002", "견본 시트 로직"),
|
||
]
|
||
got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"]
|
||
assert got["book"]["계산값"] == book["계산값"]
|
||
|
||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"})
|
||
assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404
|
||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404)
|
||
_post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404)
|
||
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "이름": " "}}, want=400)
|
||
assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400
|
||
|
||
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
|
||
assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"]
|
||
|
||
|
||
def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||
labor, price = _labor()
|
||
made = _post(
|
||
client,
|
||
"/api/m01/logic/new",
|
||
{
|
||
"logic": {
|
||
"원문번호": "",
|
||
"이름": "견본 노무",
|
||
"결과단위": "원/㎡",
|
||
"출처": "자체",
|
||
"입력": [{"이름": "품"}],
|
||
"중간": [],
|
||
"호표": [
|
||
{
|
||
"종류": "인력",
|
||
"요소": labor,
|
||
"이름": "석공",
|
||
"단위": "인",
|
||
"수량": "품",
|
||
"비목": "노무비",
|
||
}
|
||
],
|
||
"덧줄": [],
|
||
"끝수": None,
|
||
}
|
||
},
|
||
)
|
||
items = [
|
||
{"id": "t", "kind": "표", "table": SAMPLE},
|
||
{"id": "l", "kind": "노임", "key": labor},
|
||
{"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}},
|
||
{"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"},
|
||
{"id": "w", "kind": "자재", "key": labor},
|
||
{"id": "x", "kind": "표", "table": "QF999996"},
|
||
{"id": "z", "kind": "모름"},
|
||
]
|
||
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
|
||
assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"]
|
||
assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]]
|
||
assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"]
|
||
assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price
|
||
assert got["g2"]["value"] == 0
|
||
assert [got[k]["ok"] for k in ("w", "x", "z")] == [False, False, False]
|
||
|
||
|
||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||
app = FastAPI()
|
||
app.include_router(
|
||
sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
|
||
)
|
||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
|
||
client = TestClient(app)
|
||
assert client.get("/api/m01/sl/list").status_code == 403
|
||
assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403
|
||
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
|
||
assert client.get("/api/m01/sl/list").json() == {"logics": []}
|