diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 3e7775ebd..659b65e85 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -28,7 +28,7 @@ export interface CfQuery { unit?: string; field?: string; inputs?: Record; - slot?: Partial & { field?: "규격" | "이름"; op?: "포함" | "같음" }; + slot?: Partial & { field?: "규격" | "이름" | "키"; op?: "포함" | "같음" }; value?: unknown; pick?: string; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py index 835319d54..ef3cfb31e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -331,7 +331,8 @@ def _rounded(value: Any, r: dict) -> Any: 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]] @@ -343,7 +344,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: 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: _same(row[i], want)) + tests.append(lambda row, i=i, want=want: row[i] is None or _same(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), @@ -364,13 +365,14 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: tests.append(fits) else: return None, "오류" - hits = [row for row in block[1:] if all(t(list(row) + [None] * len(head)) for t in tests)] + at = head.index(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, "표에 없는 조건" if len(hits) > 1: return None, "표에 여럿 맞음" - value = hits[0][head.index(out)] - return (None, "오류") if value is None else (value, None) + return hits[0][at], None def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]: @@ -463,11 +465,8 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: for p in func.get("inputs") or []: if str(p.get("cell") or "").strip(): port = p.get("port") - _place( - cells, - p["cell"], - arrived[port] if arrived.get(port) is not None else given.get(port), - ) + v = arrived[port] if arrived.get(port) is not None else given.get(port) + _place(cells, p["cell"], {"식": "NA()"} if v == NA_VALUE else v) for v in func.get("variables") or []: if v.get("cell"): _place(cells, v["cell"], given.get(v.get("name"), v.get("example"))) @@ -475,6 +474,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: REST = "앞 조건이 안 맞아 쉬는 갈래" +NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음) def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | None = None) -> dict: @@ -486,6 +486,7 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N errors, breaks, ports, arrived, outputs = [], [], {}, {}, {} blocked: set[str] = set() resting: set[str] = set() # 조건이 안 맞아 쉬는 상자 — Eval 과 같은 뜻 + late: dict[str, list[dict]] = {} # 함수로 가는 표 찾기 실패 — 결과가 오류일 때만 끊긴 곳 cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}} def through(ln: dict) -> Any: @@ -503,23 +504,28 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N conds[f"{p.get('box')}|{p.get('name')}"] = got_v got, why = apply_rule(rule, v, conds) if why: - blocked.add(ln["to"].get("box")) - if why == "통과 안 됨": - resting.add(ln["to"].get("box")) text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}" kind = "오류" if why in ("표에 여럿 맞음", "오류") else why to = ln["to"] - breaks.append( - { - "kind": kind, - # 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음) - "level": "쉼" if kind == "통과 안 됨" else "문제", - "box": to.get("box"), - "port": to.get("name"), - "link": ln.get("id"), - "text": text, - } - ) + brk = { + "kind": kind, + # 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음) + "level": "쉼" if kind == "통과 안 됨" else "문제", + "box": to.get("box"), + "port": to.get("name"), + "link": ln.get("id"), + "text": text, + } + if ( + rule.get("kind") == "lookup" + and boxes.get(to.get("box"), {}).get("kind") == "function" + ): + late.setdefault(to.get("box"), []).append(brk) + return NA_VALUE + blocked.add(to.get("box")) + if why == "통과 안 됨": + resting.add(to.get("box")) + breaks.append(brk) if why != "통과 안 됨": errors.append(text) return None if why else got @@ -601,6 +607,10 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N if out.get("round") and _num_of(value) is not None: value = _rounded(value, out["round"]) ports[f["id"]] = {out.get("name"): value} + if value is None or (isinstance(hit, dict) and "오류" in hit): + for brk in late.get(f["id"], []): + breaks.append(brk) + errors.append(brk["text"]) for b in boxes.values(): # 출력 상자 · 원가(Eval 과 같음) if b.get("kind") != "output": continue diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py new file mode 100644 index 000000000..2d109ff59 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py @@ -0,0 +1,395 @@ +"""M02 단가산출 흐름도 — 옛 로직 줄 → 흐름도(CfLogic) 변환 (PLAN 36-2 · 흐름도_적용_정함 2 · 3). + +옛 식은 지금 서버 파서(`master_formula.parse` · `store.cm.mf`)로 나무를 읽어 함수 상자 시트로 +· 이름마다 받는 알약 → 칸(B2 · B3 …) · 식은 칸 주소 · 함수 상자 하나 = 결과 하나. + + 입력 → 입력 상자 「설계 값」(변수 · 고르기 = choice · 범위) + 중간 · 호표 수량 × 단가 · 덧줄 · 결과 → 함수 상자 · 비목 합 → 출력 상자(비목 셋 · 결과 = 값) + 호표 단가 · 식 속 요소 키 → 그룹 입력 상자(인력 · 재료 · 기계 · 유가전력 · 환율 · 요율) 요소 알약 + · 키에 {입력} 이 끼면 조건 찾기(field 「키」 · 같음 · from = 입력 알약 · 자리 여럿 = 글 잇는 함수) + 찾기(표, 조건=값).칸 → 표 입력 상자 「표」 알약 + 선 규칙 「표에서 찾기」(lookup) + 로직(키, 넘길 값=…) → 다른 로직 입력 상자(받는 알약 = 넘길 값 · 내보내는 알약 = 비목 · 결과) + 만약 → IF · 그리고 / 또는 → AND / OR · 최소 / 최대 → MIN / MAX + · 올림 / 버림 / 반올림 → ROUNDUP / ROUNDDOWN / ROUND + 자동 배치 — 입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위에서 아래로 + +부르는 꼴 — convert(row, ctx={key, file, master}) → {flow, notes} · row = 옛 로직 줄 · 파일 안 씀. +""" + +from __future__ import annotations + +import json +import re +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +COSTS = ("노무비", "재료비", "경비") +OUTPUT_PORT = "값" +TABLE_PORT = "표" +COLORS = {"input": "#2f80ed", "function": "#7b4fd6", "output": "#27ae60"} +GROUP_OF = {"LB": "인력", "MT": "재료", "MP": "재료", "MO": "유가전력", "EQ": "기계"} +GROUP_OF |= {"FX": "환율", "RT": "요율"} +FUNCS = {"만약": "IF", "최소": "MIN", "최대": "MAX", "올림": "ROUNDUP", "버림": "ROUNDDOWN"} +FUNCS["반올림"] = "ROUND" +OPS = {"==": "=", "!=": "<>"} +JOINS = {"그리고": "AND", "또는": "OR"} +_HOLE = re.compile(r"\{([^{}]+)\}") + + +class Unsupported(Exception): + """옮기지 못하는 꼴 — 쉬운 말 까닭.""" + + +def plain(v: Any) -> Any: + """JSON 값 — Decimal → int · float.""" + if isinstance(v, Decimal): + return int(v) if v == v.to_integral_value() else float(v) + if isinstance(v, (list, tuple)): + return [plain(x) for x in v] + if isinstance(v, dict): + return {k: plain(x) for k, x in v.items()} + return v + + +def literal(v: Any) -> str: + if isinstance(v, str): + return '"' + v.replace('"', '""') + '"' + if isinstance(v, Decimal): + return format(v.normalize(), "f") + return str(plain(v)) + + +class _Flow: + """흐름 한 벌 — 상자 · 선 · 못 옮긴 곳.""" + + def __init__(self, row: dict, master: Any): + self.row = row + self.master = master + self.specs = {s["이름"]: s for s in row.get("입력") or []} + self.boxes: dict[str, dict] = {} + self.links: list[dict] = [] + self.notes: list[str] = [] + self.named: dict[str, dict] = {} # 중간 · 덧줄 이름 → 내보내는 알약 + self.cache: dict[str, dict] = {} # 같은 요소 · 표 · 로직 · 값 → 알약(하나만) + + # ── 상자 · 선 ───────────────────────────────────────────────────── + def box(self, kind: str, name: str, **body: Any) -> dict: + bid = f"{kind[:2]}-{len(self.boxes) + 1}" + self.boxes[bid] = {"id": bid, "kind": kind, "name": name, "color": COLORS[kind]} + self.boxes[bid] |= {"unit": "-", "x": 0, "y": 0, **body} + return self.boxes[bid] + + def link(self, src: dict, to: dict, rule: dict | None = None) -> None: + one = {"id": f"l{len(self.links) + 1}", "from": dict(src), "to": dict(to)} + self.links.append(one | ({"rule": rule} if rule else {})) + + def design(self) -> None: + variables = [] + for s in self.row.get("입력") or []: + pick = s.get("고르기") + one = {"name": s["이름"], "type": "choice" if pick else "number"} + one |= {"unit": str(s.get("단위") or "-"), "example": plain(pick[0]) if pick else None} + if pick: + one["choices"] = plain(pick) + if "범위" in s: + one["range"] = plain(s["범위"]) + variables.append(one) + box = {"id": "in-design", "kind": "input", "name": "설계 값", "color": COLORS["input"]} + inp = {"mode": "variable", "elements": [], "variables": variables} + self.boxes["in-design"] = box | {"unit": "-", "x": 0, "y": 0, "input": inp} + + # ── 바깥 값 알약 ────────────────────────────────────────────────── + def element( + self, group: str, ref: str, unit: str, label: str, rule: dict | None = None + ) -> dict: + """그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나 + · rule = 조건 찾기(field 「키」 — ref 의 {…} 에 알약 값을 끼운 요소 · CfMaterialRule).""" + key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}" + if key in self.cache: + return self.cache[key] + gkey = f"group|{group}" + if gkey not in self.cache: + inp = {"mode": "group", "group": group, "elements": [], "variables": []} + self.cache[gkey] = self.box("input", group, input=inp) + els = self.cache[gkey]["input"]["elements"] + name = label + while any(e["name"] == name or e.get("slot") == name for e in els): + name += "·" + one = {"ref": ref, "name": name, "spec": "", "unit": unit or "-", "mode": "fixed"} + if rule: + one |= {"mode": "rule", "rule": rule, "slot": name} + self.link(rule["from"], {"box": self.cache[gkey]["id"], "name": name}) + els.append(one) + self.cache[key] = {"box": self.cache[gkey]["id"], "name": name} + return self.cache[key] + + def priced(self, group: str, ref: Any, unit: str, label: str, field: str | None = None): + """요소 → 식 마디 「@port」 — 키에 {입력} 이 끼면 조건 찾기(field 키 · 같음 · from 알약) + · 자리 표시가 여럿이면 함수 상자가 글을 이어 만든 알약 하나로(정함 · 조사 ③).""" + if isinstance(ref, dict): + texts = {k: v for k, v in ref.items() if isinstance(v, str) and _HOLE.search(v)} + else: + texts = {None: ref} if _HOLE.search(ref) else {} + if not texts: + text = ref if isinstance(ref, str) else json.dumps(plain(ref), ensure_ascii=False) + return ("@port", self.element(group, self._key(text, field), unit, label)) + 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) + tpl += f"#{field}" if field else "" + rule = {"field": "키", "op": "같음", "from": src} + return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule)) + + def hole(self, name: str) -> dict: + """자리 표시 {이름} 의 값 알약 — 입력 · 중간.""" + if name in self.specs: + return {"box": "in-design", "name": name} + if name in self.named: + return self.named[name] + raise Unsupported(f"자리 표시 {{{name}}} 의 값이 없음") + + def _key(self, ref: str, field: str | None) -> str: + """「ID:원문번호」 → 키 · 기계 칸은 「EQ키#칸」(입력 판 꼴).""" + if ":" in ref and not ref.lstrip().startswith("{"): + ref = str(self.master.get(ref).get("키")) + if field and ref[:2] != "EQ": + raise Unsupported(f"「{ref}」 의 칸 「{field}」 읽기") + return f"{ref}#{field}" if field else ref + + def table(self, key: str) -> dict: + if f"tb|{key}" not in self.cache: + group = "소요량" if key[:2] in ("QF", "QC") else "계수" + inp = {"mode": "group", "group": group, "elements": [], "variables": []} + box = self.box("input", f"표 {key}", input=inp | {"table": {"key": key}}) + self.cache[f"tb|{key}"] = {"box": box["id"], "name": TABLE_PORT} + return self.cache[f"tb|{key}"] + + def logic(self, node: tuple) -> str: + """로직(키, 넘길 값) → 다른 로직 입력 상자 id(같은 부르기는 하나).""" + key = "lg|" + repr(node) + if key not in self.cache: + head = node[1] + if "{" in head: + raise Unsupported(f"입력 따라 바뀌는 로직 「{head}」") + ref = str(self.master.logic(head).get("키")) + outs = [{"name": n, "unit": "원"} for n in (*COSTS, "결과")] + designs = [{"name": k, "unit": "-"} for k, _ in node[2]] + logic = {"key": ref, "outputs": outs, "designs": designs} + box = self.box("input", f"로직 {ref}", input={"mode": "logic", "elements": []}) + box["input"] |= {"variables": [], "logic": logic} + for k, v in node[2]: + self.link(self.value(v), {"box": box["id"], "name": k}) + self.cache[key] = {"box": box["id"]} + return self.cache[key]["box"] + + def value(self, node: tuple) -> dict: + """조건 · 넘길 값 → 내보내는 알약(입력 · 중간 · 아니면 함수 상자 · 같은 식은 하나).""" + if node[0] == "name" and node[1] in self.specs: + return {"box": "in-design", "name": node[1]} + if node[0] == "name" and node[1] in self.named: + return self.named[node[1]] + key = "val|" + repr(node) + if key not in self.cache: + label = literal(node[1]).strip('"') if node[0] in ("num", "str") else "값" + self.cache[key] = self.func(label, node) + return self.cache[key] + + # ── 함수 상자 ───────────────────────────────────────────────────── + def func(self, name: str, node: tuple, scope: dict | None = None) -> dict: + """식 하나 → 함수 상자(결과 하나) · 받는 알약마다 B2 · B3 … · 결과 칸은 그 아래.""" + bid = f"fn-{len(self.boxes) + 1}" + self.boxes[bid] = {} # 자리 먼저 — 안에서 만드는 상자와 id 가 겹치지 않게 + inputs: list[dict] = [] + seen: dict[str, str] = {} + + def take(src: dict, label: str, rule: dict | None = None) -> str: + k = json.dumps([src, rule], ensure_ascii=False, sort_keys=True, default=str) + if k not in seen: + port = label + while any(i["port"] == port for i in inputs): + port += "·" + seen[k] = f"B{len(inputs) + 2}" + inputs.append({"port": port, "cell": seen[k]}) + self.link(src, {"box": bid, "name": port}, rule) + return seen[k] + + formula = self.excel(node, take, scope or {}) + out = f"B{len(inputs) + 2}" + cells: dict[str, Any] = {"A1": {"값": "알약"}, "B1": {"값": "값"}} + for i in inputs: + cells["A" + i["cell"][1:]] = {"값": i["port"]} + cells["A" + out[1:]] = {"값": name} + cells[out] = {"식": formula} + sheet = {"id": f"s-{bid}", "이름": name[:31] or bid, "칸": cells} + func = {"sheet": sheet, "inputs": inputs, "variables": []} + func["output"] = {"cell": out, "name": name, "unit": "-"} + body = {"id": bid, "kind": "function", "name": name, "color": COLORS["function"]} + self.boxes[bid] = body | {"unit": "-", "x": 0, "y": 0, "func": func} + return {"box": bid, "name": name} + + def excel(self, node: tuple, take, scope: dict) -> str: # noqa: C901 — 마디 갈래 그대로 + kind = node[0] + if kind in ("num", "str"): + return literal(node[1]) + if kind == "neg": + return f"-({self.excel(node[1], take, scope)})" + if kind == "bin": + a, b = (self.excel(x, take, scope) for x in node[2:4]) + if node[1] in JOINS: + return f"{JOINS[node[1]]}({a},{b})" + return f"({a}{OPS.get(node[1], node[1])}{b})" + if kind == "call": + items = [self.excel(x, take, scope) for x in node[2]] + if node[1] in ("올림", "버림", "반올림") and len(items) == 1: + items.append("0") + return f"{FUNCS[node[1]]}({','.join(items)})" + if kind == "name": + return self.name(node[1], take, scope) + if kind == "ref": + group = GROUP_OF.get(node[1][:2], "기계") + return self.excel(self.priced(group, node[1], "", node[1]), take, scope) + if kind == "field": + return self.field(node, take, scope) + if kind == "logic": + return take({"box": self.logic(node), "name": "결과"}, f"{node[1]} 결과") + if kind == "@port": + return take(node[1], node[1]["name"]) + if kind == "@sum": + cells = [take(p, p["name"]) for p in node[1]] + return f"SUM({','.join(cells)})" if cells else "0" + if kind == "@text": # 자리 표시 글 「레미콘 {a}-{b}」 → "레미콘 "&B2&"-"&B3 + bits = _HOLE.split(node[1]) + out = [literal(b) if i % 2 == 0 else take(self.hole(b), b) for i, b in enumerate(bits)] + return "&".join(x for x in out if x != '""') or '""' + raise Unsupported(f"모르는 식 마디 「{kind}」") + + def name(self, name: str, take, scope: dict) -> str: + if name in scope: # 덧줄의 비목 합 · 줄 금액 + return self.excel(("@sum", scope[name]), take, scope) + if name in self.named: + return take(self.named[name], name) + if name in self.specs: + return take({"box": "in-design", "name": name}, name) + raise Unsupported(f"모르는 이름 「{name}」") + + def field(self, node: tuple, take, scope: dict) -> str: + base, col = node[1], node[2] + if base[0] == "find": + if "{" in base[1]: + raise Unsupported(f"입력 따라 바뀌는 표 「{base[1]}」") + key = str(self.master.get(base[1]).get("키")) + conds = [{"col": k, "from": self.value(v)} for k, v in base[2]] + return take(self.table(key), col, {"kind": "lookup", "conds": conds, "col": col}) + if base[0] == "ref": + 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": + return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}") + if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope: + return self.name(f"줄.{col}", take, scope) + raise Unsupported(f"칸 읽기 「{col}」") + + +# ── 한 로직 ────────────────────────────────────────────────────────── +def convert(row: dict, ctx: dict | None = None) -> dict: + """옛 로직 줄 → {flow: CfLogic, notes: 못 옮긴 곳} · ctx = {key, file, master}(M01 저장소 + /api/m01/logic/flow) · master 가 없으면 저장소가 읽어 둔 것.""" + master = (ctx or {}).get("master") or store.loaded()[1] + f = _Flow(row, master) + f.design() + try: + _body(f, row) + except Unsupported as e: + f.notes.append(f"못 옮김 — {e}") + except mf.FormulaError as e: + f.notes.append(f"못 옮김 — {e}") + f.boxes = {k: b for k, b in f.boxes.items() if b} # 못 옮겨 비운 자리 + _layout(f) + flow = {"key": row.get("키"), "name": row.get("이름") or ""} + flow |= {"resultUnit": row.get("결과단위") or "", "boxes": list(f.boxes.values())} + flow |= {"links": f.links, "userElements": []} + return {"flow": flow, "notes": list(dict.fromkeys(f.notes))} + + +def _output(f: _Flow, name: str, cost: str, src: dict, rule: dict | None, on: str) -> None: + out = {"cost": cost, "unit": "원" if cost != "값" else "-"} + if rule and rule.get("대상") == on: + out["round"] = {"places": int(rule.get("자리") or 0), "method": str(rule.get("방법"))} + box = f.box("output", name, output=out) + f.link(src, {"box": box["id"], "name": OUTPUT_PORT}) + + +def _body(f: _Flow, row: dict) -> None: + rule = row.get("끝수") if isinstance(row.get("끝수"), dict) else None + for step in row.get("중간") or []: + f.named[step["이름"]] = f.func(step["이름"], mf.parse(step["식"])) + if "결과" in row: + _output(f, "결과", "값", f.func("결과", mf.parse(row["결과"]["식"])), rule, "결과") + return + parts: dict[str, list[dict]] = {c: [] for c in COSTS} + lines: dict[str, list[dict]] = {} + for i, item in enumerate(row.get("호표") or []): + name = str(item.get("이름") or f"줄 {i + 1}") + qty = mf.parse(str(item["수량"])) + if item.get("종류") == "로직": + call = mf.parse(str(item["요소"])) + if call[0] != "logic": + raise Unsupported(f"「{name}」 단가가 로직 부르기 꼴 아님") + bid = f.logic(call) + whole = ("@sum", [{"box": bid, "name": k} for k in COSTS]) + for c in [item["비목"]] if item.get("비목") else COSTS: + price = whole if item.get("비목") else ("@port", {"box": bid, "name": c}) + part = f.func(f"{name} {c}", ("bin", "*", qty, price)) + parts[c].append(part) + lines.setdefault(f"줄.{name}", []).append(part) + continue + unit = str(item.get("단위") or "") + price = f.priced(str(item.get("종류")), item["요소"], unit, f"{name} 단가") + part = f.func(f"{name} 금액", ("bin", "*", qty, price)) + parts[item["비목"]].append(part) + lines.setdefault(f"줄.{name}", []).append(part) + for extra in row.get("덧줄") or []: + scope = {c: list(parts[c]) for c in COSTS} | lines + part = f.func(extra["이름"], mf.parse(extra["식"]), scope) + f.named[extra["이름"]] = part + parts[extra["비목"]].append(part) + for c in COSTS: + if parts[c]: + _output(f, c, c, f.func(f"{c} 합", ("@sum", parts[c])), rule, "계") + + +# ── 자동 배치 ──────────────────────────────────────────────────────── +def _layout(f: _Flow) -> None: + """입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위→아래(겹침 없음).""" + feeds: dict[str, set[str]] = {} + for ln in f.links: + srcs = [ln["from"], *[c["from"] for c in (ln.get("rule") or {}).get("conds", [])]] + feeds.setdefault(ln["to"]["box"], set()).update(s["box"] for s in srcs) + depth: dict[str, int] = {} + + def level(bid: str, path: frozenset = frozenset()) -> int: + if bid not in depth: + ins = [x for x in feeds.get(bid, ()) if x not in path and x in f.boxes] + depth[bid] = 1 + max((level(x, path | {bid}) for x in ins), default=-1) + return depth[bid] + + for bid in f.boxes: + level(bid) + last = max(depth.values(), default=0) + 1 + rows: dict[int, int] = {} + for bid, box in f.boxes.items(): + col = last if box["kind"] == "output" else depth[bid] + box["x"], box["y"] = 40 + col * 300, 40 + rows.get(col, 0) * 170 + rows[col] = rows.get(col, 0) + 1 diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index 038c1e9a7..aa8c08fc4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -35,6 +35,10 @@ export type CfValues = Record>; type Scalar = number | string | boolean | null; +/** 함수 상자 칸으로 가는 표 찾기 실패 값 — 칸에 NA() 를 넣음(엑셀과 같게 · IF 가 안 고른 갈래면 무시) */ +const NA_VALUE = "#N/A"; +const isErrorValue = (v: unknown): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); + /** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ export interface CfBreak { kind: @@ -228,7 +232,11 @@ function runFunc( ): Scalar { const func = box.func!; const sheet: Sheet = structuredClone(func.sheet); - for (const i of func.inputs) put(sheet.칸, i.cell, arrive[i.port] ?? given[i.port] ?? null); + for (const i of func.inputs) { + const v = arrive[i.port] ?? given[i.port] ?? null; + if (v === NA_VALUE) sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; + else put(sheet.칸, i.cell, v); + } for (const v of func.variables) if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); @@ -262,6 +270,8 @@ export function evalLogic( const blocked = new Set(); /** 조건이 안 맞아 쉬는 상자 — 그 아래로 번짐 */ const resting = new Set(); + /** 함수 상자로 가는 표 찾기 실패 — 그 함수 결과가 오류일 때만 끊긴 곳(엑셀과 같게) */ + const late = new Map(); /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ const through = (l: CfLink): CfValue => { const v = valueOf(l.from.box, l.from.name); @@ -295,7 +305,22 @@ export function evalLogic( ? "ManyRows" : "RuleError"; const text = tl(key, fill); - found.push({ kind, level: "문제", box: l.to.box, port: l.to.name, link: l.id, text }); + const brk: CfBreak = { + kind, + level: "문제", + box: l.to.box, + port: l.to.name, + link: l.id, + text, + }; + if ( + l.rule.kind === "lookup" && + lg.boxes.find((b) => b.id === l.to.box)?.kind === "function" + ) { + late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]); + return NA_VALUE; + } + found.push(brk); errors.push(text); } if (res.why) blocked.add(l.to.box); @@ -354,9 +379,13 @@ export function evalLogic( } else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors); else if (b.kind === "function") { const got = arrive(b); - ports[b.id] = { - [b.func!.output.name]: blocked.has(b.id) ? null : runFunc(b, got, given, errors), - }; + const out = blocked.has(b.id) ? null : runFunc(b, got, given, errors); + ports[b.id] = { [b.func!.output.name]: out }; + if (out === null || isErrorValue(out)) + for (const brk of late.get(b.id) ?? []) { + found.push(brk); + errors.push(brk.text); + } } else { const v = arrive(b)[OUTPUT_PORT] ?? null; let scalar: Scalar = Array.isArray(v) ? null : v; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index bee7c9010..3c22078b2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -12,7 +12,8 @@ export type CfKind = "input" | "function" | "output"; export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; export type CfCost = "노무비" | "재료비" | "경비" | "값"; -/** 재료 요소 방식 — 고정(품목 하나) · 선택 자리(프로젝트에서 고름) · 조건 찾기(규격 / 이름에 알약 값) */ +/** 재료 요소 방식 — 고정(품목 하나) · 선택 자리(프로젝트에서 고름) · 조건 찾기(규격 / 이름에 알약 값) + * 조건 찾기는 인력 · 기계 · 유가전력 요소도 씀(field 「키」 — 입력값으로 요소를 고름) */ export type CfMaterialMode = "fixed" | "choice" | "rule"; /** 재료 찾을 범위 */ @@ -22,9 +23,11 @@ export interface CfScope { 찾는말?: string; } -/** 재료 조건 찾기 — 품목의 규격 / 이름에 [알약 값] 이 포함 · 같음 */ +/** 재료 조건 찾기 — 품목의 규격 / 이름에 [알약 값] 이 포함 · 같음 + * field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 · + * 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */ export interface CfMaterialRule { - field: "규격" | "이름"; + field: "규격" | "이름" | "키"; op: "포함" | "같음"; from: CfPort; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index 3e2233023..57bf93bec 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -159,7 +159,8 @@ export function applyRule( return { value: got, why: null, formula }; } -/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */ +/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) + * 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */ function lookup( rule: Extract, value: unknown, @@ -182,7 +183,7 @@ function lookup( }; const out = at(rule.col); // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 - const tests: ((r: number) => string)[] = []; + const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : []; let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; @@ -191,7 +192,7 @@ function lookup( const same = at(c.col); const low = at(`${c.col}·아래`); const high = at(`${c.col}·위`); - if (same) tests.push((r) => `${same}${r}=${ref}`); + if (same) tests.push((r) => `OR(ISBLANK(${same}${r}),${same}${r}=${ref})`); else if (low && high) tests.push( (r) => diff --git a/resources/tester/test_m02_df_convert.py b/resources/tester/test_m02_df_convert.py new file mode 100644 index 000000000..de856799b --- /dev/null +++ b/resources/tester/test_m02_df_convert.py @@ -0,0 +1,291 @@ +"""M02 흐름도 — 옛 로직 줄 → 흐름도 변환(M02_DF_Convert) 시험 (PLAN 36-2). + +· 꼴 시험 — 견본 로직 몇을 옮겨 상자 · 선 · 규칙 · 자동 배치(겹침 없음)를 봄 +· 전수 대조 — 사본 폴더(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 · 식 글은 옛 파서로 셈) + · 같은 물음은 한 번만 셈 +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +COPY = ROOT / "tmp" / "master_copy_36" +REPORT = ROOT / "tmp" / "convert_report_36.json" +WORKERS = 6 + +# 일꾼(따로 뜬 파이썬 · 사본 폴더) — 표준 입력 = 키 목록 JSON · 표준 출력 = 줄마다 결과 JSON +WORKER = r""" +import json, os, re, sys +from decimal import Decimal +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 +from M02_MasterTemplete.DataFlow import M02_DF_Values as dv +mf, cm = store.cm.mf, store.cm +master = store.loaded()[1] +seen, flows = {}, {} +one_value = dv.one_value +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"] + return flows[key] +def num(v): + try: + return float(v) + except (TypeError, ValueError): + return None +def same(a, b): + a, b = num(a), num(b) + return a is not None and b is not None and abs(a - b) <= 1e-6 * max(1.0, abs(a)) +def check(row, given): + try: + old = mf.run(master, row["키"], given) + except Exception as e: + return {"state": "옛 셈 오류", "why": str(e)[:200]} + got = cv.convert(row) + flows[row["키"]] = got["flow"] + try: + design = {"in-design": {k: cv.plain(v) for k, v in given.items()}} + new = cp.run(got["flow"], design, load, []) + except Exception as e: + return {"state": "흐름 셈 멈춤", "why": repr(e)[:200], "notes": got["notes"]} + cost = new["cost"] + if "결과" in old: + pairs = [("결과", old["결과"], cost["값들"].get("결과"))] + else: + pairs = [(k, old[k], cost[k]) for k in ("노무비", "재료비", "경비", "계")] + bad = [[k, num(a), num(b)] for k, a, b in pairs if not same(a, b)] + if not bad: + return {"state": "맞음"} + return {"state": "다름", "diff": bad, "notes": got["notes"], "errors": new["errors"][:6]} +for key in json.loads(sys.stdin.read()): + row = master.logic(key) + plain = cm.시험입력({k: v for k, v in row.items() if k != "시험입력"}) + test = cm.시험입력(row) + out = {"key": key, "시험입력": check(row, test)} + out["기본 입력"] = out["시험입력"] if plain == test else check(row, plain) + print(json.dumps(out, ensure_ascii=False, default=str), flush=True) +""" + + +def _copy() -> None: + """사본 폴더 — 없으면 정본 JSON 을 복사(정본은 읽기만).""" + if not any(COPY.glob("*.json")): + COPY.mkdir(parents=True, exist_ok=True) + for f in (ROOT / "resources" / "master_data").glob("*.json"): + shutil.copy2(f, COPY / f.name) + + +def _env() -> dict: + return {**os.environ, "AISLO_MASTER_DIR": str(COPY), "PYTHONIOENCODING": "utf-8"} + + +def _run(code: str, stdin: str = "") -> str: + got = subprocess.run( + [sys.executable, "-c", code], + input=stdin, + capture_output=True, + text=True, + encoding="utf-8", + cwd=ROOT, + env=_env(), + timeout=7200, + ) + assert got.returncode == 0, got.stderr[-2000:] + return got.stdout + + +def _keys() -> list[str]: + code = ( + "import json, os, sys; sys.path.insert(0, os.getcwd())\n" + "from M01_MasterData import M01_MasterData_Store as store\n" + "files = store.loaded()[0]\n" + "print(json.dumps([r['키'] for d in files.values() if d.get('그룹') == '로직'" + " for r in d.get('줄') or []]))" + ) + return json.loads(_run(code)) + + +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 "값 다름(알림 없음)" + + +# ── 꼴 시험 ────────────────────────────────────────────────────────── +SHAPE = r""" +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_Convert as cv +master = store.loaded()[1] +got = {k: cv.convert(master.logic(k)) for k in sys.argv[1:]} +print(json.dumps(got, ensure_ascii=False, default=str)) +""" + + +def _shape(*keys: str) -> dict: + got = subprocess.run( + [sys.executable, "-c", SHAPE, *keys], + capture_output=True, + text=True, + encoding="utf-8", + cwd=ROOT, + env=_env(), + timeout=600, + ) + assert got.returncode == 0, got.stderr[-2000:] + return json.loads(got.stdout) + + +def test_꼴_상자_선_규칙_배치() -> None: + _copy() + got = _shape("GC000994", "GC000267", "GF000003", "GC000264", "GF000185") + for key, one in got.items(): + flow = one["flow"] + ids = [b["id"] for b in flow["boxes"]] + assert len(ids) == len(set(ids)), key + assert all(ln["from"]["box"] in ids and ln["to"]["box"] in ids for ln in flow["links"]), key + spots = [(b["x"], b["y"]) for b in flow["boxes"]] + assert len(spots) == len(set(spots)), f"{key} 상자 겹침" + kinds = {b["kind"] for b in flow["boxes"]} + assert {"input", "function", "output"} <= kinds, key + outs = [b for b in flow["boxes"] if b["kind"] == "output"] + ins = [b for b in flow["boxes"] if b["kind"] == "input"] + assert max(b["x"] for b in ins) < min(b["x"] for b in outs), ( + f"{key} 입력 왼쪽 · 출력 오른쪽" + ) + for b in flow["boxes"]: + if b["kind"] == "function": + f = b["func"] + assert ( + f["output"]["cell"] in f["sheet"]["칸"] + and "식" in f["sheet"]["칸"][f["output"]["cell"]] + ) + # 돈 아닌 로직 = 출력 「결과」(값) · 반올림 → ROUND + res = got["GF000003"]["flow"] + assert [b["output"]["cost"] for b in res["boxes"] if b["kind"] == "output"] == ["값"] + assert any("ROUND(" in str(b.get("func")) for b in res["boxes"]) + # 표 찾기 = 표 알약 + 선 규칙 lookup · 로직 줄 = 다른 로직 입력(비목 셋) + one = got["GC000994"]["flow"] + assert any((ln.get("rule") or {}).get("kind") == "lookup" for ln in one["links"]) + logic = [b for b in one["boxes"] if (b.get("input") or {}).get("mode") == "logic"] + assert logic and logic[0]["input"]["logic"]["key"].startswith("GC") + assert {b["output"]["cost"] for b in one["boxes"] if b["kind"] == "output"} <= { + "노무비", + "재료비", + "경비", + } + # EQ:{기계} = 기계 요소 조건 찾기(field 키 · from = 입력 알약 「기계」) + els = [ + e + for b in got["GC000267"]["flow"]["boxes"] + for e in (b.get("input") or {}).get("elements", []) + ] + keyed = [e for e in els if e.get("mode") == "rule"] + assert keyed and all(e["rule"]["field"] == "키" and "{" in e["ref"] for e in keyed) + machine = [e for e in keyed if e["ref"].startswith("EQ:{기계}#")] + assert machine and {e["rule"]["from"]["name"] for e in machine} == {"기계"} + # 레미콘 {a}-{b}-{c} = 글 잇는 함수(&) → 재료 조건 찾기 + remi = got["GC000264"]["flow"] + assert any("&" in str(b.get("func")) for b in remi["boxes"]) + # 덧줄 줄.'이름' · 비목 합 + assert got["GF000185"]["notes"] == [] or all( + not n.startswith("못 옮김") for n in got["GF000185"]["notes"] + ) + + +# ── 전수 대조 ──────────────────────────────────────────────────────── +def test_전수_대조_옛_셈_같음() -> None: + _copy() + keys = _keys() + assert len(keys) >= 1400 + chunks = [keys[i::WORKERS] for i in range(WORKERS)] + with ThreadPoolExecutor(WORKERS) as pool: + outs = list(pool.map(lambda c: _run(WORKER, json.dumps(c)), chunks)) + rows = [json.loads(x) for out in outs for x in out.splitlines() if x.startswith("{")] + assert len(rows) == len(keys) + report: dict = {"로직": len(rows)} + for kind in ("시험입력", "기본 입력"): + states = [r[kind]["state"] for r in rows] + run = [s for s in states if s != "옛 셈 오류"] + reasons: dict[str, int] = {} + for r in rows: + if r[kind]["state"] not in ("맞음", "옛 셈 오류"): + why = _reason(r[kind]) + reasons[why] = reasons.get(why, 0) + 1 + report[kind] = { + "옛 셈 오류(대조 밖)": states.count("옛 셈 오류"), + "대조": len(run), + "맞음": run.count("맞음"), + "맞는 비율": round(run.count("맞음") / max(1, len(run)), 4), + "안 맞는 까닭": dict(sorted(reasons.items(), key=lambda x: -x[1])), + } + report["안 맞는 로직"] = [ + {"키": r["key"], **r["시험입력"]} for r in rows if r["시험입력"]["state"] not in ("맞음",) + ] + REPORT.write_text( + json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8" + ) + # 변환이 멈추는 로직은 없어야 함 + assert all(r["시험입력"]["state"] != "흐름 셈 멈춤" for r in rows), [ + r for r in rows if r["시험입력"]["state"] == "흐름 셈 멈춤" + ][:3] + assert report["시험입력"]["맞는 비율"] >= 0.5, report["시험입력"] diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 10c2c925d..b29d28679 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -786,3 +786,75 @@ def test_조건_여러_줄_겹침_안맞음() -> None: over, none = got assert "조건 겹침" in over assert "조건 안 맞음" in none + + +# ── 36-2 표 찾기 — 옛 find 와 같은 뜻 · 실패는 칸에 #N/A(엑셀과 같게) ──────────────── +BLOCK = [["구분", "종류", "값"], ["가", None, 5], ["가", "특", None], ["다", "보", 8]] + + +def _lazy_flow() -> dict: + var = {"mode": "variable", "elements": [], "variables": []} + var["variables"] = [ + {"name": "x", "type": "number", "unit": "-", "example": 1}, + {"name": "구분", "type": "text", "unit": "-", "example": "가"}, + {"name": "종류", "type": "text", "unit": "-", "example": "보"}, + ] + table = {"mode": "group", "group": "소요량", "elements": [], "variables": []} + table["table"] = {"key": "QT"} + cells = {"B2": {"값": 0}, "B3": {"값": 0}, "B4": {"식": "IF(B3=1,B2,0)"}} + func = {"sheet": {"id": "s1", "이름": "f", "칸": cells}, "variables": []} + func["inputs"] = [{"port": "찾은 값", "cell": "B2"}, {"port": "x", "cell": "B3"}] + func["output"] = {"cell": "B4", "name": "결과", "unit": "-"} + box = lambda i, k, n, **b: { + "id": i, + "kind": k, + "name": n, + "color": "#000", + "unit": "-", + "x": 0, + "y": 0, + **b, + } # noqa: E731 + conds = [{"col": "구분", "from": {"box": "v", "name": "구분"}}] + conds.append({"col": "종류", "from": {"box": "v", "name": "종류"}}) + links = [ + {"id": "l1", "from": {"box": "t", "name": "표"}, "to": {"box": "f", "name": "찾은 값"}}, + {"id": "l2", "from": {"box": "v", "name": "x"}, "to": {"box": "f", "name": "x"}}, + {"id": "l3", "from": {"box": "f", "name": "결과"}, "to": {"box": "o", "name": "값"}}, + ] + links[0]["rule"] = {"kind": "lookup", "conds": conds, "col": "값"} + boxes = [box("v", "input", "설계", input=var), box("t", "input", "표", input=table)] + boxes += [ + box("f", "function", "f", func=func), + box("o", "output", "결과", output={"cost": "값", "unit": "-"}), + ] + return { + "key": None, + "name": "t", + "resultUnit": "", + "boxes": boxes, + "links": links, + "userElements": [], + } + + +def test_표_찾기_빈_조건칸_무엇이든_빈_값줄_건너뜀_실패는_쓸때만_끊김(monkeypatch) -> None: + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + lg = _lazy_flow() + cases = [(1, "가", "보"), (1, "다", "보"), (1, "라", "보"), (0, "라", "보")] + vals = [{"v": {"x": x, "구분": g, "종류": k}, "t": {"표": BLOCK}} for x, g, k in cases] + got = _run( + f"const lg = {json.dumps(lg, ensure_ascii=False)};\n" + f"const vals = {json.dumps(vals, ensure_ascii=False)};\n" + "out(vals.map(v => { const e = E.evalLogic(lg, v); return [e.outputs.o, e.breaks.map(b => b.kind)]; }));" + ) + assert [g[0] for g in got[:2]] == [5, 8] + assert got[2][1] == ["표에 없는 조건"] and got[3] == [0, []] + monkeypatch.setattr(cp.dv, "one_value", lambda item, local=None: {"ok": True, "rows": BLOCK}) + for v, (screen_out, screen_breaks) in zip(vals, got): + srv = cp.run(lg, {"v": v["v"]}, lambda key: {}, []) + out = srv["outputs"]["o"] + 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