Files
Aislo/M02_MasterTemplete/M02_Hopyo_Match.py
T
eomsangdonandClaude Opus 5.5 dd9ebad8d1 feat(830-10): 불러오기 재료 찾기를 지역 · 상하차 칸에 맞춤 — 가져온 줄 글은 빌더 규칙으로 판독
- 새 M02_Mat_Place: 가져온 이름 · 규격 → (뺀 이름, 뺀 규격, 지역, 상하차)(material_merge 판독 한 벌) · 고르는 차례 rank
- 고르는 차례 = 상하차 맞음(글 없으면 -) › 값 있는 줄 › 줄 지역 › 전국 · 지역 모름 › 서울 › 경기 › 그 밖(값 가운데에 가까운 줄)
- Elem_Alias(_same · one · region_of) · Hopyo_Match(목록에 지역 · 상하차 · 값 · 이름 같음 = 뺀 이름 + 지역 · 상하차 같음) · Hopyo_Material(_places · 변형 지역 = 지역 칸 · _prefer) · MatSame._pick · MatFind 후보 글 = 지역 칸
- 다시 고르기 · 찾기 창 · 줄 풍선말 후보 글에 지역 · 상하차(M01 placeNote 한 벌)
- 시험: 814_1 · m02_hopyo_match 기대를 지역 칸 꼴로 · 새 지킴 test_830_10_material_region 21
- 불러오기 지킴 812_1 · 814_1 · 815_1 · 817_1 · 827_2 · 827_3 · 828_1 · 828_2 · 778_5 ×2 · 793_2 ×2 · 797_1 ×2 · m02_hopyo_match 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:57:23 +09:00

