"""M02 일위대가 맞춤 낱말 · 식 도우미(PLAN 338-1 · 설계 2-2) — 엑셀 지문 · 우리 지문이 같이 씀. 장비 갈래(`mtypes`) · 규격 수(`specnums`) · 조건 낱말(`conds` · `cond_score`) · 식 풀기(`parse_rhs` — 「단가 × 식 = 금액」 → 품 · 맨 앞 단가는 뗌 · 금액 안 씀) · 닮음(`close` · `cosine` · `jacc`). 값 · 문턱은 336 실험(C4 `fp_common`) 그대로. """ from __future__ import annotations import re import unicodedata from typing import Any # ── 장비 갈래(엑셀 글 · 우리 EQ 상세구분 공통) ───────────────────────────── MACH = ( ("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe", "포크레인")), ("덤프트럭", ("덤프",)), ("크레인", ("크레인",)), ("브레이커", ("브레이커", "브레이카")), ("콘크리트펌프", ("펌프차", "펌프카", "콘크리트펌프", "콘크리트 펌프")), ("롤러", ("롤러", "로울러")), ("불도저", ("불도저", "불도우저")), ("집게", ("집게",)), ("살수차", ("살수", "물탱크")), ("모터그레이더", ("그레이더",)), ("래머", ("래머", "램머", "래 머")), ("콤팩터", ("콤팩터", "컴팩터")), ("진동기", ("진동기",)), ("발전기", ("발전기",)), ("공기압축기", ("공기압축기", "컴프레셔", "콤프레샤")), ("드릴", ("드릴", "천공기")), ("커터", ("커터",)), ("화물차", ("화물자동차", "화물차", "카고")), ("로더", ("로더",)), ("지게차", ("지게차",)), ("체인톱", ("체인톱",)), ("트랙터", ("트랙터",)), ("믹서", ("믹서",)), ("분무기", ("분무기",)), ) _SPECNUM = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|m\b|㎾|kw|ℓ|mm|hp)", re.I) def nfkc(text: Any) -> str: return unicodedata.normalize("NFKC", str(text or "")) def mtypes(text: Any) -> set[str]: t = nfkc(text).lower() return {k for k, ws in MACH if any(w in t for w in ws)} def specnums(text: Any) -> set[float]: return {float(a) for a, _ in _SPECNUM.findall(nfkc(text))} # ── 식 풀기 — 「단가 × 식 = 금액」 → 품(단가로 나눈 몫 · 단가는 버림) ───────── _UNITFRAC = re.compile( r"(인|개|본|공|EA|ea|hr|ton|Ton|TON|kg|㎥|㎡|m3|m2|m|M|조|회)\s*/\s*" r"(EA|ea|㎥|㎡|m3|m2|m|M|ton|Ton|TON|t|hr|일|개|본|조|공㎥|㎥)" ) _UNITTAIL = re.compile( r"(?<=[\d.)])\s*(hr|HR|EA|ea|kg|km|ton|Ton|TON|sec|m3|m2|mm|cm|M|m|t)(?![A-Za-z])" ) _NOTE = re.compile(r"\(([^()]*[가-힣][^()]*)\)") _LEAD = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([*/])\s*(.+)$") _SAFE = re.compile(r"^[\d.+\-*/() ]+$") def _norm(s: str) -> str: s = nfkc(s).replace("×", "*").replace("✕", "*").replace("÷", "/") s = s.replace("x", "*").replace("X", "*") s = re.sub(r"(?<=\d),(?=\d{3})", "", s) return _UNITFRAC.sub("", s) def _split(rest: str, first_op: str) -> list[tuple[str, str]]: """맨 바깥 * / 로 쪼갬 → [(연산, 조각)].""" out, depth, cur, op = [], 0, "", first_op for ch in rest: depth += (ch == "(") - (ch == ")") if depth == 0 and ch in "*/": out.append((op, cur)) op, cur = ch, "" continue cur += ch out.append((op, cur)) return out def _factor(text: str) -> tuple[float, bool, str] | None: """조각 → (값, 율 표시, 메모) · 변수(P · Q …)면 None.""" notes = _NOTE.findall(text) t = _NOTE.sub("", text) pct = "%" in t if re.search(r"\d\s*회", t): # 「× 2회」 — 횟수 곱(율로 가름) notes.append("회") t = re.sub(r"[가-힣㎥㎡㎝㎜℃˚°'\"]", "", t).replace("%", "") t = _UNITTAIL.sub("", t).strip() if not t or re.search(r"[A-Za-z]", t) or not _SAFE.match(t): return None try: v = eval(t, {"__builtins__": {}}, {}) # noqa: S307 — 숫자 · 연산 글만(_SAFE) except Exception: # noqa: BLE001 — 0 나눔 · 괄호 짝 틀림 return None return float(v) / (100 if pct else 1), pct or bool(notes), " ".join(notes) def _rates_any(parts: list[str]) -> list[tuple[float, str]]: """모든 조각의 율(메모 · %) — 풀 수 있는 조각만.""" out: list[tuple[float, str]] = [] for seg in parts: m = _LEAD.match(seg) if not m: continue for op, piece in _split(m.group(3), m.group(2)): f = _factor(piece) if f and f[1] and f[0] and all(abs(f[0] - v) > 1e-9 for v, _ in out): out.append((f[0] if op == "*" else 1 / f[0], f[2] or "%")) return out def parse_rhs(rhs: str) -> dict[str, Any] | None: """「단가 × 식 [= 바꿔 쓴 식] = 금액」 → {단가, 품, 밑품, 율, 인자} · 못 읽으면 None.""" parts = _norm(rhs).split("=") if len(parts) < 2: return None every = _rates_any(parts[:-1]) for seg in parts[:-1]: m = _LEAD.match(seg) if not m: continue facs = [] for op, piece in _split(m.group(3), m.group(2)): f = _factor(piece) if f is None: break facs.append((op, *f)) else: if not facs or any(v == 0 and op == "/" for op, v, _, _ in facs): continue full = base = 1.0 rates = [] for op, v, rate, note in facs: full = full * v if op == "*" else full / v if rate: rates.append([round(v if op == "*" else 1 / v, 6), note or "%"]) else: base = base * v if op == "*" else base / v if not rates and every: # 바꿔 쓴 식엔 메모가 빠짐 — 앞 조각의 율로 밑품 for v, _ in every: base /= v or 1 rates = [[round(v, 6), note] for v, note in every] plain = [v if op == "*" else 1 / v for op, v, rate, _ in facs if not rate and v] return {"단가": float(m.group(1)), "품": full, "밑품": base, "율": rates, "인자": plain} return None