From 609dd98fe26bea89dc960d99b805c6a8897290db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 20:15:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(m02-df):=20=EC=9E=90=EC=9E=AC=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=9D=84=EC=96=B4=EC=93=B0=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B3=A0=20=EB=B9=84=EA=B5=90=20=C2=B7=20=EC=84=A0=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=ED=99=94=EB=A9=B4(Rule.ts)=20=3D=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /materials — 품목 글(이름 · 규격 · 키 · 원문번호) · 찾는 낱말 모두 띄어쓰기를 지우고 포함(AND) — 「흄관 300」 3 → 8 · 「흄관」 45 → 117 - 시험 — 이름 속 띄어쓰기 · Rule.ts 를 Node 로 그대로 돌려 서버 apply_rule 과 25가지 맞댐(표 찾기 범위 · 숫자 하나 = 같음 · 여럿 · 없음 · 조건 · 바꾸기 · 끝수) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/DataFlow/M02_DF_Values.py | 13 +-- resources/tester/test_m02_df_router.py | 89 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index cb4cfad15..1e6fa07c3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -298,18 +298,19 @@ def _squash(text: Any) -> str: def _found(sub: str, detail: str, q: str) -> list[dict]: - """범위(구분 · 상세구분) 안 · 띄어쓰기로 나눈 말을 모두(AND) 이름 · 규격 · 키 · 원문번호에서.""" - words = [w.lower() for w in str(q or "").split()] + """범위(구분 · 상세구분) 안 · 띄어쓰기로 나눈 말을 모두(AND) 이름 · 규격 · 키 · 원문번호에서. + + 품목 글 · 찾는 낱말 모두 띄어쓰기를 지우고 비교 — 「흄 관(B형)」 도 「흄관」 으로 걸림. + """ + words = [_squash(w) for w in str(q or "").split()] numbers = _numbers() rows = (store.loaded()[0].get(MATERIALS) or {}).get("줄") or [] out = [] for r in rows: if (sub and r.get("구분") != sub) or (detail and r.get("상세구분") != detail): continue - hay = " ".join( - str(x or "") - for x in (r.get("이름"), r.get("규격"), r.get("키"), numbers.get(r.get("키"))) - ).lower() + fields = (r.get("이름"), r.get("규격"), r.get("키"), numbers.get(r.get("키"))) + hay = "|".join(_squash(x) for x in fields) if all(w in hay for w in words): out.append(r) return out diff --git a/resources/tester/test_m02_df_router.py b/resources/tester/test_m02_df_router.py index 4af744437..96b8a613e 100644 --- a/resources/tester/test_m02_df_router.py +++ b/resources/tester/test_m02_df_router.py @@ -15,6 +15,7 @@ from __future__ import annotations import json import shutil +import subprocess from decimal import Decimal from pathlib import Path @@ -28,6 +29,7 @@ from M01_MasterData import M01_MasterData_Store as store from M02_MasterTemplete.DataFlow import M02_DF_Router as cf from M02_MasterTemplete.DataFlow import M02_DF_Values as dv +ROOT = Path(__file__).resolve().parents[2] REAL = store.FOLDER OWN = "로직_자체.json" SAMPLE = "QF999995" @@ -607,3 +609,90 @@ def test_새_길도_시스템_관리자만(folder: Path) -> None: ): assert getattr(client, method)(url).status_code == 403 # fmt: skip assert client.post("/api/m02/df/logic-value", json={"key": "x"}).status_code == 403 + + +def test_자재_찾기_이름_속_띄어쓰기(client: TestClient, folder: Path) -> None: + """품목 글 · 찾는 낱말 모두 띄어쓰기를 지우고 비교 — 「흄 관(B형)」 도 「흄관」 으로 걸림.""" + path = folder / "재료_자재품목.json" + data = json.loads(path.read_text(encoding="utf-8")) + row = dict.fromkeys(data["줄"][0]) + row.update( + { + "키": "MT999990", + "구분": "시험구분", + "이름": "견 본 관(B형)", + "규격": "Φ 300mm", + "물가자료": 1, + } + ) + data["줄"].append(row) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + for q in ("견본관 300", "견 본관 Φ300", "본관(b형)"): + got = client.get("/api/m02/df/materials", params={"q": q, "sub": "시험구분"}).json() + assert [x["ref"] for x in got["items"]] == ["MT999990"], q + + +RULE_RUNNER = r""" +const fs = require("fs"), path = require("path"); +const [root, jobs] = [process.argv[1], JSON.parse(process.argv[2])]; +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const cache = {}; +function load(file) { + if (cache[file]) return cache[file].exports; + const m = { exports: {} }; + cache[file] = m; + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (spec) => { + if (spec === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; + if (spec.endsWith(".css")) return {}; + const base = spec.startsWith("@ui/") ? path.join(root, "ui_template", spec.slice(4)) + : spec.startsWith("@config/") ? path.join(root, "config", spec.slice(8)) + : spec.startsWith(".") ? path.resolve(path.dirname(file), spec) : null; + if (!base) return require(spec); + const hit = [base + ".ts", base].find((c) => fs.existsSync(c) && fs.statSync(c).isFile()); + if (!hit) throw new Error("못 찾음 " + spec); + return hit.endsWith(".ts") ? load(hit) : require(hit); + }; + new Function("exports", "module", "require", js)(m.exports, m, req); + return m.exports; +} +const rule = load(path.join(root, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts")); +console.log(JSON.stringify(jobs.map((j) => { const r = rule.applyRule(j.rule, j.value, j.conds); return [r.value, r.why]; }))); +""" + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_선_규칙_화면_Rule_ts_와_서버가_같은_값() -> None: + """화면 `M02_DF_Rule.applyRule`(Node 로 그대로) = 서버 `apply_rule` — 값 · 까닭.""" + block = [["돌", "높이·아래", "높이·위", "석공"], ["깬돌", None, 3, 0.5], ["깬돌", 3, 7.5, 0.6], + ["깬돌", 7.5, None, 0.8], ["견치돌", 4, 4, 0.7], ["견치돌", 4, 4, 0.9]] # fmt: skip + look = {"kind": "lookup", "col": "석공", "conds": [{"col": "돌", "from": {"box": "v", "name": "돌"}}, + {"col": "높이", "from": {"box": "v", "name": "높이"}}]} # fmt: skip + gate = {"kind": "gate", "from": {"box": "v", "name": "높이"}, "op": "≥", "value": 3} + jobs = [{"rule": look, "value": block, "conds": {"돌": d, "높이": h}} + for d, h in (("깬돌", 2), ("깬돌", 3), ("깬돌", 3.1), ("깬돌", 7.5), ("깬돌", 100), ("깬돌", -5), + ("견치돌", 4), ("견치돌", 5), ("없는돌", 1))] # fmt: skip + jobs += [{"rule": gate, "value": 10, "conds": {"높이": h}} for h in (2, 3, 4)] + jobs += [{"rule": {"kind": "gate", "from": {"box": "v", "name": "s"}, "op": op, "value": "가"}, + "value": 1, "conds": {"s": s}} for op, s in (("=", "가"), ("≠", "가"), ("=", "나"))] # fmt: skip + jobs += [{"rule": {"kind": "scale", "op": op, "value": 7}, "value": 123.45, "conds": {}} + for op in ("×", "÷", "+%", "-%")] # fmt: skip + jobs += [{"rule": {"kind": "round", "places": p, "method": m}, "value": v, "conds": {}} + for p, m, v in ((0, "반올림", 2.5), (1, "올림", 1.21), (-1, "버림", 1239), (-3, "반올림", 45500), + (2, "버림", -1.239), (0, "반올림", -2.5))] # fmt: skip + result = subprocess.run( + ["node", "-e", RULE_RUNNER, str(ROOT), json.dumps(jobs, ensure_ascii=False)], + capture_output=True, text=True, encoding="utf-8", timeout=120, + ) # fmt: skip + assert result.returncode == 0, result.stderr + screen = json.loads(result.stdout) + server = [cf.dc.apply_rule(j["rule"], j["value"], j["conds"]) for j in jobs] + for job, (sv, sw), (cv, cw) in zip(jobs, screen, server): + same = (sv is None and cv is None) or ( + sv is not None + and cv is not None + and abs(Decimal(str(sv)) - Decimal(str(cv))) < Decimal("1e-9") + ) + assert same and sw == cw, (job, (sv, sw), (cv, cw))