From ab514e8d105e9fb3d2fbed72f42e83f0018e816c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(m01-sl):=20=EC=9E=90=EC=9E=AC=20=EB=B0=94?= =?UTF-8?q?=EA=B9=A5=20=EA=B0=92=20=E2=80=94=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C=EC=9C=BC=EB=A1=9C=EB=8F=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=A5=BC=20=EC=B0=BE=EC=9D=8C(=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=9E=AC=EB=A3=8C=20=EC=A4=84=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B8=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - {kind: "자재", key} 의 key 가 「{」 로 시작하는 JSON 글(또는 묶음)이면 {구분, 상세구분, 검색어, 규격?} 로 풀어 /calc 재료 줄처럼(검색어 · 자동값 차례 · 단위 환산) · 아니면 키 - unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 · 못 찾으면 쉬운 말(맞는 자재 없음 · 단가가 자료에 비어 있음) - Api SlQuery 에 unit? · 시험 — 검색어 묶음 단가 = /calc 그 줄 단가(글 · 묶음) · 저장 때도 같은 값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/SheetLogic/M01_SL_Api.ts | 5 +- M01_MasterData/SheetLogic/M01_SL_Router.py | 46 +++++++++++++++-- resources/tester/test_m01_sl_router.py | 60 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts index 6f5917799..1a6dba9e2 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Api.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -17,8 +17,9 @@ import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model"; export type { SlField }; -/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` */ -export type SlQuery = { id: string } & SlSource; +/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` · 자재 `key` = 키 또는 검색어 묶음 JSON 글 + * (`{구분, 상세구분, 검색어, 규격?}`) · `unit` = 그 줄 단위(주면 기존 로직 재료 줄과 같은 단가) */ +export type SlQuery = { id: string; unit?: string } & SlSource; /** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */ export type SlOutside = SlQuery; diff --git a/M01_MasterData/SheetLogic/M01_SL_Router.py b/M01_MasterData/SheetLogic/M01_SL_Router.py index a47d9f48e..fffc2daa1 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Router.py +++ b/M01_MasterData/SheetLogic/M01_SL_Router.py @@ -97,7 +97,7 @@ def delete(key: str) -> dict: # ── 바깥 값 ──────────────────────────────────────────────────────────── # 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}` # 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`) -# 노임 · 자재 {kind, key} → 그 줄 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) +# 노임 · 자재 {kind, key, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음 # 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1) # 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움) RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음 @@ -131,8 +131,46 @@ def _money(got: dict, field: str | None) -> dict: return {"ok": True, "value": got["sums"][pick]} -def _price(kind: str, key: str) -> dict: - """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" +def _pick(key: Any) -> dict | None: + """자재 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None.""" + if isinstance(key, dict): + return key + if isinstance(key, str) and key.lstrip().startswith("{"): + try: + got = json.loads(key) + except ValueError: + return None + return got if isinstance(got, dict) else None + return None + + +def _material(cond: dict, unit: str) -> dict: + """검색어 묶음 자재 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산).""" + line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} + got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None) + if not got["ok"]: + return got + one = got["lines"][0] + if one["단가"] is None: + word = cond.get("검색어") or "" + empty = "값 없음" in str(one.get("까닭") or "") # 품목은 찾았는데 단가 칸이 빔 + why = ( + "단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름" + if empty + else "맞는 자재 없음" + ) + return {"ok": False, "reason": f"「{word}」 {why}"} + picked = one.get("품목") or {} + out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None} + return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})} + + +def _price(kind: str, key: Any, unit: str = "") -> dict: + """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음.""" + cond = _pick(key) if kind == "자재" else None + if cond is not None: + return _material(cond, unit) + key = str(key or "") try: brief = store.loaded()[1].get(key) except cm.mf.FormulaError as e: @@ -205,7 +243,7 @@ def one_outside(item: dict) -> dict: if kind == "표": got = _table(str(item.get("table") or "")) elif kind in ("노임", "자재"): - got = _price(kind, str(item.get("key") or "")) + got = _price(kind, item.get("key"), str(item.get("unit") or "")) elif kind == "장비": inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"} diff --git a/resources/tester/test_m01_sl_router.py b/resources/tester/test_m01_sl_router.py index c17cecb79..0d856b566 100644 --- a/resources/tester/test_m01_sl_router.py +++ b/resources/tester/test_m01_sl_router.py @@ -304,3 +304,63 @@ def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None: ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == rows + + +def _material_cond() -> tuple[dict, str]: + """값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음).""" + rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"] + row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분")) + cond = { + "구분": row["구분"], + "상세구분": row["상세구분"], + "검색어": f"{row['이름']} {row['규격'] or ''}".strip(), + } + return cond, row["단위"] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None: + cond, unit = _material_cond() + line = { + "종류": "재료", + "요소": cond, + "이름": "재료", + "단위": unit, + "수량": "1", + "비목": "재료비", + } + row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체", + "입력": [], + "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip + want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"] + assert want is not None + + text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글 + bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False) + items = [ + {"id": "s", "kind": "자재", "key": text, "unit": unit}, + {"id": "d", "kind": "자재", "key": cond, "unit": unit}, + {"id": "x", "kind": "자재", "key": bad, "unit": unit}, + ] + got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]} + assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"] + assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음" + + # 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록) + logic = { + "key": None, + "name": "자재 견본", + "resultUnit": "원/㎡", + "workbook": { + "종류": "통합문서", + "판": 1, + "서식": [{}], + "시트": [{"id": "s1", "이름": "재료", "칸": {}}], + }, + "nodes": [], + "edges": [], + "outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}], + } + saved = _post(client, "/api/m01/sl/save", {"logic": logic}) + assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want + _post(client, "/api/m01/sl/delete", {"key": saved["key"]})