Files
Aislo/M02_MasterTemplete/M02_Machine_Find.py
T
eomsangdonandClaude Opus 5.5 6015525efd feat(842-2): 기계 비활성 — M01 기계 목록 「사용」 스위치 · 끈 기계는 찾기 · 고르기 · 잇기에서 뺌 · 해상 기계 79 대 끔
- 표지 = 기계 줄 맨 위 칸 `비활성: true`(없음 = 켜짐 · 값 묶음 밖) · 틀 검사 = 기계 줄에만 true(check_master OFF)
- M01 기계 목록 = 끝 열 「사용」 스위치(초안 → 페이지 [저장] · 자동저장 없음) · 끈 줄 흐림 · 머리 「비활성 같이 보기」(끄면 서버 rows state 「켜짐」)
- 뺌 = 요소 찾기 창(elements · 찾아 넣기 · 단가산출 기계 창) · logic-lib rows · M01 시험 계산 기계 고르기 표 · 내 기계 「비슷한 기계에서 시작」 ·
  불러오기 기계 잇기(M02_Machine_Find 표) · 기계경비식 「기계」 옵션(새 GET /api/m02/df/machine-off · 고른 값은 남기고 「· 비활성 기계」 꼬리)
- 셈 길은 그대로 — 끈 기계를 쓰던 단가산출 · 사본 값 같음
- 자료 = 해상장비 79 대(펌프 준설선 13 · 그래브 준설선 10 · 하천골재채취선 7 · 기중기선 5 · 예선 9 · 양묘선 11 · 토운선 6 · 이우선 2 · 대선 16) 끔
- 시험 = test_842_2_machine_off 새로 · test_804_10 옵션 글 단언을 새 글로 고침(끈 기계 거름이 들어간 줄)

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

379 lines
18 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) + 지금 층 내 기계(821-1 · `M02_MyMachine.whole`) · 부르는 곳 = 불러오기 이음(`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 collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Any
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
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=4)
def _table(whole: Any) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in whole.numbers.get("EQ", {}).values() if not r.get("비활성")] # 842-2
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, None] # (때, 표, 층) — M01 · 내 기계 판 확인은 층마다 1 초에 한 번
_M01_ONLY: ContextVar[bool] = ContextVar("m01_only", default=False)
@contextmanager
def m01_only() -> Iterator[None]:
"""이 안의 찾기 = M01 기계만(821-4 불러오기 — 내 기계 잇기는 `M02_San_MyMachine.attach` 한 곳)."""
token = _M01_ONLY.set(True)
try:
yield
finally:
_M01_ONLY.reset(token)
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
if _M01_ONLY.get():
return _table(m01.loaded()[1])
now, here = time.monotonic(), store.root()
if _SEEN[1] is None or now - _SEEN[0] > 1.0 or _SEEN[2] != here:
_SEEN[:] = [now, _table(mym.whole()), here]
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