Files
Aislo/M02_MasterTemplete/M02_Machine_Find.py
T
eomsangdonandClaude Opus 5.5 3d4b2ec1d1 feat(812-9): 가져온 기계 잇기 다시 — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 범위 규격 · 별칭 먼저 · 조합 기계 둘 · 조회어 기종만
- 새 `M02_Machine_Find` — 기종 = 기계표 이름 · 괄호 붙임 · 상세구분(낱말표 24 안 씀) · 소리 표기 한 글(로우더 = 로더 · 로울러 = 롤러 · 브레이카 = 브레이커 · 굴삭기 = 굴착기 · 램 머 = 래머 …) · 기계표 괄호 낱말로 좁힘(표에 없는 낱말 발파 · 리핑 = 안 봄) · 규격 = 수 + 단위 전부(범위 · 복수 · 인치 = ㎝) · 하나 = 이음 · 여럿 = 「기계 후보 n — 고르기」 · 0 = 「기계 못 찾음」 표지(가까운 규격으로 대신 잇지 않음)
- 별칭 먼저 — 마스터 층 + 지금 층 별칭 장부 기계 짝(이름 + 규격 · 이름만 · 규격 속 할증 글 뺌) · `M02_Doc_Match_Note._ALIAS` 여섯 쌍 걷음(소리 표기 = 정규화 · 같은 기종 = 기계표 · 별칭 장부)
- 조합 기계 = 기계마다 항목 하나(요구 738-3 · 제목 = 그 기계 · 줄 쌓기 차림 `M02_Doc_NativeLayout`) · 줄 둘(「+」 · 괄호 속 다른 기종 · 「…조합」 · 덤프트럭(자동덮개) = 덤프트럭 + 자동덮개시설) — 같은 수량 식 · 엑셀 값은 첫 줄 · 다음 줄 0 + 표지 「조합 기계 — 경비는 한 덩이」 · 우리 단가로 = 각자 시간당 경비
- 원파일 EST · STmate · 엑셀 읽기기 · 일위대가 호표(`Hopyo_Elem` · `Hopyo_Fixed._eqs` · `Hopyo_Row.m01_picks`)가 같은 찾기 · STmate 묶음 기계 줄도 시간당 경비 이음
- 문서 머리 `가져온.기계`(기호마다 판정 · 후보 · 찾기어) · 못 이은 기계 줄도 기계 줄(이름 단추 · `owned_of` 파이썬 · TS 짝) · 이름 단추 조회어 = 기종 이름만(규격 뺌) · 고르면 엑셀 값 기억 그대로(금액 안 바뀜) · 문서 [저장] = 고른 EQ → 이 층 별칭 장부 줄 → M01 별칭 장부 후보(승인 흐름 그대로) · 장부 짝 글 = 「기계 EQ…」
- 마스터 층 별칭 장부(resources/master_template/실무자료/맞춤.json) = 사용자 승인 다섯(요구 735-5 · 736) — 아스팔트페이버(피니셔) = 아스팔트 피니셔 · 디젤 엔진 = 엔진(디젤) · 워터제트 = 워터젯트 · 버킷준설기 = 버킷식준설기 · 바이브레이트 봉상후렉시블 = 콘크리트 진동기 엔진식 ø45(2.6㎾)
- 시험 — 새 test_812_9(예 전부 · 정규화 · 괄호 · 범위 · 조합 둘 · 별칭 먼저 · 표지 · 고르면 별칭 줄 · 금액 전후) · test_812_3 전석쌓기 · 측구터파기 연암 항목을 기계마다 항목으로 고침(조합 기계 둘로 가름 · 요구 738-3 이 바꾼 까닭)
- 전수 목록 docs/raw/요구_1104/_목록_812-9_기계이음.xlsx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 21:02:29 +09:00

396 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)