# ruff: noqa: E501 """흐름도 적용(PLAN 36-4 서버 · 정함 1 · 2 · 4 · 10 · 11 · 12) — 로직 줄 「흐름」 칸. 못박는 것(마스터 사본 폴더에서만 · 정본은 안 바뀜) - `/save` 줄에 실은 흐름 = 검사 → 서버 다시 셈(받는 칸에 값) → 파일에 씀 · 다시 읽으면 그대로 - 흐름 꼴이 틀리면(없는 알약 · 돌고 도는 선) 422 · 빈 받는 자리는 경고 - `/calc` 갈래 — 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · 노무비 · 재료비 · 경비 · 계) · 없으면 옛 셈 - 새 줄(키 없음) + 흐름 → 자체 파일 GX 다음 키 · 구분 자체 · 본떠 만들기는 흐름도 함께 - `GET /logic/flow` — 흐름 있으면 그대로 · 없으면 변환(준비 전이면 빈 흐름 + 「변환 준비 중」) - DataFlow 다른 로직 값 · 설계 입력이 마스터 키도 받음(흐름 · 옛 로직) · 서로 부르면 막음 - 시스템 관리자가 아니면 403 """ from __future__ import annotations import copy import hashlib import importlib.util 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 m01_router from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Flow as sflow from M02_MasterTemplete.DataFlow import M02_DF_Router as df REAL = store.FOLDER KEY = "GF000219" # 13-4-1 메쌓기 — 흐름을 얹어 볼 정본 로직 OLD = "GF000160" # 12-4 합판거푸집 — 흐름 없는 옛 로직 OWN = "로직_자체.json" def _flow(name: str = "넓이 품") -> dict: """길이 × 폭 × 1000 → 노무비 — 입력 상자(변수 둘) · 함수 상자 · 출력 상자.""" sheet = {"id": "s-fn", "이름": "넓이", "칸": {"A2": {"값": "길이"}, "A3": {"값": "폭"}, "A4": {"값": "금액"}, "B4": {"식": "B2*B3*1000"}}} # fmt: skip return { "key": None, "name": name, "resultUnit": "원/㎡", "userElements": [], "boxes": [ {"id": "in", "kind": "input", "name": "치수", "color": "", "unit": "-", "x": 0, "y": 0, "input": {"mode": "variable", "elements": [], "variables": [ {"name": "길이", "type": "number", "unit": "m", "example": 2}, {"name": "폭", "type": "number", "unit": "m", "example": 3}]}}, {"id": "fn", "kind": "function", "name": "넓이 금액", "color": "", "unit": "원", "x": 0, "y": 0, "func": {"sheet": sheet, "inputs": [{"port": "길이", "cell": "B2"}, {"port": "폭", "cell": "B3"}], "variables": [], "output": {"cell": "B4", "name": "금액", "unit": "원"}}}, {"id": "out", "kind": "output", "name": "노무비", "color": "", "unit": "원/㎡", "x": 0, "y": 0, "output": {"cost": "노무비", "unit": "원/㎡"}}, ], "links": [ {"id": "l1", "from": {"box": "in", "name": "길이"}, "to": {"box": "fn", "name": "길이"}}, {"id": "l2", "from": {"box": "in", "name": "폭"}, "to": {"box": "fn", "name": "폭"}}, {"id": "l3", "from": {"box": "fn", "name": "금액"}, "to": {"box": "out", "name": "값"}}, ], } # fmt: skip def _hashes(folder: Path) -> dict[str, str]: return {p.name: hashlib.sha1(p.read_bytes()).hexdigest() for p in folder.glob("*.json")} @pytest.fixture def folder(tmp_path: Path, monkeypatch) -> Path: master = tmp_path / "master" master.mkdir() for path in REAL.glob("*.json"): shutil.copy(path, master / path.name) monkeypatch.setattr(store, "FOLDER", master) monkeypatch.setattr(df, "DATA", tmp_path / "_data") return master @pytest.fixture def client(folder: Path) -> TestClient: app = FastAPI() app.include_router(m01_router.router) app.include_router(df.router) return TestClient(app) def _get(client: TestClient, url: str, want: int = 200, **params) -> dict: res = client.get(url, params=params) assert res.status_code == want, 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 _save_flow(client: TestClient, key: str, flow: dict, want: int = 200) -> dict: got = _get(client, "/api/m01/logic", key=key) row = {**got["logic"], "흐름": flow} part = {"file": got["file"], "version": got["version"], "changes": [{"op": "edit", "key": key, "row": row}]} # fmt: skip return _post(client, "/api/m01/save", {"files": [part]}, want) def test_흐름_저장_다시_셈_다시_읽기(client: TestClient, folder: Path) -> None: before = _hashes(REAL) done = _save_flow(client, KEY, _flow()) assert done["warnings"] == [] and done["added"] == [] saved = _get(client, "/api/m01/logic", key=KEY)["logic"]["흐름"] cells = saved["boxes"][1]["func"]["sheet"]["칸"] assert ( Decimal(str(cells["B2"]["값"])) == 2 and Decimal(str(cells["B3"]["값"])) == 3 ) # 서버가 넣은 값 assert "key" not in saved got = _get(client, "/api/m01/logic/flow", key=KEY) assert got["converted"] is False and got["notes"] == [] and got["flow"]["key"] == KEY assert [b["id"] for b in got["flow"]["boxes"]] == ["in", "fn", "out"] assert _hashes(REAL) == before # 사본 밖(정본)은 안 바뀜 def test_흐름_꼴_검사_422_와_빈_자리_경고(client: TestClient) -> None: bad = _flow() bad["links"][0]["from"]["name"] = "없는알약" assert "없는 알약" in str(_save_flow(client, KEY, bad, 422)) loop = _flow() loop["boxes"].append({**copy.deepcopy(loop["boxes"][1]), "id": "fn2", "name": "되돌이"}) loop["links"] += [ {"id": "l4", "from": {"box": "fn", "name": "금액"}, "to": {"box": "fn2", "name": "길이"}}, {"id": "l5", "from": {"box": "fn2", "name": "금액"}, "to": {"box": "fn", "name": "폭"}}, ] loop["links"] = [x for x in loop["links"] if x["id"] != "l2"] assert "돌고 도는" in str(_save_flow(client, KEY, loop, 422)) empty = _flow() empty["links"] = [x for x in empty["links"] if x["id"] != "l2"] warns = _save_flow(client, KEY, empty)["warnings"] assert any("넓이 금액 · 폭" in w for w in warns), warns def test_계산_갈래_흐름이면_흐름_셈_아니면_옛_셈(client: TestClient) -> None: _save_flow(client, KEY, _flow()) got = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {"길이": 4, "폭": "5"}}) assert got["flow"] is True and got["ok"] is True assert {k: Decimal(str(v)) for k, v in got["sums"].items()} == { "노무비": 20000, "재료비": 0, "경비": 0, "계": 20000} # fmt: skip assert ( got["outputs"][0]["name"] == "노무비" and Decimal(str(got["outputs"][0]["value"])) == 20000 ) plain = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}}) # 예시값 2 × 3 assert Decimal(str(plain["sums"]["계"])) == 6000 row = {**_get(client, "/api/m01/logic", key=KEY)["logic"], "흐름": _flow()} # 저장 전 줄 row["흐름"]["boxes"][1]["func"]["sheet"]["칸"]["B4"] = {"식": "B2*B3*10"} draft = _post(client, "/api/m01/calc", {"key": KEY, "inputs": {}, "row": row}) assert Decimal(str(draft["sums"]["계"])) == 60 old = _post(client, "/api/m01/calc", {"key": OLD, "inputs": {}}) assert "flow" not in old # 흐름 없는 로직은 옛 셈 그대로 def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClient, folder: Path) -> None: version = store.read(OWN)[1] if (folder / OWN).is_file() else "" row = {"키": "", "이름": "시험 흐름 로직", "결과단위": "원/㎡", "출처": "", "구분": "산림품셈", "흐름": _flow("시험 흐름 로직")} # fmt: skip part = {"file": OWN, "version": version, "changes": [{"op": "add", "row": row}]} done = _post(client, "/api/m01/save", {"files": [part]}) assert len(done["added"]) == 1 and done["added"][0]["file"] == OWN key = done["added"][0]["key"] assert key.startswith("GX") saved = _get(client, "/api/m01/logic", key=key)["logic"] assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직" assert ( Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"])) == 6000 ) made = _post(client, "/api/m01/logic/copy", {"key": key, "이름": "복사한 흐름"}) assert made["key"].startswith("GX") and made["key"] != key copied = _get(client, "/api/m01/logic/flow", key=made["key"]) assert copied["converted"] is False and len(copied["flow"]["boxes"]) == 3 bad = {"file": OWN, "version": store.read(OWN)[1], "changes": [{"op": "add", "row": row}]} bad["changes"][0]["row"] = {**row, "흐름": {"boxes": "아님"}} _post(client, "/api/m01/save", {"files": [bad]}, 422) def test_흐름_없는_로직_받기는_변환(client: TestClient) -> None: got = _get(client, "/api/m01/logic/flow", key=OLD) assert got["converted"] is True and got["flow"]["key"] == OLD if importlib.util.find_spec(sflow.CONVERT) is None: # sub7 변환이 오기 전 assert got["notes"] == [sflow.NOT_READY] and got["flow"]["boxes"] == [] _get(client, "/api/m01/logic/flow", 404, key="GX999999") def test_다른_로직_값_마스터_키_흐름_옛_순환(client: TestClient) -> None: _save_flow(client, KEY, _flow()) got = _post(client, "/api/m02/df/logic-value", {"key": KEY, "designs": {"길이": 10}}) assert Decimal(str(got["outputs"]["노무비"])) == 30000 and got["errors"] == [] designs = _get(client, "/api/m02/df/design-inputs", key=KEY)["designs"] assert [d["port"] for d in designs] == ["길이", "폭"] given = {k: str(v) for k, v in store.cm.시험입력(store.logic(OLD)["logic"]).items()} old = _post(client, "/api/m02/df/logic-value", {"key": OLD, "designs": given}) want = _post(client, "/api/m01/calc", {"key": OLD, "inputs": given}) assert old["outputs"] == want["sums"] and set(old["outputs"]) == { "노무비", "재료비", "경비", "계", } names = [d["port"] for d in _get(client, "/api/m02/df/design-inputs", key=OLD)["designs"]] assert names == [x["이름"] for x in store.logic(OLD)["logic"].get("입력") or []] # 흐름 안 다른 로직 입력이 옛 로직(OLD) 계 를 받아 노무비로 · 자기 자신을 부르면 막힘 flow = _flow() pills = [{"name": k, "unit": "-"} for k in given] ref = {"key": OLD, "outputs": [{"name": "계", "unit": "원"}], "designs": pills} flow["boxes"].append({"id": "lg", "kind": "input", "name": "거푸집", "color": "", "unit": "-", "x": 0, "y": 0, "input": {"mode": "logic", "elements": [], "variables": [], "logic": ref}}) # fmt: skip flow["boxes"].append({**copy.deepcopy(flow["boxes"][2]), "id": "out2", "name": "경비"}) flow["boxes"][-1]["output"]["cost"] = "경비" flow["links"].append( {"id": "l9", "from": {"box": "lg", "name": "계"}, "to": {"box": "out2", "name": "값"}} ) _save_flow(client, KEY, flow) mixed = _post(client, "/api/m01/calc", {"key": KEY, "inputs": given}) assert Decimal(str(mixed["sums"]["경비"])) == Decimal(str(want["sums"]["계"])) ref["key"] = KEY self_call = _save_flow(client, KEY, flow)["warnings"] assert any("서로를 부름" in w for w in self_call), self_call def test_시스템_관리자가_아니면_403(folder: Path) -> None: app = FastAPI() app.include_router( m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)] ) app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1} client = TestClient(app) before = _hashes(folder) assert client.get("/api/m01/logic/flow", params={"key": KEY}).status_code == 403 assert client.post("/api/m01/save", json={"files": []}).status_code == 403 assert client.post("/api/m01/calc", json={"key": KEY, "inputs": {}}).status_code == 403 assert _hashes(folder) == before