fix(M01): 표 찾기 실패는 그 찾기를 쓰는 줄만 비움 · 까닭 글 날글 없앰

- NoRow · Blank — 표에 맞는 줄이 없으면 중간값을 거쳐 쓰는 줄까지 단가·금액 null + 까닭 · 소계·계는 빈 줄 빼고 더함
- 까닭 「표 QC001869(1-2-3)에 시공구분 기계시공 · … 인 줄이 없음」 · 재료 고르기 후보 없음도 같은 규칙
- 시험 한 개 더 (test_m01_auto.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-24 15:07:19 +09:00
co-authored by Claude Sonnet 5
parent 5e9df81cbd
commit 3ef2ba566f
3 changed files with 94 additions and 26 deletions
+67 -23
View File
@@ -36,6 +36,22 @@ class EmptyInput(FormulaError):
"""값을 안 넣은(null) 입력을 씀 — 그 줄만 비우고 로직은 계속(`run`)."""
class NoRow(EmptyInput):
"""표에서 조건에 맞는 줄이 하나가 아님 — 그 찾기를 쓰는 줄만 비움(값이 비어 있는 입력과 같은 길)."""
class Blank:
"""못 구한 중간값 — 이 값을 쓰는 줄만 까닭과 함께 비움."""
def __init__(self, why: str):
self.why = why
def _plain(v) -> str:
"""사람이 읽는 값 글 — 파이썬 날글(Decimal('1000') · 따옴표) 없이."""
return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v)
# ── 읽기 ──────────────────────────────────────────────────────────────
class _Parser:
def __init__(self, text: str):
@@ -299,7 +315,9 @@ def fill(template: str, env: dict) -> str:
if m.group(1) not in env:
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
value = env[m.group(1)]
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
if isinstance(value, Blank):
raise EmptyInput(value.why)
return _plain(value)
return _HOLE.sub(one, template)
@@ -353,7 +371,13 @@ def find(table: dict, conds: dict, want: str | None = None) -> dict:
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
hits = [row for row in hits if want in row]
if len(hits) != 1:
raise FormulaError(f"{_name(table)}{conds} 맞는 줄 {len(hits)}")
where = f"{table.get('')}({table.get('원문번호')})"
what = " · ".join(f"{c} {_plain(v)}" for c, v in conds.items())
raise NoRow(
f"{where}{what} 인 줄이 없음"
if not hits
else f"{where}{what} 인 줄이 {len(hits)}"
)
return hits[0]
@@ -366,6 +390,8 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
raise FormulaError(f"모르는 이름 「{node[1]}")
if env[node[1]] is None:
raise EmptyInput(f"입력 「{node[1]}」 비어 있음")
if isinstance(env[node[1]], Blank):
raise EmptyInput(env[node[1]].why)
return env[node[1]]
if kind == "ref":
value = element(master, fill(node[1], env), env)[0]
@@ -489,15 +515,36 @@ def _inputs(row: dict, given: dict) -> dict:
return env
def _shown(env: dict) -> dict:
"""돌려줄 중간값 — 못 구한 값은 null."""
return {k: None if isinstance(v, Blank) else v for k, v in env.items()}
def _blank_line(item: dict, why: str) -> dict:
"""비어 있는 입력·못 구한 값을 쓰는 호표 줄 — 단가·금액만 비우고 까닭."""
return {
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": None,
"단가": None,
"금액": None,
"비목": {item["비목"]: None} if "비목" in item else {},
"까닭": why,
}
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
row = master.logic(ref)
env = _inputs(row, given)
for step in row.get("중간", []):
env[step["이름"]] = evaluate(parse(step[""]), env, master, depth)
try:
env[step["이름"]] = evaluate(parse(step[""]), env, master, depth)
except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움
env[step["이름"]] = Blank(str(e))
if "결과" in row:
value = evaluate(parse(row["결과"][""]), env, master, depth)
return {"결과": cut(row, value, "결과"), "중간": env}
return {"결과": cut(row, value, "결과"), "중간": _shown(env)}
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for item in row.get("호표", []):
@@ -505,21 +552,14 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
try:
qty = evaluate(parse(item["수량"]), env, master, depth)
except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움
qty, price, why = None, None, str(e)
lines.append(
{
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": None,
"단가": None,
"금액": None,
"비목": {item["비목"]: None} if "비목" in item else {},
"까닭": why,
}
)
lines.append(_blank_line(item, str(e)))
continue
if item["종류"] == "로직":
bundle = evaluate(parse(item["요소"]), env, master, depth)
try:
bundle = evaluate(parse(item["요소"]), env, master, depth)
except EmptyInput as e:
lines.append(_blank_line(item, str(e)))
continue
price = bundle[""]
split = (
{k: qty * bundle[k] for k in COST_ITEMS}
@@ -529,11 +569,15 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
else:
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
# 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄
ref = (
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else fill(item["요소"], env)
)
try:
ref = (
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else fill(item["요소"], env)
)
except EmptyInput as e:
lines.append(_blank_line(item, str(e)))
continue
# 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌
# (없는 키는 틀린 로직 — 지금처럼 멈춤)
if isinstance(ref, str) and qty != 0:
@@ -589,7 +633,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
}
)
sums = {k: cut(row, v, "") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
return {"": lines, **sums, "": sum(sums.values()), "중간": env}
return {"": lines, **sums, "": sum(sums.values()), "중간": _shown(env)}
# ── 검사 ──────────────────────────────────────────────────────────────
@@ -16,7 +16,15 @@ LINKED = "MP" # 품셈재료 테이블ID
MARKET = "MT" # 자재품목 테이블ID
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
PICK_KEYS = ("구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별") # 재료 고르기 조건 칸 (`_틀.md` 7장)
PICK_KEYS = (
"구분",
"상세구분",
"이름",
"규격",
"대표",
"지역",
"계약종별",
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
_UNITS = {"ea": "", "ton": "", "t": "", "l": "", "리터": "", "set": "", "세트": ""}
@@ -98,7 +106,9 @@ def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[s
if not hits:
raise mf.FormulaError(f"{master.label(label_ref)}」 지역 값 없음 — {env.get(REGION)}")
if len({s for s, _ in hits}) > 1:
raise mf.FormulaError(f"{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
raise mf.FormulaError(
f"{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)"
)
return [key for _, key in hits]
@@ -155,7 +165,8 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s
keys = candidates(master, cond, env, unit)
keys = [k for k in keys if _offers(master, k)] or keys
if not keys:
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
what = " · ".join(f"{k} {mf._plain(v)}" for k, v in cond.items())
raise mf.FormulaError(f"재료 고르기 후보 없음 — {what}")
return keys[0]
+13
View File
@@ -124,3 +124,16 @@ def test_빠진_원_입력도_null_처럼_줄만_비움(client) -> None:
assert got["ok"] is True, (path, got)
lines = client.post("/api/m01/calc", json={"key": "GF000056", "inputs": given}).json()["lines"]
assert {o["이름"] for o in lines if o.get("까닭")} == {"체인오일", "체인톱 손료"}
def test_표_찾기_실패는_그_줄만_비우고_까닭은_날글_없이(client) -> None:
given = {**_auto(client, "GC001000"), "시공구분": "기계시공"} # 표 1-2-3 에 그 조합 줄이 없음
got = client.post("/api/m01/calc", json={"key": "GC001000", "inputs": given}).json()
assert got["ok"] is True, got
blank = [one for one in got["lines"] if one.get("까닭")]
assert blank and all(one["금액"] is None for one in blank)
why = blank[0]["까닭"]
assert "표 QC001869(1-2-3)에 시공구분 기계시공" in why and "인 줄이 없음" in why
assert not any(ch in why for ch in "{}'") and "Decimal" not in why
rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None)
assert Decimal(str(got["sums"][""])) == rest