feat(M01): 로직 「흐름」 칸 — 저장 검사 · 서버 다시 셈 · 흐름 받기 · 계산 갈래 · 신규 GX · 다른 로직 마스터 키(PLAN 36-4)

- /save 줄의 흐름 = 꼴 검사(없는 알약 · 겹친 선 · 돌고 도는 선 422 · 빈 받는 자리 경고) → M02_DF_Compute 로 다시 셈 → 받는 칸 값
- 키 없는 새 흐름 줄 = 로직_자체.json 에 GX 다음 키 · 구분 자체 · 옛 식 칸은 빈 것 · 응답 {files, added, warnings}
- GET /api/m01/logic/flow = 흐름 그대로 · 없으면 M02_DF_Convert.convert · 아직 없으면 빈 흐름 + 변환 준비 중
- /calc = 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · sums 노무비 · 재료비 · 경비 · 계 · outputs) · 없으면 옛 셈
- DataFlow 다른 로직 입력 · /logic-value · /design-inputs 가 마스터 키도(흐름 = 흐름 셈 · 옛 로직 = 옛 셈) · 서로 부르면 막음
- Compute 의 Router _num 은 모듈로 부름(서로 부름 풀기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
2026-10-01 23:41:32 +09:00
co-authored by Claude Opus 5.5
parent ff127b22cc
commit 7b90d28e22
5 changed files with 546 additions and 5 deletions
@@ -0,0 +1,240 @@
# 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