"""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`) · 연결 찾기(`M02_Doc_Match_Note`). 차례: 1. 별칭 장부(`M02_Hopyo_Ledger` · 마스터 층 + 지금 층 · 짝 갈래 기계) — 이름 + 규격 열쇠 = 그 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 pathlib import Path 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 by_code(code: Any) -> str | None: """원문번호 → EQ 키.""" want = str(code or "").strip("'\"") return next((r.key for r in table()[0] if r.raw.get("원문번호") == want), None) 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] # ── 별칭 장부 ───────────────────────────────────────────────────────── def _alias_rows() -> dict[tuple[str, str], list[str]]: """(이름, 규격) 정규화 → EQ 키들 — 마스터 층 + 지금 층 별칭 장부 기계 짝.""" from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store here, top = Path(store.root()), Path(store.FOLDER) folders = [here] + ([top] if here != top else []) files = [(f, hopyo_store._dir(f) / f"{hopyo_store.MEMORY}.json") for f in folders] stamp = tuple((str(f), p.stat().st_mtime_ns if p.is_file() else 0) for f, p in files) return _alias_read(stamp) @lru_cache(maxsize=8) def _alias_read(stamp: tuple) -> dict[tuple[str, str], list[str]]: from M02_MasterTemplete import M02_Hopyo_Ledger as ledger out: dict[tuple[str, str], list[str]] = {} for folder, _ in stamp: for row in ledger.read(folder).values(): pairs = row.get("짝") or [] eqs = [str(p["키"]) for p in pairs if p.get("갈래") == "기계" and p.get("키")] if row.get("갈래") != "짝" or not eqs: continue origin = row.get("원래") or {} out.setdefault((norm(origin.get("품명")), norm(_clean(origin.get("규격")))), eqs) return out def alias(name: Any, spec: Any) -> list[str]: """별칭 장부 기계 짝 — 이름 + 규격 열쇠(규격 속 할증 글은 뺌) · 없으면 [].""" return _alias_rows().get((norm(name), 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 = alias(name, spec) if len(hit) == 1 and hit[0] in rows_by: r = rows_by[hit[0]] return _ans(ALIAS, [r], str(r.raw.get("이름"))) 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 = alias(name, "") if len(named) == 1 and named[0] in rows_by: # 이름만 별칭 = 그 EQ 기종으로 base = rows_by[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 len(named) == 1 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` + {이름, 규격}(조합 = 둘 이상) · 글 통째 별칭(짝 여럿)이 먼저.""" whole = alias(name, spec) rows_by = table()[2] if len(whole) > 1 and all(k in rows_by for k in whole): made = [_ans(ALIAS, [rows_by[k]], str(rows_by[k].raw.get("이름"))) for k in whole] return [{**x, "이름": str(name or ""), "규격": str(spec or "")} for x in made] 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 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 def same_kind(a: Any, b: Any) -> bool | None: """두 기계 이름이 같은 기종인지(연결 찾기) — 둘 다 기종을 찾으면 판정 · 아니면 None.""" found = [] for t in (a, b): named = alias(t, "") row = table()[2].get(named[0]) if len(named) == 1 else None found.append(kind_rows(row.base if row else split_name(t)[0])) if not all(found): return None return bool({r.base for r in found[0]} & {r.base for r in found[1]}) # ── 고른 것 → 별칭 장부(저장 때) ────────────────────────────────────── def learn(doc: Any, who: Any) -> int: """가져온 식 문서 [저장] — 기계 줄에 사람이 고른 EQ(찾기 판정과 다름) = 지금 층 별칭 장부 줄 (갈래 기계 · 열쇠 「이름|규격|단위」) → M01 별칭 장부 후보로 모임(승인은 M01) · 쓴 줄 수.""" from M02_MasterTemplete import M02_Doc_Copy as dc from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_MasterTemplete_Store as store head = (doc or {}).get("가져온") if isinstance(doc, dict) else None if not isinstance(head, dict) or not isinstance(head.get("기계"), list): return 0 by_id = {dc.md.var_id(v): v for v in doc.get("변수") or [] if isinstance(v, dict)} rows: dict[str, dict] = {} for one in head["기계"]: link = ((by_id.get(str((one or {}).get("기호"))) or {}).get("연결")) or {} code = (link.get("입력") or {}).get("기계") if link.get("갈래") == "단가산출" else None eq = by_code(code) origin = one.get("원래") or {} if not eq or eq == one.get("키") or not origin.get("이름"): continue if alias(origin["이름"], origin.get("규격")) == [eq]: # 이미 장부에 같은 짝 continue plain = {"품명": str(origin["이름"]), "규격": str(origin.get("규격") or ""), "단위": str(origin.get("단위") or "hr")} # fmt: skip full = ledger.key({"이름": plain["품명"], "규격": plain["규격"], "단위": plain["단위"]}) pair = {"갈래": "기계", "키": eq, "입력값": {}, "배": 1} rows[full] = {"원래": plain, "갈래": "짝", "짝": [pair]} if rows: ledger.put(store.root(), rows, who) return len(rows)