"""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 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"(? 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"(? 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 ""), "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 == "자재": 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 ())]