diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 191ed674..771d4616 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -212,6 +212,12 @@ def get_logic(key: str) -> dict: return _call(store.logic, key) +@router.get("/logic/auto") +def get_logic_auto(key: str) -> dict: + """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님).""" + return _call(store.auto, key) + + @router.get("/elements") def get_elements(group: str, q: str = "", limit: int = 50) -> dict: return _call(store.elements, group, q, limit) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index cf8d28e0..3babd1fd 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -19,6 +19,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 +import master_auto as mau # noqa: E402 import master_combo as mcb # noqa: E402,F401 (`_Store_Combo` 가 `store.mcb` 로 씀) import master_copy as mcp # noqa: E402,F401 (`_Store_Make` 가 `store.mcp` 로 씀) import master_keys as mk # noqa: E402 @@ -560,6 +561,15 @@ def calc(key: str, inputs: dict, row: dict | None = None, file: str | None = Non } +def auto(key: str) -> dict: + """견본 입력 — 마스터 보기용 자동값(`master_auto`) · 프로젝트 기본값 아님 · 저장 안 함.""" + whole = loaded()[1] + try: + return {"값": mau.values(whole, whole.logic(key))} + except cm.mf.FormulaError as e: + raise StoreError(404, str(e)) from e + + def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None): """사람이 읽는 식 줄 — 비목 묶음마다 「이름 : 단가 * 수량식 = 금액」 과 소계(`master_text`).""" given = {k: _dec(v) for k, v in inputs.items()} diff --git a/resources/master_data/scripts/master_auto.py b/resources/master_data/scripts/master_auto.py new file mode 100644 index 00000000..217b63a4 --- /dev/null +++ b/resources/master_data/scripts/master_auto.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""견본 입력 — 마스터 화면 보기용 자동값(프로젝트 기본값 아님 · 저장 안 함). + +고르기 = 목록 첫째 · 범위 = 최솟값 · 찾기 조건 값으로 쓰이는 수 = 그 표 그 칸 가장 낮은 줄 값 +(범위 줄이면 그 줄에 드는 아래 끝) · 나누는 자리에 오는 수 = 1 · 그 밖 0. +찾기·나누기 쓰임은 로직 식(중간 · 결과 · 호표 수량·요소 · 덧줄)을 풀어 알아냄. +""" + +from __future__ import annotations + +from decimal import Decimal + +import master_formula as mf + + +def _texts(row: dict): + for step in row.get("중간", []): + yield step["식"] + if "결과" in row: + yield row["결과"]["식"] + for item in row.get("호표", []): + yield item["수량"] + if isinstance(item.get("요소"), str): + yield item["요소"] + for extra in row.get("덧줄", []): + yield extra["식"] + + +def _names(node) -> set: + return {part[1] for part in mf.walk(node) if part[0] == "name"} + + +def _uses(row: dict) -> tuple[dict, set]: + """(찾기 조건 값으로 바로 쓰인 이름 → [(표, 조건)], 나누는 자리에 든 이름) + — 나누는 자리에 든 중간의 식에 쓰인 이름도 나누는 자리로 봄.""" + finds, divs = {}, set() + for text in _texts(row): + try: + node = mf.parse(text) + except mf.FormulaError: + continue + for part in mf.walk(node): + if part[0] == "bin" and part[1] == "/": + divs |= _names(part[3]) + if part[0] == "find": + for cond, arg in part[2]: + if arg[0] == "name": + finds.setdefault(arg[1], []).append((part[1], cond)) + for step in reversed(row.get("중간", [])): + if step["이름"] in divs: + divs |= _names(mf.parse(step["식"])) + return finds, divs + + +def _fits(master: mf.Master, ref: str, cond: str) -> list: + """표 `ref` 조건 칸 `cond` 에 드는 수 — 낮은 차례 · 범위 칸이면 줄마다 그 줄에 드는 끝 + (아래 끝이 열린 줄 「60 미만」 은 0).""" + try: + table = master.get(ref) + except mf.FormulaError: + return [] + ranged = table.get("조건", {}).get(cond) == "범위" + out = set() + for r in table.get("줄", []): + if r.get(cond) is None: # 빈 조건 칸은 무엇이든 맞음 — 값을 정하지 않음 + continue + low, high = r[cond] if ranged else (r[cond], None) + ends = [Decimal(0) if low is None else low, high] + out |= {v for v in ends if isinstance(v, Decimal) and mf._match(table, r, cond, v)} + return sorted(out) + + +def _lowest(master: mf.Master, uses: list): + """찾기 여럿이 같은 입력을 쓰면 모든 표에 드는 가장 낮은 수 · 없으면 첫 표의 가장 낮은 수.""" + sets = [_fits(master, ref, cond) for ref, cond in uses] + both = sorted(set.intersection(*map(set, sets))) if sets else [] + return (both or next((s for s in sets if s), [None]))[0] + + +def values(master: mf.Master, row: dict) -> dict: + """로직 줄 하나의 입력마다 자동값.""" + finds, divs = _uses(row) + out = {} + for spec in row.get("입력", []): + name = spec["이름"] + if spec.get("고르기"): + out[name] = spec["고르기"][0] + elif spec.get("범위"): + out[name] = spec["범위"][0] + else: + got = _lowest(master, finds.get(name, [])) + out[name] = got if got is not None else Decimal(1 if name in divs else 0) + return out diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 2453ff82..7a88a453 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -486,7 +486,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: lines = [] for item in row.get("호표", []): qty = evaluate(parse(item["수량"]), env, master, depth) - source = None + source = why = None if item["종류"] == "로직": bundle = evaluate(parse(item["요소"]), env, master, depth) price = bundle["계"] @@ -503,37 +503,49 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: if isinstance(item["요소"], dict) else fill(item["요소"], env) ) + # 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌 + # (없는 키는 틀린 로직 — 지금처럼 멈춤) + if isinstance(ref, str) and qty != 0: + master.get(ref) try: price, source = element(master, ref, env, item.get("단위")) - except FormulaError: - if qty != 0: - raise - price = None + why = ( + None + if price is not None + else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값" + ) + except FormulaError as e: + price, why = None, str(e) if price is None and qty == 0: - price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0 - if price is None: - raise FormulaError(f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값") - split = {item["비목"]: qty * price} + price, why = Decimal(0), None # 안 쓰는 재료 줄 — 값이 비어도 금액 0 + split = {item["비목"]: None if price is None else qty * price} for k, v in split.items(): - sums[k] += v + sums[k] += v or 0 lines.append( { "이름": item.get("이름", ""), "단위": item.get("단위", ""), "수량": qty, "단가": price, - "금액": qty * price, + "금액": None if price is None else qty * price, "비목": split, **({"출처": source} if source else {}), + **({"까닭": why} if why else {}), } ) - # 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'` - env["줄"] = {item["이름"]: item["금액"] for item in lines if item["이름"]} + # 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌) + env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} + blank = {x["이름"]: x["까닭"] for x in lines if x.get("까닭")} for extra in row.get("덧줄", []): env.update(sums) - value = evaluate(parse(extra["식"]), env, master, depth) - env[extra["이름"]] = value - sums[extra["비목"]] += value + try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움 + value, why = evaluate(parse(extra["식"]), env, master, depth), None + env[extra["이름"]] = value + except FormulaError as e: + gone = next((n for n in blank if n in extra["식"]), None) + value, why = None, f"「{gone}」 줄 비어 못 셈 — {blank[gone]}" if gone else str(e) + blank[extra["이름"]] = why + sums[extra["비목"]] += value or 0 lines.append( { "이름": extra["이름"], @@ -542,6 +554,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: "단가": value, "금액": value, "비목": {extra["비목"]: value}, + **({"까닭": why} if why else {}), } ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 diff --git a/resources/tester/test_m01_auto.py b/resources/tester/test_m01_auto.py new file mode 100644 index 00000000..4979af8b --- /dev/null +++ b/resources/tester/test_m01_auto.py @@ -0,0 +1,96 @@ +"""M01 견본 입력(`GET /logic/auto`) · 값 빈 품목은 그 줄만 비움 — PLAN 3-2 · 계약 3-1 표. + +자동값 = 고르기 목록 첫째 · 범위 최솟값 · 찾기 조건 값은 그 표 가장 낮은 줄 · 나누는 수 1 · 그 밖 0. +정본 폴더를 읽기만 함(쓰지 않음). +""" + +from __future__ import annotations + +from decimal import Decimal + +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 + + +@pytest.fixture(scope="module") +def client() -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _auto(client: TestClient, key: str) -> dict: + got = client.get("/api/m01/logic/auto", params={"key": key}) + assert got.status_code == 200, got.text + return got.json()["값"] + + +def _calc(client: TestClient, key: str) -> dict: + got = client.post("/api/m01/calc", json={"key": key, "inputs": _auto(client, key)}).json() + assert got["ok"] is True, got + return got + + +def test_고르기는_목록_첫째_범위는_최솟값(client) -> None: + got = _auto(client, "GF000160") # 12-4 합판거푸집 + assert got["횟수"] == "1회사용시" and got["폼타이"] == "아님" and got["합판"] == "MT000018" + assert got["소형할증"] == 0 # 범위 [0, 30] + assert got["높이할증"] == 0 # 찾기·나누기에 안 쓰임 + + +def test_찾기_조건_값은_표_가장_낮은_줄(client) -> None: + assert _auto(client, "GC000561")["층수층"] == 0 # 가장 낮은 줄 「6 미만」 — 아래 끝이 열림 + assert _auto(client, "GC000559")["강재총사용량"] == 0 # 「60 미만」 + # 두 표가 같은 입력을 씀 — 한 표는 6 부터 · 한 표는 15 부터 → 둘 다에 드는 가장 낮은 값 + assert _auto(client, "GC000724")["규격"] == 15 + + +def test_나누는_수는_1(client) -> None: + assert _auto(client, "GC000998")["수직고초과단계"] == 1 + assert _auto(client, "GC000278")["두께"] == 1 + + +def test_없는_로직은_404(client) -> None: + assert client.get("/api/m01/logic/auto", params={"key": "GF999999"}).status_code == 404 + + +def test_견본_금액은_앞과_같음(client) -> None: + assert Decimal(str(_calc(client, "GF000160")["sums"]["계"])) == Decimal("117485.212") + assert Decimal(str(_calc(client, "GF000219")["sums"]["계"])) == Decimal("79279.668") + + +def test_후보_0_값_빈_품목은_그_줄만_비움(client) -> None: + got = _calc(client, "GC000027") # 가새(후보 0) · 발판(값 없음) + blank = {one["이름"]: one for one in got["lines"] if one.get("까닭")} + assert len(blank) == 2 + for one in blank.values(): + assert one["단가"] is None and one["금액"] is None + assert "후보 없음" in blank["가새 L1518-2개"]["까닭"] + assert "값 없음" in blank["발판 45×200×2000"]["까닭"] + rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None) + assert Decimal(str(got["sums"]["계"])) == rest > 0 # 빈 줄은 빼고 더함 + + +def test_빈_줄을_부르는_덧줄도_비움(client) -> None: + got = _calc(client, "GF000185") # 레미콘 후보 0 → 레미콘 금액을 부르는 덧줄 + blank = [one for one in got["lines"] if one.get("까닭")] + assert [one["이름"] for one in blank] == ["구체콘크리트(철근) 레미콘", "콘크리트다짐"] + assert "구체콘크리트(철근) 레미콘" in blank[1]["까닭"] and blank[1]["금액"] is None + + +def test_자동값으로_로직_전부_멈춤_0() -> None: + files, whole = store.loaded() + stuck = [] + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + try: + store.cm.mf.run(whole, str(row["키"]), store.mau.values(whole, row)) + except (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError) as e: + stuck.append(f"{row['키']} {e}") + assert stuck == []