300 lines
13 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 324-2 · 338 · 785-7) — 엑셀 구성 줄 · 우리 로직 · 자재 이름 정리 · 목록.
이름 정리(`tidy` · `words` · `near`) · 규격 수(`numbers` · `spec_inputs`) · 환산(`factor` = `M02_Hopyo_Row.factor`) ·
우리 목록(`targets` — 돈 로직 · 자재품목 · 씨앗) · 이름 같음(`_exact`) · B09 줄 열쇠(`line_key`).
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
M01 은 읽기만(로직 별칭 안 씀). 옛 맞춤 흐름(후보 순위 · 근거 차례 · 보기 · 미리 셈)은 785-7 에서 지움.
"""
from __future__ import annotations
import json
import re
import unicodedata
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Mat_Place as place
mf = m01.cm.mf
mp = mf.mp
mm = mp.mm
SEED_NAME = "일위대가조합.json"
LOGIC_KINDS = ("단가산출", "기계", "기타")
MATERIAL_KINDS = ("자재", "별도계상")
MACH_WORDS = (
("불도우저", "불도저"),
("굴삭기", "굴착기"),
("브레이카", "브레이커"),
("로울러", "롤러"),
("크로울러", "크롤러"),
)
_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$")
_PAREN = re.compile(r"\([^()]*\)")
_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)")
_NAMED = re.compile(r"(?<![A-Za-z])([A-Za-z]\d?)\s*[=:]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?", re.I)
# 이름 붙은 수 → 입력 이름 낱말(긴 낱말이 앞섬 · 「뒷길이」 = L3)
LETTERS = {
"D": ("관경", "직경", "구경", "지름"),
"H": ("높이",),
"B": ("폭", "너비"),
"T": ("두께",),
"L": ("거리", "길이"),
"L3": ("뒷길이",),
}
# ── 정규화 ───────────────────────────────────────────────────────────
def line_key(line: dict[str, Any]) -> str:
"""기억 열쇠 = 품명 | 규격 | 단위(정규화)."""
return "|".join(
(
mm.plain(line.get("이름")),
mm.plain_spec(line.get("규격")),
mm.plain_unit(line.get("단위")),
)
)
def tidy(name: Any) -> str:
"""정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸."""
text = str(name or "")
for old, new in MACH_WORDS:
text = text.replace(old, new)
return mm.plain(_PAREN.sub("", text))
def words(name: Any) -> frozenset[str]:
"""뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것
(낱글자 덩어리는 붙임 「면 목」 = 면목)."""
text = unicodedata.normalize("NFKC", str(name or "")).lower()
for old, new in MACH_WORDS:
text = text.replace(old, new)
out, single = [], ""
for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)):
if len(piece) == 1:
single += piece
continue
out += [single, piece]
single = ""
return frozenset(w for w in [*out, single] if len(w) > 1)
def near(mine: frozenset[str], theirs: frozenset[str]) -> bool:
"""뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음."""
return not mine or any(a in b or b in a for a in mine for b in theirs)
def _spec_text(text: Any) -> str:
out = str(text or "")
for old, new in MACH_WORDS:
out = out.replace(old, new)
return mm.plain_spec(out).replace(",", "").replace("~", "∼")
def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]:
"""규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임."""
text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or "")))
return {
letter.upper(): (Decimal(value), unit.lower() if unit else None)
for letter, value, unit in _NAMED.findall(text)
}
def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal:
"""길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로."""
scale = mp.UNITS["길이"]
want = mm.plain_unit(want) if want else None
if have in scale and want in scale:
return value * Decimal(scale[have]) / Decimal(scale[want])
return value
def _json(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None:
if found is None:
return None
return _in_unit(found[0], found[1] or "m", "m")
# ── 규격 → 입력값 ─────────────────────────────────────────────────────
def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]:
"""(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위."""
raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}"
pool = _spec_text(raw)
named = numbers(line.get("규격"))
tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)]
unit = mm.plain_unit(line.get("단위"))
values, unread = {}, []
for spec in specs:
name = str(spec.get("이름") or "")
picks = spec.get("고르기") or []
labels = spec.get("고르기이름") or {}
words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name]
letter = max(words, key=lambda x: len(x[0]))[1] if words else None
got: Any = None
if letter == "L" and "거리" in name and named.get("L", (0, None))[1] in ("cm", "mm"):
letter = None # 「L=35cm」 = 부재 길이 — 운반 · 거리(m) 칸 아님
if letter in named:
value = _json(_in_unit(*named[letter], spec.get("단위")))
got = value if not picks or value in picks else None
if got is None and picks:
hits = []
for opt in picks:
label = str(labels.get(opt) or opt)
for text in (label, _PAREN.sub("", label)):
flat = _spec_text(text)
if (
_NUM.fullmatch(flat) and len(flat) > 1
): # 수 고르기 = 제 단위가 붙은(또는 맨) 수만
hits += (
[(len(flat), opt)]
if any(_num_in(flat, w, spec) for w in tokens)
else []
)
elif len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")):
hits.append((len(flat), opt))
# 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」)
hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)]
got = max(hits, key=lambda x: x[0])[1] if hits else None
if got is not None:
values[name] = got
elif spec.get("자동값") in (None, ""):
unread.append(name)
return values, unread
_NUM = re.compile(r"\d+(?:\.\d+)?")
def _num_in(flat: str, pool: str, spec: dict[str, Any]) -> bool:
"""규격 글에 수 `flat` 이 따로 서 있고 뒤에 다른 단위가 안 붙음(「35cm」 의 35 ≠ 경사 35°)."""
own = _spec_text(spec.get("단위"))
for m in re.finditer(rf"(?<![\d.]){re.escape(flat)}(?![\d.])", pool):
after = re.match(r"[a-z㎜㎝㎞㎡㎥°%]+", pool[m.end() :])
if not after or (own and after.group().startswith(own)):
return True
return False
# ── 환산 ─────────────────────────────────────────────────────────────
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
"""`{배, 갈래}` — `M02_Hopyo_Row.factor` 로 옮김(785-3 · 옛 길이 부름)."""
from M02_MasterTemplete import M02_Hopyo_Row as hopyo_row # Row 가 Match 를 부름 — 늦게 읽음
return hopyo_row.factor(have, want, spec)
def fits(have: Any, want: Any) -> bool:
"""단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤)."""
units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()}
return mm.plain_unit(have) in units or mp.same_kind(have, want)
# ── 우리 쪽 목록(한 번 읽어 둠) ─────────────────────────────────────────
def _grams(text: str) -> set[str]:
return {text[i : i + 2] for i in range(len(text) - 1)} or {text}
@lru_cache(maxsize=2)
def _targets(whole_id: int) -> dict[str, Any]:
"""로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위."""
files, whole = m01.loaded()
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []]
pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"]
unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만
logics, mats = [], []
for row in rows:
if str(row.get("키")) in unit_of:
name = str(row.get("이름") or "")
logics.append(
{
"로직": str(row["키"]),
"이름": name,
"단위": unit_of[str(row["키"])],
"산림": "산림" in str(row.get("구분") or ""),
"plain": mm.plain(name),
"tidy": tidy(name),
}
)
for key, row in whole.index.get(mm.MARKET, {}).items():
name = str(row.get("이름") or "")
mats.append(
{
"자재": str(key),
"이름": name,
"규격": str(row.get("규격") or ""),
"단위": str(row.get("단위") or ""),
"지역": str(row.get("지역") or place.NONE), # 830-10 지역 · 상하차 칸
"상하차": str(row.get("상하차") or place.NONE),
"값": place.value(row),
"plain": mm.plain(name),
"spec": mm.plain_spec(row.get("규격")),
"base": (base := re.sub(r"-(사급|관급)$", "", name.strip())),
"tidy": tidy(base),
}
)
names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}}
for pool, group in (("로직", logics), ("자재", mats)):
for one in group:
one["grams"] = _grams(one["tidy"])
one["words"] = words(one["base"] if "base" in one else one["이름"])
names[pool].setdefault(one["plain"], []).append(one)
return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()}
def targets() -> dict[str, Any]:
return _targets(id(m01.loaded()[1]))
def _seeds() -> dict[str, set[str]]:
"""씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만)."""
try:
doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes())
except (OSError, ValueError):
return {}
out: dict[str, set[str]] = {}
for row in doc.get("줄") or []:
for one in row.get("담은로직") or []:
memo = _SEED_TAIL.sub("", str(one.get("메모") or ""))
if memo and one.get("로직"):
out.setdefault(mm.plain(memo), set()).add(str(one["로직"]))
return out
def _public(one: dict[str, Any]) -> dict[str, Any]:
keep = ("로직", "자재", "전국", "이름", "규격", "단위", "지역", "상하차")
return {k: one[k] for k in keep if k in one}
def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]:
"""② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림)."""
name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격"))
same = got["이름"][pool].get(name) or []
if (
pool == "자재"
): # 830-10 — 가져온 이름의 지역 · 인도 조건 글 = 우리 지역 · 상하차 칸(옛 온 이름 같음과 같은 뜻)
want = place.line(line.get("이름"), line.get("규격"))
same = [
m
for m in got["이름"]["자재"].get(mm.plain(want["이름"])) or []
if (m["지역"], m["상하차"]) == (want["지역"], want["상하차"])
]
spec = mm.plain_spec(want["규격"])
return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else [])
if same:
return same
codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get(
name
)
return [x for x in got["로직"] if x["로직"] in (codes or ())]