diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index ef3cfb31e..1cfd10991 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -332,14 +332,16 @@ def _rounded(value: Any, r: dict) -> Any: def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: """표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나 - · 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find 와 같음 · Rule lookup 과 같음).""" + · 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄 + (옛 엔진 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") - if out not in head: + 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 = [] + tests, ups = [], [] for c in rule.get("conds") or []: col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {}) if col in head: @@ -351,6 +353,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: 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]) @@ -365,13 +368,18 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: tests.append(fits) else: return None, "오류" - at = head.index(out) + 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 @@ -594,7 +602,7 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N for f, book, calc in zip(funcs, books, calcs): sheet = book["시트"][0] for cell, v in (calc.get(sheet.get("id")) or {}).items(): - if "오류" in v: + if "오류" in v and (sheet.get("칸") or {}).get(cell, {}).get("식") != "NA()": errors.append(f"「{f.get('name')}」 {cell} 칸 계산 오류 {v['오류']}") out = f["func"].get("output") or {} at = _at(out.get("cell") or "A1")[2] diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py index 2d109ff59..b4141badd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py @@ -139,14 +139,15 @@ class _Flow: if len(texts) > 1: raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」") ((at, text),) = texts.items() - holes = list(dict.fromkeys(_HOLE.findall(text))) - if len(holes) == 1: - src, hole = self.hole(holes[0]), holes[0] - else: # 「레미콘 {a}-{b}-{c}」 — 함수 상자가 글을 이음 - src = self.func(f"{label} 찾는 말", ("@text", text)) - hole = src["name"] - text = "{" + hole + "}" - tpl = text if at is None else json.dumps(plain(ref | {at: text}), ensure_ascii=False) + # 찾는 값 = 원문번호(「EQ:{기계}」 → 기계 · 「MO:경유:{지역}」 → "경유:"&지역) + want = text.partition(":")[2] if at is None and ":" in text else text + if _HOLE.fullmatch(want): + src = self.hole(want[1:-1]) + else: + src = self.func(f"{label} 찾는 말", ("@text", want)) + if at is not None: + self.notes.append(f"재료 검색어에 입력이 낌 「{text}」 — 조건 찾기(키 같음)로 옮김") + tpl = text if at is None else json.dumps(plain(ref), ensure_ascii=False) tpl += f"#{field}" if field else "" rule = {"field": "키", "op": "같음", "from": src} return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule)) @@ -295,6 +296,9 @@ class _Flow: group = GROUP_OF.get(base[1][:2], "기계") node2 = self.priced(group, base[1], "", f"{base[1]} {col}", col) return self.excel(node2, take, scope) + if base[0] == "logic" and col == "계": # 다른 로직 출력은 비목 셋 — 계 = 그 합 + bid = self.logic(base) + return self.excel(("@sum", [{"box": bid, "name": k} for k in COSTS]), take, scope) if base[0] == "logic": return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}") if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope: @@ -356,7 +360,9 @@ def _body(f: _Flow, row: dict) -> None: lines.setdefault(f"줄.{name}", []).append(part) continue unit = str(item.get("단위") or "") - price = f.priced(str(item.get("종류")), item["요소"], unit, f"{name} 단가") + ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류 + group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료" + price = f.priced(group, ref, unit, f"{name} 단가") part = f.func(f"{name} 금액", ("bin", "*", qty, price)) parts[item["비목"]].append(part) lines.setdefault(f"줄.{name}", []).append(part) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index aa8c08fc4..f3549a10c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -241,7 +241,7 @@ function runFunc( if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) - if ("오류" in v) { + if ("오류" in v && sheet.칸[cell]?.식 !== "NA()") { const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; errors.push(tl("CellError", { box: box.name, cell, err })); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index 57bf93bec..990e9cba3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -181,9 +181,12 @@ function lookup( const i = head.indexOf(name); return i < 0 ? null : colName(i); }; - const out = at(rule.col); + // 값 칸이 범위로 갈림(「이름·아래」 「이름·위」) — 아래 = 위면 그 값 + const two = !at(rule.col) && !!at(`${rule.col}·아래`) && !!at(`${rule.col}·위`); + const out = two ? at(`${rule.col}·아래`) : at(rule.col); // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : []; + const ups: string[] = []; let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; @@ -193,13 +196,14 @@ function lookup( const low = at(`${c.col}·아래`); const high = at(`${c.col}·위`); if (same) tests.push((r) => `OR(ISBLANK(${same}${r}),${same}${r}=${ref})`); - else if (low && high) + else if (low && high) { + ups.push(high); tests.push( (r) => `IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` + `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r})))`, ); - else bad = true; + } else bad = true; }); const hc = colName(W + 1 + rule.conds.length); const last = rows + 1; @@ -207,13 +211,25 @@ function lookup( 칸[`${hc}${r}`] = { 식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1", }; - const range = `${hc}2:${hc}${Math.max(2, last)}`; + // 여럿이면 범위 위 끝이 가장 작은 줄(옛 find · 「20 이하」 「40 이하」 …) — 둘째 맞음 칸 + const hc2 = colName(W + 3 + rule.conds.length); + const span = (c: string): string => `${c}2:${c}${Math.max(2, last)}`; + for (let r = 2; r <= last; r++) { + const top = ups.map((h) => `OR(ISBLANK(${h}${r}),${h}${r}=MINIFS(${span(h)},${span(hc)},1))`); + 칸[`${hc2}${r}`] = { + 식: top.length ? `IF(AND(${hc}${r}=1,${top.join(",")}),1,0)` : `${hc}${r}`, + }; + } + const range = span(hc2); const count = `${colName(W + 2 + rule.conds.length)}1`; const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()"; 칸[count] = { 식: `SUM(${range})` }; 칸.ZZ1 = { 식: formula }; + const up = at(`${rule.col}·위`); + if (two && up) 칸.ZZ2 = { 식: formula.replace(`INDEX(${out}2:${out}`, `INDEX(${up}2:${up}`) }; if (bad) return { value: null, why: "오류", formula }; - const [n, got] = run(칸, [count, "ZZ1"]); + const [n, got, top] = run(칸, [count, "ZZ1", "ZZ2"]); + if (two && n === 1 && got !== top) return { value: null, why: "오류", formula }; if (n === 0) return { value: null, why: "표에 없는 조건", formula }; if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula }; if (isError(got)) return { value: null, why: "오류", formula }; diff --git a/resources/tester/test_m02_df_convert.py b/resources/tester/test_m02_df_convert.py index de856799b..fafb7d995 100644 --- a/resources/tester/test_m02_df_convert.py +++ b/resources/tester/test_m02_df_convert.py @@ -4,9 +4,8 @@ · 전수 대조 — 사본 폴더(tmp/master_copy_36 · AISLO_MASTER_DIR)의 로직 1,472 마다 옛 셈(master_formula.run) = 변환 흐름도 셈(Compute.run) · 입력 = check_master.시험입력(정함 ①) · 기본 입력(줄의 시험입력 뺌)도 같이 · 맞는 비율 · 안 맞는 까닭 → tmp/convert_report_36.json -· 조건 찾기 field 「키」(입력값으로 요소 고르기 · 정함 ②) · 표 값 식 글을 수로(정함 ③)는 sub4 몫 - — 대조는 같은 뜻 대역(ref 의 {…} 에 알약 값을 끼워 dv.one_value · 식 글은 옛 파서로 셈) - · 같은 물음은 한 번만 셈 +· 조건 찾기 field 「키」(입력값으로 요소 고르기 · 정함 ②) · 표 값 식 글 → 수(정함 ③)는 서버 길로 + (Compute · Values · sub4) · 값 찾기(dv.one_value)는 같은 물음이면 한 번만 셈 """ from __future__ import annotations @@ -26,8 +25,7 @@ WORKERS = 6 # 일꾼(따로 뜬 파이썬 · 사본 폴더) — 표준 입력 = 키 목록 JSON · 표준 출력 = 줄마다 결과 JSON WORKER = r""" -import json, os, re, sys -from decimal import Decimal +import json, os, sys sys.path.insert(0, os.getcwd()) from M01_MasterData import M01_MasterData_Store as store from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp, M02_DF_Convert as cv @@ -40,41 +38,8 @@ def cached(item, local=None): k = json.dumps(item, ensure_ascii=False, sort_keys=True, default=str) if k not in seen: seen[k] = one_value(item, local) - if seen[k].get("rows"): # 표 값 식 글 → 수(Values._table 대역) - seen[k]["rows"] = [[arith(x) for x in r] for r in seen[k]["rows"]] return seen[k] -def arith(x): - if isinstance(x, str) and mf._ARITH.fullmatch(x): - try: - return mf.evaluate(mf.parse(x), {}, master) - except (mf.FormulaError, ArithmeticError): - return x - return x dv.one_value = cached -element_value = cp._element_value -HOLE = re.compile(r"\{([^{}]+)\}") -def stand_in(group, el, given, ports, errors, box, ctx): - rule = el.get("rule") or {} - if el.get("mode") != "rule" or rule.get("field") != "키": - return element_value(group, el, given, ports, errors, box, ctx) - src = rule.get("from") or {} - v = ports.get(src.get("box"), {}).get(src.get("name")) - if v is None: - errors.append(f"「{el.get('name')}」 자리 값 없음") - return None - text = format(Decimal(str(v)).normalize(), "f") if isinstance(v, (int, float)) else str(v) - ref, _, field = HOLE.sub(lambda m: text, el["ref"]).partition("#") - try: - ref = ref if ref.lstrip().startswith("{") else str(master.get(ref).get("키")) - except mf.FormulaError as e: - errors.append(f"「{el.get('name')}」 {e}") - return None - ref += f"#{field}" if field else "" - got = cached({"group": group, "ref": ref, "unit": el.get("unit")}) - if not got["ok"]: - errors.append(f"「{el.get('name')}」 {got.get('reason')}") - return got.get("value") if got["ok"] else None -cp._element_value = stand_in def load(key): if key not in flows: flows[key] = cv.convert(master.logic(key))["flow"] @@ -95,7 +60,8 @@ def check(row, given): got = cv.convert(row) flows[row["키"]] = got["flow"] try: - design = {"in-design": {k: cv.plain(v) for k, v in given.items()}} + env = mf._inputs(row, given) # 옛 엔진처럼 글로 적힌 수 → 수(화면은 수로 넘김) + design = {"in-design": {k: cv.plain(v) for k, v in env.items()}} new = cp.run(got["flow"], design, load, []) except Exception as e: return {"state": "흐름 셈 멈춤", "why": repr(e)[:200], "notes": got["notes"]} @@ -156,17 +122,24 @@ def _keys() -> list[str]: return json.loads(_run(code)) +REASONS = [ # (알림에 든 말, 까닭) — 앞이 먼저 + ("표에 여럿 맞음", "표 범위 규칙 다름 — 여럿 맞음(이상·이하 · 이상·미만 경계)"), + ("표에 없는 조건", "표 범위 규칙 다름 — 없는 조건(이상·이하 · 이상·미만 경계)"), + ("같음", "재료 검색어 자리 — 서버 조건 찾기(키 같음) ≠ 옛 검색어 찾기"), + ("맞는 재료 없음", "재료 빈 검색어 — 서버 재료 값 찾기 다름"), + ("비어 있음", "단가 빈 품목 — 서버 처리 다름"), + ("값 없음", "단가 빈 품목 — 서버 처리 다름"), +] + + def _reason(r: dict) -> str: if r["state"] != "다름": return r["state"] moved = [n for n in r.get("notes") or [] if n.startswith("못 옮김")] if moved: return "못 옮김 — " + moved[0].split("—", 1)[1].strip().split("「")[0].strip() - for e in r.get("errors") or []: - tail = e.rsplit("」", 1)[-1].strip() - if tail: - return "흐름 셈 알림 — " + tail.split("—")[0].strip()[:40] - return "값 다름(알림 없음)" + text = " | ".join(r.get("errors") or []) + return next((why for word, why in REASONS if word in text), "값 다름(알림 없음)") # ── 꼴 시험 ────────────────────────────────────────────────────────── diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index b29d28679..a8d95f9fa 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -858,3 +858,40 @@ def test_표_찾기_빈_조건칸_무엇이든_빈_값줄_건너뜀_실패는_ want = None if isinstance(screen_out, str) and screen_out.startswith("#") else screen_out assert (None if out is None else float(out)) == (None if want is None else float(want)) assert [b["kind"] for b in srv["breaks"]] == screen_breaks + + +def test_표_찾기_값_칸이_범위로_갈림_아래_같으면_그_값() -> None: + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + block = [["구분", "값·아래", "값·위"], ["가", 3, 3], ["나", 1, 2]] + rule = { + "kind": "lookup", + "conds": [{"col": "구분", "from": {"box": "v", "name": "구분"}}], + "col": "값", + } + got = _run( + RULE + + f"const block = {json.dumps(block, ensure_ascii=False)};\n" + + f"const rule = {json.dumps(rule, ensure_ascii=False)};\n" + "out(['가', '나'].map(g => { const r = R.applyRule(rule, block, { 구분: g }); return [r.value, r.why]; }));" + ) + assert got == [[3, None], [None, "오류"]] + assert [list(cp._lookup(rule, block, {"구분": g})) for g in ("가", "나")] == got + + +def test_표_찾기_여럿이면_위_끝_가장_작은_줄() -> None: + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + block = [["거리·아래", "거리·위", "Q"], [None, 20, 11], [None, 40, 10], [None, 60, 9]] + rule = { + "kind": "lookup", + "conds": [{"col": "거리", "from": {"box": "v", "name": "x"}}], + "col": "Q", + } + got = _run( + RULE + + f"const block = {json.dumps(block)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n" + "out([1, 30, 60, 70].map(x => { const r = R.applyRule(rule, block, { x }); return [r.value, r.why]; }));" + ) + assert got == [[11, None], [10, None], [9, None], [None, "표에 없는 조건"]] + assert [list(cp._lookup(rule, block, {"x": x})) for x in (1, 30, 60, 70)] == got