From a774b4e97b6175d7cc22dc15a1f5ed14bb69e9dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:32:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(m02-df):=2036-2=20=EB=8D=A7=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=AC=EB=A3=8C=20=EC=A1=B0=EA=B1=B4=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=E3=80=8C=EA=B2=80=EC=83=89=EC=96=B4=E3=80=8D=20?= =?UTF-8?q?=ED=8B=80=20=C2=B7=20=ED=91=9C=20=EB=B2=94=EC=9C=84=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EA=B7=9C=EC=B9=99=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model CfMaterialRule field 「검색어」 + template(「레미콘 {a}-{b}-{c}」) + slots(자리마다 알약) · materialSources · depends · 흐름 따라가기에 자리 알약도 - Compute 재료 조건 찾기 검색어 = 틀에 자리 알약 값을 끼운 글(옛 fill)을 dv.slot_price 값으로 넘김(서버 찾기는 sub4) - 표 찾기: 표 블록 「·범위규칙」 칸(줄마다 초과·이하 · 이상·이하 · 이상·미만 · 이하)을 Rule lookup · Compute _lookup 이 따름 · 없으면 초과·이하 - 변환: 재료 검색어 자리 = 검색어 틀 · scope 구분 · 상세구분 · 조건 찾기 요소로 들어가는 선은 안 만듦(depends 로 이음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 5 ++- M02_MasterTemplete/DataFlow/M02_DF_Compute.py | 39 +++++++++++++++++-- M02_MasterTemplete/DataFlow/M02_DF_Convert.py | 22 ++++++++--- M02_MasterTemplete/DataFlow/M02_DF_Model.ts | 24 ++++++++++-- M02_MasterTemplete/DataFlow/M02_DF_Rule.ts | 11 +++++- resources/tester/test_m02_df_convert.py | 12 +++++- resources/tester/test_m02_df_model.py | 20 ++++++++++ 7 files changed, 115 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 659b65e85..f1002f961 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -28,7 +28,10 @@ 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 1cfd10991..87b9ceeb9 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Compute.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Compute.py @@ -126,7 +126,10 @@ def _element_value( "field": rule.get("field"), "op": rule.get("op"), } - got = dv.slot_price(slot, ports.get(src.get("box"), {}).get(src.get("name"))) + value = ports.get(src.get("box"), {}).get(src.get("name")) + if rule.get("field") == "검색어": # 틀의 자리마다 알약 값 → 채운 검색어 글(빈 자리면 None) + value, slot["unit"] = _fill_template(rule, ports), el.get("unit") + got = dv.slot_price(slot, value) if not got["ok"] and el.get("default"): errors.append( f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈" @@ -153,6 +156,20 @@ def _element_value( return got.get("value") +def _fill_template(rule: dict, ports: dict) -> str | None: + """재료 검색어 틀의 자리에 slots 알약 값 — 옛 엔진 fill 그대로(빈 자리 = None).""" + env = {} + for x in rule.get("slots") or []: + src = x.get("from") or {} + env[x.get("name")] = ports.get(src.get("box"), {}).get(src.get("name")) + if any(v is None for v in env.values()): + return None + try: + return store.cm.mf.fill(str(rule.get("template") or ""), env) + except store.cm.mf.FormulaError: + return None + + def _port(el: dict) -> str: """요소 알약 이름 — Model `elementPort`(고정 아니면 자리 이름 slot · 없으면 name).""" slot = str(el.get("slot") or "").strip() @@ -342,6 +359,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: 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: @@ -359,10 +377,17 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]: 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)) - and (row[hi] is None or (high is not None and x <= high)) + 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) @@ -444,7 +469,12 @@ def _depends(lg: dict) -> list[tuple[str, str]]: for b in lg.get("boxes") or []: for el in (b.get("input") or {}).get("elements") or []: if el.get("mode") == "rule" and el.get("rule"): - out.append(((el["rule"].get("from") or {}).get("box"), b["id"])) + rule = el["rule"] + srcs = [ + rule.get("from") or {}, + *[x.get("from") or {} for x in rule.get("slots") or []], + ] + out += [(p.get("box"), b["id"]) for p in srcs] return out @@ -482,6 +512,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict: REST = "앞 조건이 안 맞아 쉬는 갈래" +RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE) NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py index b4141badd..07742adaf 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Convert.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Convert.py @@ -103,10 +103,10 @@ class _Flow: # ── 바깥 값 알약 ────────────────────────────────────────────────── def element( - self, group: str, ref: str, unit: str, label: str, rule: dict | None = None + self, group: str, ref: str, unit: str, label: str, rule: dict | None = None, **more: Any ) -> dict: """그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나 - · rule = 조건 찾기(field 「키」 — ref 의 {…} 에 알약 값을 끼운 요소 · CfMaterialRule).""" + · rule = 조건 찾기(CfMaterialRule · 알약 값은 Model depends 로 · 선 없음) · more = scope.""" key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}" if key in self.cache: return self.cache[key] @@ -120,8 +120,7 @@ class _Flow: 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}) + one |= {"mode": "rule", "rule": rule, "slot": name, **more} els.append(one) self.cache[key] = {"box": self.cache[gkey]["id"], "name": name} return self.cache[key] @@ -139,14 +138,20 @@ class _Flow: if len(texts) > 1: raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」") ((at, text),) = texts.items() + if at == "검색어": # 재료 검색어 틀 — 자리마다 알약 · 서버는 옛 검색어 찾기(정함 · sub4) + names = list(dict.fromkeys(_HOLE.findall(text))) + slots = [{"name": n, "from": self.hole(n)} for n in names] + rule = {"field": "검색어", "op": "같음", "from": slots[0]["from"]} + rule |= {"template": text, "slots": slots} + scope = {k: str(v) for k, v in ref.items() if k in ("구분", "상세구분") and v} + tpl = json.dumps(plain(ref), ensure_ascii=False) + return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule, scope=scope)) # 찾는 값 = 원문번호(「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} @@ -383,6 +388,11 @@ def _layout(f: _Flow) -> None: 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) + for bid, b in f.boxes.items(): # 조건 찾기 요소 — 값을 주는 알약 상자 뒤 + for e in (b.get("input") or {}).get("elements") or []: + rule = e.get("rule") or {} + srcs = [rule.get("from"), *[x["from"] for x in rule.get("slots") or []]] + feeds.setdefault(bid, set()).update(p["box"] for p in srcs if p) depth: dict[str, int] = {} def level(bid: str, path: frozenset = frozenset()) -> int: diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index 3c22078b2..8a012ef26 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -27,9 +27,19 @@ export interface CfScope { * field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 · * 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */ export interface CfMaterialRule { - field: "규격" | "이름" | "키"; + field: "규격" | "이름" | "키" | "검색어"; op: "포함" | "같음"; from: CfPort; + /** field 「검색어」 — 옛 재료 검색어 찾기(master_pick.search) 의 틀(「레미콘 {a}-{b}-{c}」) · scope 구분 · 상세구분 안 */ + template?: string; + /** 틀의 자리 {이름} 마다 값을 줄 알약 — from = 첫 자리 */ + slots?: { name: string; from: CfPort }[]; +} + +/** 재료 조건 찾기가 값을 받는 알약 — from + 틀 자리들(겹침 없이) */ +export function materialSources(rule: CfMaterialRule): CfPort[] { + const all = [rule.from, ...(rule.slots ?? []).map((x) => x.from)]; + return all.filter((p, i) => all.findIndex((q) => q.box === p.box && q.name === p.name) === i); } /** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id @@ -446,7 +456,8 @@ export function depends(lg: CfLogic): { from: string; to: string }[] { } for (const b of lg.boxes) for (const e of b.input?.elements ?? []) - if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id }); + if (e.mode === "rule" && e.rule) + for (const p of materialSources(e.rule)) out.push({ from: p.box, to: b.id }); return out; } @@ -584,7 +595,8 @@ export function traceBack(lg: CfLogic, port: CfPort): CfStep[] { lg.links.find((l) => l.to.box === box.id && l.to.name === q.name), ); for (const e of box.input?.elements ?? []) - if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out"); + if (e.mode === "rule" && e.rule && elementPort(e) === p.name) + for (const q of materialSources(e.rule)) visit(q, "out"); }; const box = boxOf(lg, port.box); if (!box) return []; @@ -630,7 +642,11 @@ export function traceForward(lg: CfLogic, port: CfPort): CfStep[] { visit(l.to, "in", l); for (const b of lg.boxes) for (const e of b.input?.elements ?? []) - if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name) + if ( + e.mode === "rule" && + e.rule && + materialSources(e.rule).some((q) => q.box === p.box && q.name === p.name) + ) visit({ box: b.id, name: elementPort(e) }, "out"); }; const box = boxOf(lg, port.box); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index 990e9cba3..df684c2d3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -159,6 +159,9 @@ export function applyRule( return { value: got, why: null, formula }; } +/** 표 블록 범위 경계 규칙 칸(줄마다 「초과·이하」 「이상·이하」 「이상·미만」 「이하」 · Values._table) */ +export const RANGE_RULE = "·범위규칙"; + /** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) * 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */ function lookup( @@ -187,6 +190,12 @@ function lookup( // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : []; const ups: string[] = []; + // 범위 경계 — 줄마다 「·범위규칙」 칸(이상 … = 아래 끝 포함 · … 미만 = 위 끝 뺌) · 없으면 초과 · 이하 + const rc = at(RANGE_RULE); + const lowCmp = (c: string, r: number, x: string): string => + rc ? `IF(LEFT(${rc}${r},2)="이상",${c}${r}<=${x},${c}${r}<${x})` : `${c}${r}<${x}`; + const highCmp = (c: string, r: number, x: string): string => + rc ? `IF(RIGHT(${rc}${r},2)="미만",${x}<${c}${r},${x}<=${c}${r})` : `${x}<=${c}${r}`; let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; @@ -201,7 +210,7 @@ function lookup( 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})))`, + `AND(OR(ISBLANK(${low}${r}),${lowCmp(low, r, ref)}),OR(ISBLANK(${high}${r}),${highCmp(high, r, ref)})))`, ); } else bad = true; }); diff --git a/resources/tester/test_m02_df_convert.py b/resources/tester/test_m02_df_convert.py index fafb7d995..44b6c985f 100644 --- a/resources/tester/test_m02_df_convert.py +++ b/resources/tester/test_m02_df_convert.py @@ -216,9 +216,17 @@ def test_꼴_상자_선_규칙_배치() -> None: 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} = 글 잇는 함수(&) → 재료 조건 찾기 + # 「MO:경유:{지역}」 = 원문번호 글을 잇는 함수(&) → 유가 조건 찾기 + assert any("&" in str(b.get("func")) for b in got["GC000267"]["flow"]["boxes"]) + # 레미콘 {a}-{b}-{c} = 재료 조건 찾기 field 검색어 · 틀 · 자리 셋 · 구분 범위(정함 · 선 없음) remi = got["GC000264"]["flow"] - assert any("&" in str(b.get("func")) for b in remi["boxes"]) + found = [e for b in remi["boxes"] for e in (b.get("input") or {}).get("elements", [])] + word = [e for e in found if (e.get("rule") or {}).get("field") == "검색어"] + assert word and word[0]["rule"]["template"] == "레미콘 {굵은골재최대치수}-{강도}-{슬럼프}" + assert [x["name"] for x in word[0]["rule"]["slots"]] == ["굵은골재최대치수", "강도", "슬럼프"] + assert word[0]["scope"]["구분"] == "레미콘" + groups = {b["id"] for b in remi["boxes"] if (b.get("input") or {}).get("mode") == "group"} + assert not [ln for ln in remi["links"] if ln["to"]["box"] in groups] # 덧줄 줄.'이름' · 비목 합 assert got["GF000185"]["notes"] == [] or all( not n.startswith("못 옮김") for n in got["GF000185"]["notes"] diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index a8d95f9fa..a3047d2dc 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -895,3 +895,23 @@ def test_표_찾기_여럿이면_위_끝_가장_작은_줄() -> None: ) 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 + + +def test_표_찾기_범위_경계_규칙_칸() -> None: + from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp + + head = ["h·아래", "h·위", "Q", "·범위규칙"] + block = [head, [None, 10, 1, "이상·미만"], [10, 20, 2, "이상·미만"], [20, 30, 3, "이상·이하"]] + rule = { + "kind": "lookup", + "conds": [{"col": "h", "from": {"box": "v", "name": "x"}}], + "col": "Q", + } + xs = [5, 10, 20, 30, 31] + got = _run( + RULE + + f"const block = {json.dumps(block, ensure_ascii=False)};\nconst rule = {json.dumps(rule, ensure_ascii=False)};\n" + f"out({json.dumps(xs)}.map(x => {{ const r = R.applyRule(rule, block, {{ x }}); return [r.value, r.why]; }}));" + ) + assert got == [[1, None], [2, None], [3, None], [3, None], [None, "표에 없는 조건"]] + assert [list(cp._lookup(rule, block, {"x": x})) for x in xs] == got From cca0e09cd216d7ebd7d977512130967616a32672 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:33:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(M02-=ED=9D=90=EB=A6=84=EB=8F=84):=20?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=20=EA=B0=92=20=EC=A4=84=EC=9D=80?= =?UTF-8?q?=20=EC=84=A0=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=A7=80=EB=82=98?= =?UTF-8?q?=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- resources/tester/test_m02_df_formula_view.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/resources/tester/test_m02_df_formula_view.py b/resources/tester/test_m02_df_formula_view.py index d015c2a9e..be3d768f0 100644 --- a/resources/tester/test_m02_df_formula_view.py +++ b/resources/tester/test_m02_df_formula_view.py @@ -178,3 +178,18 @@ def test_조건_만들기_식과_다시_줄(): def test_못_바꾸는_식은_null(formula): (back,) = run([{"op": "toDraft", "formula": formula}]) assert back is None + + +def test_받는_칸은_선_규칙을_지난_값으로_그림(): + # 시트에 남은 옛 값(99)이 아니라 규칙을 지나 들어온 값(arrived 0.15)으로 값 식을 그림 + box = func_box( + {"B1": {"값": 99}, "B3": {"식": "B1*(1+B2/100)"}}, + [{"port": "석공량", "cell": "B1"}], + [var("높이", "B2", 4)], + "B3", + "석공 인", + ) + (got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 0.15}}]) + assert got["lines"][0]["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156" + (stale,) = run([{"op": "lines", "box": box, "arrived": {}}]) + assert stale["lines"][0]["valued"].startswith("99 ×") # 들어온 값이 없을 때만 칸 값 From a5edf43d6e4cdecbaf0d20a771bcdc1ca07a7449 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 2 Oct 2026 00:34:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(m01):=20=ED=9D=90=EB=A6=84=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84=20=EB=81=9D=EC=88=98=20=3D=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=83=81=EC=9E=90=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EB=A6=B0=20=EB=A1=9C=EC=A7=81=20=EC=9E=90=EC=8B=A0=EC=9D=98=20?= =?UTF-8?q?=EB=81=9D=EC=88=98=20=C2=B7=20=EC=83=88=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EB=A7=8C=20=EA=B8=B0=EB=B3=B8=201=EC=9B=90=20=EB=AF=B8?= =?UTF-8?q?=EB=A7=8C=20=EB=B2=84=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왼쪽 흐름 보기 소계 · 계와 오른쪽 원가 합계가 같은 끝수를 씀(Round.totalRoundOf) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_FlowView.ts | 17 +++++++--- M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 12 +++++++ M02_MasterTemplete/DataFlow/M02_DF_Summary.ts | 17 +++++++--- resources/tester/test_m01_logic_flowview.py | 33 ++++++++++++++++++- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts index cdf1e7456..6b4acb35c 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -21,7 +21,12 @@ import { type FormulaLine, } from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; -import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; +import { + itemRoundNow, + roundText, + totalRoundNow, + totalRoundOf, +} from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; import { flowRows, type FlowFunc, @@ -42,7 +47,7 @@ const TEXT = { HValue: ["값", "Value"], Sub: ["소계", "Subtotal"], Total: ["계", "Total"], - TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + TotalNote: ["합계 끝수", "Total rounding"], None: ["출력 상자가 없음", "No output box"], NoFunc: ["함수 상자가 없음", "No function box"], } as const; @@ -158,7 +163,8 @@ export function buildFlowView( ); return; } - const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const totalRound = totalRoundOf(lg, totalRoundNow()); + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRound }); const detail = section(tf("Detail"), [ ...(rows.groups.length ? rows.groups.map(groupTable) @@ -168,7 +174,10 @@ export function buildFlowView( attrs: { "data-sum": String(rows.total) }, text: `${tf("Total")} ${rows.totalText}`, }), - el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + el("p", { + className: "m01-logic__muted", + text: `${tf("TotalNote")}: ${roundText(totalRound)}`, + }), ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), ]); /** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5101ad946..f4ddf13d0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -107,3 +107,15 @@ export function showRounded(v: number, r?: CfRound): string { let totalNow: CfRound = { places: 0, method: "버림" }; void defaultTotalRound().then((r) => (totalNow = r)); export const totalRoundNow = (): CfRound => totalNow; + +/** 그 로직의 합계 끝수 — 비용 출력 상자에 끝수가 실려 있으면 그것(옛 로직을 옮긴 흐름은 자신의 끝수) · + * 없으면 기본(새 로직 = 일위대가표 계금 1원 미만 버림) */ +export function totalRoundOf( + lg: { boxes: { kind: string; output?: { cost: string; round?: CfRound } }[] }, + fallback: CfRound, +): CfRound { + const hit = lg.boxes.find( + (b) => b.kind === "output" && b.output?.cost !== "값" && b.output?.round, + ); + return hit?.output?.round ?? fallback; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts index a4686af39..148e15827 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts @@ -12,7 +12,14 @@ import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money" import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model"; import { showValue } from "./M02_DF_OutputPanel"; -import { defaultTotalRound, itemRoundNow, roundBy, roundText, showRounded } from "./M02_DF_Round"; +import { + defaultTotalRound, + itemRoundNow, + roundBy, + roundText, + showRounded, + totalRoundOf, +} from "./M02_DF_Round"; import { renderVerify, type CfSel } from "./M02_DF_Verify"; import "./M02_DF_Summary.css"; @@ -69,18 +76,20 @@ export function renderSummary( ? ts("Total") : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); const { cost } = result; + /** 합계 끝수 — 비용 출력 상자에 실린 끝수(옮긴 로직의 자신 끝수) · 없으면 기본 1원 미만 버림 */ + const sumRound = totalRoundOf(lg, totalRound); const big = el("p", { className: "m02-dfsum__big", children: [ el("span", { text: money ? perText : ts("Total") }), - el("strong", { text: `${formatMoney(roundBy(totalRound, cost.계))} ${ts("Won")}` }), + el("strong", { text: `${formatMoney(roundBy(sumRound, cost.계))} ${ts("Won")}` }), ], }); const costs = el("dl", { className: "m02-dfsum__costs", children: COSTS.flatMap((c) => [ el("dt", { text: ts(COST_KEY[c]) }), - el("dd", { text: formatMoney(roundBy(totalRound, cost[c])) }), + el("dd", { text: formatMoney(roundBy(sumRound, cost[c])) }), ]), }); @@ -161,7 +170,7 @@ export function renderSummary( : []), el("p", { className: "m02-dfsum__muted", - text: `${ts("RoundTotal")}: ${roundText(totalRound)}`, + text: `${ts("RoundTotal")}: ${roundText(sumRound)}`, }), verifyHost, ); diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py index 67a4d35ea..de7d5380e 100644 --- a/resources/tester/test_m01_logic_flowview.py +++ b/resources/tester/test_m01_logic_flowview.py @@ -37,8 +37,9 @@ function load(f) { return box.exports; } const m = load(file); +const r = load(path.join(root, "M02_MasterTemplete", "DataFlow", "M02_DF_Round.ts")); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round)); +const out = jobs.map((job) => job.op === "totalRound" ? r.totalRoundOf(job.lg, job.fallback) : m.flowRows(job.lg, job.ev, job.round)); process.stdout.write(JSON.stringify(out)); setTimeout(() => process.exit(0), 50); """ @@ -182,3 +183,33 @@ def test_missing_value_shows_dash(): rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] assert group(rows, "labor")["lines"][0]["text"] == "—" assert rows["errors"] == ["끊김"] + + +def test_total_round_follows_converted_logic_own_round(): + """옮긴 로직 = 비용 출력 상자에 실린 끝수 · 새 로직(끝수 없음) = 기본.""" + default = {"places": 0, "method": "버림"} + own = {"places": 1, "method": "반올림"} + converted = logic( + [out_box("o1", "인건비", "노무비", round_=own), out_box("o2", "작업량", "값")] + ) + fresh = logic([out_box("o1", "인건비", "노무비")]) + only_plain = logic([out_box("o3", "작업량", "값", round_={"places": 3, "method": "버림"})]) + got = run( + [ + {"op": "totalRound", "lg": converted, "fallback": default}, + {"op": "totalRound", "lg": fresh, "fallback": default}, + {"op": "totalRound", "lg": only_plain, "fallback": default}, + ] + ) + assert got == [own, default, default] # 값(비용 아님)의 끝수는 합계 끝수가 아님 + + +def test_subtotal_and_total_use_converted_round(): + own = {"places": 1, "method": "반올림"} + lg = logic([out_box("o1", "인건비", "노무비", round_=own)]) + ev = result( + {"o1": 3216.7}, {"노무비": 3216.7, "재료비": 0, "경비": 0, "값들": {}, "계": 3216.7} + ) + rows = run([{"lg": lg, "ev": ev, "round": {"item": ROUND["item"], "total": own}}])[0] + assert group(rows, "labor")["subText"] == "3,216.7" + assert rows["totalText"] == "3,216.7"