fix(m02-df): 흐름 셈 = 옛 셈 — 빈 단가 줄을 부르는 덧줄은 빈 값 · 전수 1,472 모두 맞음

- 빈 단가(blank) 요소 값 = 「#N/A」(서버 Compute · 화면 값 받기 Entry_Detail · Api CfAnswer.blank) → 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳 「단가 없는 품목을 씀」(Eval NoPrice · Compute 같음)
- 변환: 비목 합 · 덧줄의 비목 이름 = 빈 줄 0(SUM(IFERROR…)) · 호표 줄 = IF(수량=0,0,수량×단가) · 줄.이름은 빈 줄이면 빈 값(옛 env 줄)
- 오류 값 칸은 NA() 로(Compute _is_err · Eval isErrorValue)
- Compute 700줄 넘어 표 찾기 · 값 같음을 M02_DF_Lookup.py 로 나눔(Compute 가 다시 내보냄)
- 전수 대조 시험입력 · 기본 입력 1,472/1,472 · 시험 test_m02_df_model 37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-02 08:02:10 +09:00
co-authored by Claude Opus 5.5
parent d202a3a6d8
commit d822a08a5e
7 changed files with 231 additions and 101 deletions
@@ -0,0 +1,112 @@
"""M02 흐름도 — 선 규칙 「표에서 찾기」 · 값 같음(Compute 에서 나눔 · Rule lookup 과 같은 뜻).
표 블록 = 머리 줄 + 줄 · 범위 칸 「이름·아래」 「이름·위」 · 끝 칸 「·범위규칙」.
빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄은 뺌 · 글은 대소문자까지 · 여럿이면 위 끝 최소 줄(옛 find).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE)
def _num_of(v: Any) -> Decimal | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float, Decimal)):
return Decimal(str(v))
return None
def _same(a: Any, b: Any) -> bool:
"""엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음."""
na, nb = _num_of(a), _num_of(b)
if a is None or b is None:
other = b if a is None else a
return other is None or other == "" or _num_of(other) == 0
if na is not None and nb is not None:
return na == nb
if isinstance(a, str) and isinstance(b, str):
return a.lower() == b.lower()
return False
def _is_err(v: Any) -> bool:
return isinstance(v, str) and re.fullmatch(r"#[A-Z/0!?]+", v) is not None
def _exact(a: Any, b: Any) -> bool:
"""표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」."""
return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b)
def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
"""표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나
· 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄
(옛 엔진 find 와 같음 · Rule lookup 과 같음)."""
if not isinstance(block, list) or len(block) < 2:
return None, "오류"
head = [str(h if h is not None else "") for h in block[0]]
out = rule.get("col")
two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head)
if out not in head and not two:
return None, "오류"
tests, ups = [], []
rk = head.index(RANGE_RULE) if RANGE_RULE in head else None
for c in rule.get("conds") or []:
col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {})
if col in head:
i = head.index(col)
tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want))
elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head:
lo, hi, x = (
head.index(col + dv.RANGE_LOW),
head.index(col + dv.RANGE_HIGH),
_num_of(want),
)
ups.append(hi)
def fits(row, lo=lo, hi=hi, x=x) -> bool:
low, high = _num_of(row[lo]), _num_of(row[hi])
if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함)
return x is not None and x == low
word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음)
return (
x is not None
and (
row[lo] is None
or (low is not None and (low <= x if word[:2] == "이상" else low < x))
)
and (
row[hi] is None
or (high is not None and (x < high if word[-2:] == "미만" else x <= high))
)
)
tests.append(fits)
else:
return None, "오류"
at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값
rows = [list(r) + [None] * len(head) for r in block[1:]]
hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)]
if not hits:
return None, "표에 없는 조건"
for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄
tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None]
hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)]
if len(hits) > 1:
return None, "표에 여럿 맞음"
if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]):
return None, "오류"
return hits[0][at], None
def _cond_of(conds: dict, port: dict) -> Any:
"""조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf)."""
key = f"{port.get('box')}|{port.get('name')}"
return conds[key] if key in conds else conds.get(port.get("name"))