- 809_1 이름 = 진짜 흠 + 낡은 기대 — 읽기기(812-1)가 지은 율 줄 이름 「율」이 엑셀 글 머리로 안 바뀜 → `Import_Shape._MADE` 에 율 더함 · 합계 = 차림 글줄 · 기계 = 한 줄(이름 · 규격 칸) 은 낡은 기대 고침 · 금액 15,935 · 336 · 723 · 16,994 그대로 - 812_9 별칭 = 낡은 기대 — 815-2 가 아스팔트페이버 별칭을 마스터 기계 줄 칸으로 옮겨 「넣기 전 못 찾음」 이 안 맞음 · 시험은 마스터 칸으로 봄 · 부르는 곳 없는 `M02_Machine_Find.learn`(옛 장부 쓰기) · `by_code` · `same_kind`(걷은 연결 찾기 몫) 지움 - 804_2 hourly = 낡은 기대 — 814-1 이 GC000268 목록을 117 → 209 로 늘려 겹친 116 대 고름이 GC000269 로 뒤집힘(116 대 모두 원 단위 같음) · 같은 로직 줄만 대던 시험 → 같은 갈래 식(부착 · 손료만 · 조종원) 줄 모두 댐 - 진짜 흠(브레인 갈래 가) — GC000272 「조종원 없는 기종」 목록에 기계표 조종원 1 인 9 대가 겹쳐 목록 길이 차례로 고름 → `logic_of` = 기계표 조종원 > 0 이면 조종원 호표 든 식 먼저 · 불러오기 `hourly_link` 도 같은 길 · 바뀐 4 대 = 진동롤러(핸드가이드식) 0.7ton 6,557 → 41,969 · 래머 80kg 1,959 → 23,708 · 플레이트 콤팩터 1.5ton 2,855 → 24,604 · 발전기 100kW 45,535 → 67,284(M01 문서 금액 그대로) · 시험에 9 대 굳힘 - 778_5 기계 표 = 진짜 흠 — 812-9 찾기가 호표 기계 글 「운 반 : 덤프트럭(4.5Ton)」 · 「…[hr] : 90%」 · 「(90%)」 머리 꼬리를 안 뗌 → `Hopyo_Fixed._eqs` 쌍점 조각 · 율 꼬리 뗌 · 이어짐 187 · 고를 것 1 · 못 이음 2(조합 한 줄 · 괄호 깨짐) · 모두 이음 블록 161 - 721 단추 자리 = 낡은 기대(808-1 [단가 갱신][재생성] tools 더함) - 815-1 보고 요약에 「합친 뒤」 열 — 기계 이음 95.7 % · 우리 값 원파일 1,891 · 0 원 줄 블록 147 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
15 KiB
Python
317 lines
15 KiB
Python
"""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
|
||
|
||
|
||
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
|