"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독. 과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) · 일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`). 차례: 1. 별칭(M01 기계 줄 「별칭」 칸 · 815-1 `M02_Elem_Alias`) — 「이름 규격」 통째 별칭 = 그 EQ(여럿 = 후보 여럿) · 이름만 별칭 = 그 EQ 기종으로 바꿔 2 로 2. 기종 = EQ 이름(괄호 뗌) · 괄호 붙임 · 상세구분과 같은 글 › 그 글을 품은 EQ(표기 정규화 `norm`) 3. 괄호 낱말 — 기계표 괄호에 있는 낱말(무한궤도 · 타이어 · 자주식 …)만 거름 · 표에 없는 낱말(발파 · 리핑)은 안 봄 4. 규격 = 수 + 단위(범위 · 여럿) 모두 EQ 규격에 같은 값(단위 한쪽 없음 = 봄) › 없으면 EQ 범위 안 하나 = 이음 · 여럿 = 「후보 여럿」 · 0 = 「못 찾음」(가까운 규격으로 대신 잇지 않음) 조합 기계(「+」 · 괄호 속 다른 기종 · 「…조합」 · 「덤프트럭(자동덮개)」) = `parts` 로 갈라 각각 찾음 · 규격 없는 쪽은 앞 쪽 규격. """ from __future__ import annotations import math import re import time import unicodedata from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 # 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로) SOUND = ( ("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"), ("굴삭기", "굴착기"), ("굴삭", "굴착"), ("불도우저", "불도저"), ("램머", "래머"), ("크로울러", "크롤러"), ("콤프레샤", "공기압축기"), ("컴프레셔", "공기압축기"), ("컴프레서", "공기압축기"), ("컴팩터", "콤팩터"), ("타이어형", "타이어"), ("젯트", "제트"), ("후렉시블", "플렉시블"), ("스프레야", "스프레이어"), ("코어", "코아"), ) # fmt: skip _UNIT = ( ("m3/min", "m3/min"), ("m3/분", "m3/min"), ("m3/hr", "m3/hr"), ("m3/h", "m3/hr"), ("km/hr", "km/hr"), ("km/h", "km/hr"), ("l/min", "l/min"), ("t/hr", "t/hr"), ("kg/cm2", "kg/cm2"), ("kg/min", "kg/min"), ("m3", "m3"), ("톤용", "ton"), ("ton", "ton"), ("톤", "ton"), ("t", "ton"), ("kw", "kw"), ("mm", "mm"), ("cm", "cm"), ("kg", "kg"), ("km", "km"), ("m", "m"), ("l", "l"), ("hp", "hp"), ("amp", "a"), ("cc", "cc"), ('"', "in"), ("inch", "in"), ("인치", "in"), ) # fmt: skip _NUM = r"\d+(?:\.\d+)?" _TOKEN = re.compile( rf"({_NUM})(?:\s*([~∼\-])\s*({_NUM}))?\s*(" + "|".join(re.escape(u) for u, _ in _UNIT) + r")?(?![a-z])" ) _PAREN = re.compile(r"[(\[]([^()\[\]]*)[)\]]") _EXTRA = re.compile( r"[:,]?\s*\(?할증율?[^)]*\)?" ) # 「0.2㎥:할증120%」 · 「(할증125%)」 = 율(규격 아님) NOTE = "참고 · " LINKED, ALIAS, MANY, NONE = "이어짐", "별칭", "후보 여럿", "못 찾음" SHOW = 8 # 후보 글 · 목록 상한 def norm(text: Any) -> str: """표기 정규화 — NFKC · 작은 글자 · 빈칸 뺌 · 소리 표기 한 글.""" t = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or "")).lower()) for old, new in SOUND: t = t.replace(old, new) return t def _clean(text: Any) -> str: t = unicodedata.normalize("NFKC", str(text or "")).lower() t = _EXTRA.sub(" ", t) return re.sub(r"(?<=\d),(?=\d{3}(?!\d))", "", t) # 천 자리 쉼표 def tokens(text: Any) -> list[tuple[float, float, str | None]]: """규격 글 → [(아래, 위, 단위)] — 범위 「10~12」 · 여럿 「32m,80~95㎥/hr」 · 단위 없음 = None.""" out = [] for m in _TOKEN.finditer(_clean(text)): lo, sep, hi, unit = float(m.group(1)), m.group(2), m.group(3), m.group(4) top = float(hi) if hi else lo if sep == "-" and top <= lo: # 「50-2.6m」 = 범위 아님 — 앞 수만 top = lo canon = dict(_UNIT).get(unit) if unit else None if canon == "in": # 인치 = ㎝(×2.54 · 우리 표는 ㎝) lo, top, canon = round(lo * 2.54, 6), round(top * 2.54, 6), "cm" out.append((lo, top, canon)) return out def _unit_ok(a: str | None, b: str | None) -> bool: return a is None or b is None or a == b or {a, b} <= {"ton", "t/hr"} def spec_fit(mine: list, theirs: list) -> str | None: """가져온 규격 수가 EQ 규격에 다 있음 = 「같음」 · EQ 범위 안 = 「안」 · 아니면 None.""" if not mine: return "같음" if not theirs else None same = all( any(abs(a - c) < 1e-9 and abs(b - d) < 1e-9 and _unit_ok(u, w) for c, d, w in theirs) for a, b, u in mine ) if same: return "같음" inside = all( any(c - 1e-9 <= a and b <= d + 1e-9 and c < d and _unit_ok(u, w) for c, d, w in theirs) for a, b, u in mine ) return "안" if inside else None def _words(inner: str) -> list[str]: return [norm(w) for w in re.split(r"[\s,;·/]+", inner) if w.strip()] def split_name(text: Any) -> tuple[str, list[list[str]], list[str]]: """이름(+규격) 글 → (기종 글 정규화, 괄호 낱말 묶음들, 규격 조각들) — 괄호 속 수 = 규격.""" raw = _clean(text) groups, specs = [], [] for inner in _PAREN.findall(raw): bits = [b for b in re.split(r"[,;]", inner) if b.strip()] specs += [b for b in bits if re.search(r"\d", b)] groups.append([w for b in bits if not re.search(r"\d", b) for w in _words(b)]) base = _PAREN.sub(" ", raw) specs += [m.group(0) for m in _TOKEN.finditer(base)] base = _TOKEN.sub(" ", base) return norm(base), groups, specs # ── 기계표 ──────────────────────────────────────────────────────────── class Row: __slots__ = ("raw", "key", "base", "detail", "joined", "full", "words", "spec", "said") def __init__(self, r: dict) -> None: name = str(r.get("이름") or "") self.raw, self.key = r, str(r.get("키")) inner = _PAREN.findall(unicodedata.normalize("NFKC", name)) self.words = {w for x in inner for w in _words(x)} self.base = norm(_PAREN.sub("", unicodedata.normalize("NFKC", name))) self.detail = norm(r.get("상세구분")) self.joined = {w + self.base for w in self.words} | {self.base + w for w in self.words} self.full = norm(name) self.spec = tokens(r.get("규격")) self.said = norm( _TOKEN.sub(" ", _clean(r.get("규격"))) ) # 규격 글 낱말(「더블」 · 「엔진식」) @lru_cache(maxsize=2) def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]: rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()] words = frozenset(w for r in rows for w in r.words) return rows, words, {r.key: r for r in rows} _SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번 def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]: now = time.monotonic() if _SEEN[1] is None or now - _SEEN[0] > 1.0: _SEEN[:] = [now, _table(id(m01.loaded()[1]))] return _SEEN[1] def kind_rows(base: str) -> list[Row]: """기종 글 → EQ 줄 — 이름 · 괄호 붙임 같음 › 상세구분 같음 › 그 글을 품음(첫 층만).""" if len(base) < 2: return [] rows = table()[0] for test in ( lambda r: base == r.base or base in r.joined, lambda r: base == r.detail, lambda r: base in r.full or base in r.detail, ): got = [r for r in rows if test(r)] if got: return got return [] def _kind_word(word: str, base: str) -> str | None: """괄호 낱말이 다른 기종(조합)이면 그 기종 글 — 「부착용집게」 · 「브레이커조합」 · 「자동덮개」.""" w = word.removesuffix("조합") if len(w) < 2 or w == base: return None rows = table()[0] if any(w in (r.base, r.detail) for r in rows): return w if any(r.base.startswith(base + w) for r in rows): return base + w return None # ── 조합 기계 ───────────────────────────────────────────────────────── def parts(name: Any, spec: Any = "") -> list[tuple[str, str]]: """기계 글 → [(이름, 규격)] — 「+」 · 괄호 속 다른 기종이면 둘 이상 · 규격 없는 쪽 = 앞 쪽 규격 · 하나면 받은 그대로.""" text = f"{name or ''} {spec or ''}".strip() known = table()[1] out: list[tuple[str, str]] = [] for piece in [p for p in text.split("+") if p.strip()] or [text]: main, base, extra = _clean(piece), split_name(piece)[0], [] for m in _PAREN.finditer(_clean(piece)): bits = [b for b in re.split(r"[,;]", m.group(1)) if b.strip()] words = [w for b in bits if not re.search(r"\d", b) for w in _words(b)] kind = next((k for w in words if w not in known and (k := _kind_word(w, base))), None) if kind: main = main.replace(m.group(0), " ") extra.append((kind, ",".join(b for b in bits if re.search(r"\d", b)))) bare = _TOKEN.sub( " ", _PAREN.sub(lambda m: " " if re.search(r"\d", m.group(1)) else m.group(0), main) ) own = " ".join(split_name(main)[2]) out += [(" ".join(bare.split()).strip(" ,"), own), *extra] if len(out) < 2: return [(str(name or ""), str(spec or ""))] first = next((s for _, s in out if s), "") return [(n, s or first) for n, s in out] # ── 별칭(M01 기계 줄 「별칭」 칸 · 815-1) ─────────────────────────────────── def alias(name: Any, spec: Any) -> list[str]: """M01 기계 줄 별칭(`M02_Elem_Alias`) — 규격이 있으면 「이름 규격」 통째 별칭(규격 속 할증 글은 뺌) · 규격 빔 = 이름 별칭 · 없으면 [].""" from M02_MasterTemplete import M02_Elem_Alias as ea rows = ea.book()["별칭"].get("EQ", {}) return list(rows.get(ea.am.norm(name) + ea.am.norm(_clean(spec)), [])) # ── 찾기 ────────────────────────────────────────────────────────────── def _brief(r: Row) -> dict[str, Any]: raw = r.raw return {"키": r.key, "이름": raw.get("이름"), "규격": raw.get("규격") or "", "원문번호": raw.get("원문번호")} # fmt: skip def _query(rows: list[Row], base: str) -> str: names = {str(r.raw.get("이름")) for r in rows} if len(names) == 1: return names.pop() details = {str(r.raw.get("상세구분")) for r in rows} return details.pop() if len(details) == 1 else base def _ans(kind: str, rows: list[Row], query: str, why: str = "") -> dict[str, Any]: key = rows[0].key if kind in (LINKED, ALIAS) else None return {"판정": kind, "키": key, "후보": [_brief(r) for r in rows[:SHOW]], "찾기어": query, "까닭": why, "수": len(rows)} # fmt: skip def find(name: Any, spec: Any = "") -> dict[str, Any]: """기계 하나(조합 아님) → `{판정, 키, 후보, 찾기어, 까닭, 수}`.""" rows_by = table()[2] hit = [rows_by[k] for k in alias(name, spec) if k in rows_by] if hit: # 별칭 하나 = 그 EQ · 여럿 = 후보 여럿 if len(hit) == 1: return _ans(ALIAS, hit, str(hit[0].raw.get("이름"))) return _ans(MANY, hit, _query(hit, hit[0].base), f"후보 {len(hit)}") base = split_name(name)[0] _, groups, specs = split_name(f"{name or ''} {spec or ''}") # 규격 괄호 낱말 「3.6m(일반용)」 groups.append( _words(_TOKEN.sub(" ", _PAREN.sub(" ", _clean(spec)))) ) # 규격 글 낱말 「탑승유압식」 named = [rows_by[k] for k in alias(name, "") if k in rows_by] if named and len({r.base for r in named}) == 1: # 이름만 별칭 = 그 EQ 기종으로 base = named[0].base mine = tokens(" ".join(specs)) kinds = kind_rows(base) known = table()[1] rows = kinds for w in (w for g in groups for w in g): if w in known: # 기계표 괄호 낱말 = 꼭 맞아야 함 rows = [r for r in rows if w in r.full] elif len(w) > 1 and (kept := [r for r in rows if w in r.said]): # 규격 글 낱말 = 좁힐 때만 rows = kept if not mine: # 규격 수 없음 = 그 기종 전부(괄호 · 규격 낱말로 거른 것) got = rows else: same = [r for r in rows if spec_fit(mine, r.spec) == "같음"] got = same or [r for r in rows if spec_fit(mine, r.spec) == "안"] if len(got) == 1: # 이름만 별칭으로 기종을 정했으면 「별칭」 return _ans(ALIAS if named else LINKED, got, str(got[0].raw.get("이름"))) if got: return _ans(MANY, got, _query(got, base), f"후보 {len(got)}") if not kinds: return _ans(NONE, [], base, "기종 모름 — 별칭") why = "우리 표에 없음(규격)" if rows else "우리 표에 없음(괄호 낱말)" return _ans( NONE, sorted(rows or kinds, key=lambda r: _gap(mine, r.spec)), _query(kinds, base), why ) def _gap(mine: list, theirs: list) -> float: """가까운 규격 차례(보여주기만) — 첫 수끼리 비.""" if not mine or not theirs or mine[0][0] <= 0 or theirs[0][0] <= 0: return math.inf return abs(math.log(mine[0][0] / theirs[0][0])) def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]: """기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상).""" out: list[dict[str, Any]] = [] for n, s in parts(name, spec): if out and s and not split_name(n)[2] and s == out[0]["규격"] and out[0]["키"]: s = str(out[0]["후보"][0]["규격"]) # 앞 줄 규격을 받은 쪽 = 이은 우리 규격(단위까지) out.append({**find(n, s), "이름": n, "규격": s}) return out # ── 같은 파일 안 구성 대조로 가름(816-2 · 요구 773-3) ─────────────────────────── MADE = "같은 파일 구성 같음" def peers(machines: dict[Any, tuple[str, str, dict]]) -> dict[Any, list[list[str]]]: """같은 파일 기계 {코드: (이름, 규격, 구성 {손료 [[계수, 가격]], 주연료, 조종원})} → {코드: 구성이 같은 단독 기계(손료 줄 하나) [이름, 규격] 들} — 낱 기계 = 구성 통째 같음 · 조합 = 손료 줄마다 그 줄 하나뿐인 단독 기계. 같은 파일 · 같은 때의 값끼리(우리 표 값과 안 맞댐).""" alone = {c: m for c, m in machines.items() if len(m[2].get("손료") or []) == 1} out: dict[Any, list[list[str]]] = {} for c, (_, _, make) in machines.items(): loss = make.get("손료") or [] if len(loss) == 1: got = [o for o, m in alone.items() if o != c and m[2] == make] else: got = [o for o, m in alone.items() if o != c and m[2]["손료"][0] in loss] if got: out[c] = [[alone[o][0], alone[o][1]] for o in got] return out def narrow(parts: list[dict[str, Any]], same: list | None) -> list[dict[str, Any]]: """「후보 여럿」 부분 → 같은 파일에서 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아(`find`) 그 후보 가운데 키 하나로 모이면 그 줄 · 여럿 · 없음 · 대조 없음 = 그대로(우리 값으로 넘겨짚지 않음).""" if not same: return parts found = [(n, s, find(n, s)) for n, s in same] out = [] for p in parts: if p["판정"] == MANY: mine = {c["키"] for c in p["후보"]} keys = { g["키"] for _, _, g in found if g["판정"] in (LINKED, ALIAS) and g["키"] in mine } if len(keys) == 1: key = keys.pop() n, s = next((n, s) for n, s, g in found if g["키"] == key) p = {**p, **_ans(LINKED, [table()[2][key]], n, f"{MADE} — {n} {s}".strip())} out.append(p) return out def mark(got: dict[str, Any], name: Any, spec: Any) -> str | None: """못 이은 기계 표지 글(「참고 · 」 머리) — 이음 · 별칭 = None.""" said = f"「{' '.join(x for x in (str(name or ''), str(spec or '')) if x)}」" if got["판정"] == MANY: names = " · ".join(f"{c['이름']} {c['규격']}".strip() for c in got["후보"]) return f"{NOTE}기계 후보 {got['수']} — 고르기 {said}: {names}" if got["판정"] == NONE: return f"{NOTE}기계 못 찾음 — {said} {got['까닭']}" return None