fix(m01-sl): 자재 바깥 값 — 검색어 묶음으로도 단가를 찾음(기존 엔진 재료 줄과 같은 길)

- {kind: "자재", key} 의 key 가 「{」 로 시작하는 JSON 글(또는 묶음)이면 {구분, 상세구분, 검색어, 규격?} 로 풀어 /calc 재료 줄처럼(검색어 · 자동값 차례 · 단위 환산) · 아니면 키
- unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 · 못 찾으면 쉬운 말(맞는 자재 없음 · 단가가 자료에 비어 있음)
- Api SlQuery 에 unit? · 시험 — 검색어 묶음 단가 = /calc 그 줄 단가(글 · 묶음) · 저장 때도 같은 값

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 13:53:20 +09:00
co-authored by Claude Opus 5.5
parent a02ea70d2e
commit ab514e8d10
3 changed files with 105 additions and 6 deletions
+3 -2
View File
@@ -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;
+42 -4
View File
@@ -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 "", "이름": "장비"}
+60
View File
@@ -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"]})