- 새 M02_Hopyo_MatSame — ㉮ 이름 같은 품목(빈칸 · 괄호 차례 · 등급 · 점 · 소리 표기 넷) + 규격 수 묶음 같음(차례 · 단위 배 · 한쪽 듦) + 규격 낱말 안 부딪힘 + 우리 품목 하나 → 이음 · ㉯ 이름 같은 묶음 = 규격 어근 품은 줄 · 덧말 적은 줄 1 순위 · 규격 수 같은 후보 먼저 - M02_Hopyo_Row 재료 길 = 바탕 이름 유일 짝 뒤 ㉮ › 옛 후보 하나 › ㉯ + 옛 후보 - M02_San_Link — 별도계상 줄 = 요소 잇기 없음 + 표지 「별도계상 · 품목은 프로젝트에서」 · 못 이은 재료 까닭에 1 순위 이름 · 규격 - 전수(원파일 2,220 · 엑셀 1,021) 재료 이음 88.0 → 93.5 % · 새 이음 112 줄 51 종 · 끊긴 이음 0 · 금액 그대로 · 치즐 0.7 이 엉뚱한 0.2 줄에서 0.7 줄로 옮김 - 시험 test_817_1_material_find(과녁 100 줄 · 틀린 이음 막기 · 별도계상 표지) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""M02 불러오기 재료 표기 다름 찾기(PLAN 817-1 · 요구 773-5) — `M02_Hopyo_Row._m01_picks` 재료 길이 부름.
|
||
|
||
서버 단독 · 읽기만 · 별칭 · 정확 일치 · 바탕 이름 유일 짝(`M02_Hopyo_Material.sure`)이 못 찾은 줄만.
|
||
- `same(줄)` ㉮ 이음 = 이름이 같은 품목(표기만 다름) · 규격 수 묶음 같음 · 규격 낱말 안 부딪힘 · 그런 우리
|
||
품목 하나(지역 · 관급/사급 줄은 한 품목) → 그 자재 줄 · 아니면 None.
|
||
- `narrow(줄)` ㉯ 후보 좁힘 = 이름 같은 품목 묶음을 규격 낱말(어근 「초본류」 → 초본)을 품은 줄 · 수 묶음
|
||
닮은 줄 · 덧말 적은 줄 차례로(자동 이음 아님 · 1 순위 맨 위).
|
||
- `spec_first(후보, 줄)` = 후보 가운데 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저.
|
||
|
||
이름 같음: 바탕(괄호 · 지역 · 관급/사급 꼬리 · 등급 「1급」 뗌 · 빈칸 · 점 「P.V.C」 · 소리 표기 `SPELL`) 같음 ·
|
||
괄호 차례 「패널(유로폼)」 = 「유로폼패널」(글자 묶음 같음) · 괄호 이름 「서비스볼트(고장력볼트)」 = 고장력볼트 ·
|
||
(약한 같음 — 수 묶음이 같을 때만) 끝 낱말 같음 「지수판」 ↔ 「PVC지수판」 · 앞 낱말 「구역화물자동차」 ↔ 구역화물 ·
|
||
규격에 든 이름 「안고초」 ↔ 산림종자 안고초(가져온 규격 빔).
|
||
수 묶음: 규격 + 이름 괄호의 수(차례 무관) · KS 번호 · 무게(kg · ㎏/포 · m/kg) · 제곱 꼬리(㎡ · ㎠) 뗌 ·
|
||
천 단위 쉼표 · 같음 > 낱마다 단위 배(1 · 10 · 100 · 1000 — 3.6×3.0×3.0 m = 30×30×3600 mm) > 한쪽이 다른 쪽에 듦
|
||
(이름 같을 때만 · 덧글 「2000 길이 · 20~30mm」).
|
||
규격 낱말 부딪힘 = 두 쪽 다 괄호 · 규격 한글 낱말이 있고 두 글자 묶음이 하나도 안 겹침(「연강용」 ↔ 「주철용」) —
|
||
상대 이름에 든 낱말은 뺌.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import unicodedata
|
||
from functools import lru_cache
|
||
from itertools import permutations
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||
|
||
SPELL = (
|
||
(r"칠렌", "틸렌"),
|
||
(r"매쉬", "메쉬"),
|
||
(r"신나(?![가-힣])", "신너"),
|
||
(r"pipe", "파이프"),
|
||
) # 소리 · 영문 표기
|
||
NOISE = frozenset({"이내", "이하", "이상", "국산", "포함"})
|
||
SCALES = (1, 10, 100, 1000)
|
||
_STEM = re.compile(r"(?<=..)[류형용]$")
|
||
_KS = re.compile(r"ks\s*[a-z]?\s*-?\s*\d+")
|
||
_SQUARE = re.compile(r"(?:c|m)?m[23](?![\d.])")
|
||
_THOUSAND = re.compile(r"(?<=\d),(?=\d{3}(?!\d))")
|
||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||
_PAREN = re.compile(r"\(([^()]*)\)")
|
||
|
||
|
||
def _low(text: Any) -> str:
|
||
t = unicodedata.normalize("NFKC", str(text or "")).lower().replace("m/m", "mm")
|
||
return hmat._APART.sub("", t)
|
||
|
||
|
||
def _tidy(text: str) -> str:
|
||
t = re.sub(r"(?<=[a-z])[.\s]+(?=[a-z])", "", text)
|
||
for a, b in SPELL:
|
||
t = re.sub(a, b, t)
|
||
t = re.sub(r"\d+급", "", t)
|
||
return re.sub(r"[^가-힣a-z0-9]", "", t)
|
||
|
||
|
||
def _bag(text: str) -> tuple[float, ...]:
|
||
t = _THOUSAND.sub("", _SQUARE.sub("", _KS.sub("", text)))
|
||
return tuple(sorted(float(x) for x in _NUM.findall(t)))
|
||
|
||
|
||
def _quals(text: str) -> frozenset[str]:
|
||
words = (w for w in re.findall(r"[가-힣]{2,}", text) if not hmat._region(w))
|
||
return frozenset(w for w in words if w not in hmat.SUPPLY and w not in NOISE)
|
||
|
||
|
||
def _item(name: Any, spec: Any) -> dict[str, Any]:
|
||
"""이름 · 규격 → 바탕 · 괄호 조각 · 수 묶음 · 규격 낱말 · 온 이름 글."""
|
||
low, spec = _low(name), _low(spec)
|
||
parens = _PAREN.findall(low)
|
||
pieces = tuple(p for p in (_tidy(x) for x in parens for x in x.split(",")) if p)
|
||
core = _tidy(hmat._bare(low))
|
||
return {
|
||
"core": core,
|
||
"pieces": pieces,
|
||
"chars": "".join(sorted(core + "".join(pieces))),
|
||
"bag": _bag(f"{spec} {' '.join(parens)}"),
|
||
"quals": _quals(f"{' '.join(pieces)} {spec}"),
|
||
"whole": core + "".join(pieces),
|
||
"spec": _tidy(spec),
|
||
}
|
||
|
||
|
||
@lru_cache(maxsize=2)
|
||
def _book(_got: int) -> dict[str, Any]:
|
||
rows, _ = hmat.pool()
|
||
out: list[dict[str, Any]] = []
|
||
by: dict[str, dict[str, list[int]]] = {
|
||
"core": {},
|
||
"chars": {},
|
||
"piece": {},
|
||
"tail": {},
|
||
"spec": {},
|
||
}
|
||
for at, r in enumerate(rows):
|
||
one = {**_item(r["one"]["이름"], r["one"]["규격"]), "row": r}
|
||
out.append(one)
|
||
c = one["core"]
|
||
by["core"].setdefault(c, []).append(at)
|
||
by["chars"].setdefault(one["chars"], []).append(at)
|
||
by["spec"].setdefault(one["spec"], []).append(at)
|
||
for p in one["pieces"]:
|
||
by["piece"].setdefault(p, []).append(at)
|
||
for i in range(1, len(c) - 1): # 끝 낱말(두 글자 이상)
|
||
by["tail"].setdefault(c[i:], []).append(at)
|
||
return {"줄": out, "찾기": by}
|
||
|
||
|
||
def book() -> dict[str, Any]:
|
||
return _book(id(hm.targets()))
|
||
|
||
|
||
def _related(me: dict[str, Any]) -> dict[int, bool]:
|
||
"""이름 같은 우리 줄 → {차례: 굳셈(True) · 약함(False)}."""
|
||
by, c = book()["찾기"], me["core"]
|
||
strong = [*by["core"].get(c, []), *by["chars"].get(me["chars"], []), *by["piece"].get(c, [])]
|
||
if (
|
||
c not in by["core"]
|
||
): # 괄호 이름(「서비스볼트(고장력볼트)」) — 바탕 이름이 우리 표에 없을 때만
|
||
strong += [i for p in me["pieces"] for i in by["core"].get(p, [])]
|
||
weak = (
|
||
[*by["tail"].get(c, [])] if len(c) >= 2 else []
|
||
) # 우리 이름 끝 = 가져온 이름(「PVC지수판」)
|
||
for k in range(
|
||
2, len(c) - 1
|
||
): # 우리 이름 = 가져온 이름 앞(「구역화물자동차」) · 뒤 덧말이 우리 낱말 아님
|
||
if c[k:] not in by["core"] and c[k:] not in by["tail"]:
|
||
weak += by["core"].get(c[:k], [])
|
||
out = dict.fromkeys(weak, False)
|
||
out.update(dict.fromkeys(strong, True))
|
||
return out
|
||
|
||
|
||
def _bigrams(words: frozenset[str]) -> set[str]:
|
||
return {w[i : i + 2] for w in words for i in range(len(w) - 1)}
|
||
|
||
|
||
def _qual_fit(me: dict[str, Any], x: dict[str, Any]) -> tuple[bool, bool, bool]:
|
||
"""(부딪힘, 겹침, 낱말 있음) — 상대 온 이름에 든 낱말은 뺌."""
|
||
a = frozenset(w for w in me["quals"] if _tidy(w) not in x["whole"])
|
||
b = frozenset(w for w in x["quals"] if _tidy(w) not in me["whole"])
|
||
share = bool(_bigrams(a) & _bigrams(b))
|
||
return bool(a and b and not share), share, bool(a or b)
|
||
|
||
|
||
def _near(x: float, y: float) -> bool:
|
||
return any(abs(x * s - y) <= 1e-6 * max(abs(y), 1) or abs(y * s - x) <= 1e-6 * max(abs(x), 1) for s in SCALES) # fmt: skip
|
||
|
||
|
||
def num_fit(a: tuple[float, ...], b: tuple[float, ...]) -> tuple[int, int]:
|
||
"""(갈래 3 같음 · 2 단위 배 · 1 한쪽이 듦 · 0 다름, 맞은 수 개수)."""
|
||
if a == b:
|
||
return 3, len(a)
|
||
if a and len(a) == len(b) and len(a) <= 6:
|
||
if any(all(_near(x, y) for x, y in zip(a, p)) for p in permutations(b)):
|
||
return 2, len(a)
|
||
small, big = sorted((list(a), list(b)), key=len)
|
||
if small and len(small) < len(big):
|
||
rest = list(big)
|
||
for x in small:
|
||
if x not in rest:
|
||
return 0, 0
|
||
rest.remove(x)
|
||
return 1, len(small)
|
||
return 0, 0
|
||
|
||
|
||
def _fold(x: dict[str, Any]) -> tuple:
|
||
r = x["row"]
|
||
return r["base"], r["key"][0], r["key"][2]
|
||
|
||
|
||
def _scored(line: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[tuple, dict[str, Any]]]]:
|
||
me = _item(line.get("이름"), line.get("규격"))
|
||
rows = book()["줄"]
|
||
out = []
|
||
for at, strong in _related(me).items():
|
||
x = rows[at]
|
||
clash, share, words = _qual_fit(me, x)
|
||
if not clash:
|
||
out.append(
|
||
((strong, share, *num_fit(me["bag"], x["bag"]), words, x["core"] == me["core"]), x)
|
||
)
|
||
return me, out
|
||
|
||
|
||
def _pick(group: list[dict[str, Any]], line: dict[str, Any]) -> dict[str, Any]:
|
||
"""한 품목의 변형 줄들 → 하나(줄 관급/사급 › 줄 지역 › 서울 › 같은 단위 › 엑셀 차례 — `M02_Elem_Alias.one` 과 같은 차례)."""
|
||
supply, regions = hmat._wants(line)
|
||
unit = hm.mm.plain_unit(line.get("단위"))
|
||
|
||
def rank(r: dict[str, Any]) -> tuple:
|
||
return (
|
||
supply is not None and r["supply"] not in (None, supply),
|
||
bool(regions) and not hmat._hit(r, regions),
|
||
"서울" not in r["region"],
|
||
r["key"][1] != unit,
|
||
r["at"],
|
||
)
|
||
|
||
return min((x["row"] for x in group), key=rank)["one"]
|
||
|
||
|
||
def same(line: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""㉮ 표기만 다름 — 우리 품목 하나면 그 자재 줄(`hm.targets` 꼴) · 아니면 None."""
|
||
me, scored = _scored(line)
|
||
if not me["spec"]: # 가져온 규격 빔 = 규격에 든 이름(「안고초」)만 · 그 밖은 ㉯
|
||
hits = [book()["줄"][i] for i in book()["찾기"]["spec"].get(me["whole"], [])]
|
||
hits = [x for x in hits if not x["bag"] and not me["bag"]]
|
||
return _pick(hits, line) if hits and len({_fold(x) for x in hits}) == 1 else None
|
||
ok = []
|
||
for (strong, share, tier, common, words, same_core), x in scored:
|
||
if not tier or (tier == 1 and not strong):
|
||
continue
|
||
if not me["bag"] and not x["bag"] and not share and words:
|
||
continue # 수 없는 규격 = 규격 낱말이 겹쳐야
|
||
ok.append(((tier, common, share, strong, same_core), x))
|
||
if not ok:
|
||
return None
|
||
best = max(k for k, _ in ok)
|
||
group = [x for k, x in ok if k == best]
|
||
return _pick(group, line) if len({_fold(x) for x in group}) == 1 else None
|
||
|
||
|
||
def _stems(me: dict[str, Any]) -> set[str]:
|
||
return {_STEM.sub("", w) for w in me["quals"]}
|
||
|
||
|
||
def narrow(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
|
||
"""㉯ 이름 같은 품목 묶음(굳센 같음) → 규격 낱말 어근 품은 줄 › 수 묶음 닮음 › 덧말 적음 차례(품목마다 하나)."""
|
||
me, scored = _scored(line)
|
||
stems = _stems(me)
|
||
rows = [(k, x) for k, x in scored if k[0]]
|
||
rank = []
|
||
for (_strong, _share, tier, common, _words, same_core), x in rows:
|
||
text = x["whole"] + x["spec"]
|
||
hit = sum(s in text for s in stems)
|
||
rank.append(((-hit, -tier, -common, not same_core, len(x["spec"]), x["row"]["at"]), x))
|
||
seen, out = set(), []
|
||
for _k, x in sorted(rank, key=lambda t: t[0]):
|
||
if (f := _fold(x)) not in seen:
|
||
seen.add(f)
|
||
out.append(_pick([y for _, y in rank if _fold(y) == f], line))
|
||
return out[:limit]
|
||
|
||
|
||
def spec_first(cands: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""후보 차례 — 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저(「중하중그레이팅 395×995×52」)."""
|
||
mine = _item(line.get("이름"), line.get("규격"))["bag"]
|
||
if len(mine) < 2:
|
||
return cands
|
||
return sorted(cands, key=lambda c: _item(c.get("이름"), c.get("규격"))["bag"] != mine)
|