"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름. 줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm. 바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘). 규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가 자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) · 줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함. 맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐 (지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 먼저 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급). 지역(361-1) = 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는 변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`national` · 바탕 이름(전국)-관급/사급 · 규격 · 단위 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 자동 고름 없음 · 지역 변형은 고르기 창에서 사용자가. 그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료). 가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른 자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관. """ from __future__ import annotations import re import unicodedata from functools import lru_cache from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hm mm = hm.mm APART = "별도계상" SUPPLY = ("관급", "사급") NO_REGION = ("전국", "전지역") REGIONS = ( *"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(), *"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(), *"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(), *"여주 이천 가평 연천".split(), ) SKIP_WORDS = frozenset({"조합공통품목"}) NATIONAL = "전국" NATIONAL_TAG = "전국 — 지역은 프로젝트에서" REGION_TAG = "지역 — 줄 글" _APART = re.compile(r"\(?\s*별도\s*계상\s*\)?") _NUM = re.compile(r"\d+(?:\.\d+)?") _RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)") _TAIL = re.compile(r"[\s,\-]*([가-힣]+)$") SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001) def _nfkc(text: Any) -> str: return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm") @lru_cache(maxsize=1) def _places() -> frozenset[str]: """곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」).""" names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘(")) words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))} return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY}) def _region(word: str) -> bool: return word.startswith(REGIONS) or word in NO_REGION or word in _places() def _paren_words(name: str) -> set[str]: return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)} def _bare(name: Any) -> str: """괄호 글 · 끝 지역 · 관급/사급 꼬리 뗀 글(정규화 전 · 「레미콘(서울)-사급」 → 레미콘).""" text = _nfkc(name) while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text: text = cut while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))): if m.start() == 0: break text = text[: m.start()] for w in SUPPLY: text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text return text.strip() def base(name: Any) -> str: """바탕 이름 — `_bare` 정규화.""" return mm.plain(_bare(name)) def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]: """(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시.""" out, apart = dict(line), line.get("갈래") == APART for k in ("이름", "규격"): text = str(line.get(k) or "") if _APART.search(text): apart, text = True, _APART.sub("", text).strip(" ,") out[k] = text return out, apart def _nums(text: str) -> list[float]: return [float(x) for x in _NUM.findall(text)] def _ranges(text: str) -> list[tuple[float, bool, float, bool]]: return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)] def _words(text: str) -> frozenset[str]: return ( frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY) - SKIP_WORDS ) @lru_cache(maxsize=2) def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: """자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]}).""" rows, index = [], {} for at, one in enumerate(hm.targets()["자재"]): name, spec = _nfkc(one["이름"]), _nfkc(one["규격"]) words = set(re.findall(r"[가-힣]+", name)) row = { "one": one, "at": at, "base": base(name), "nums": _nums(spec), "ranges": _ranges(spec), "spec": mm.plain_spec(spec), "region": sorted(w for w in words if _region(w) and w not in NO_REGION), "supply": next((w for w in SUPPLY if w in name), None), "key": ( tuple(sorted(_nums(spec))), mm.plain_unit(one["단위"]), _words(spec) | _words(" ".join(_paren_words(name))), ), } row["grams"] = hm._grams(row["base"]) rows.append(row) index.setdefault(row["base"], []).append(row) return rows, index def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: return _pool(id(hm.targets())) def _near(x: float, y: float) -> bool: return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES) def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float: """규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫.""" text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}") if mm.plain_spec(line.get("규격")) == row["spec"]: return 1.0 mine = _nums(text) if row["ranges"]: inside = any( (a < x or (lo and a == x)) and (x < b or (hi and x == b)) for x in mine for a, lo, b, hi in row["ranges"] ) return 1.0 if inside else 0.0 if not mine or not row["nums"]: return 0.0 return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine) def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]: """(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급.""" text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}") words = set(re.findall(r"[가-힣]+", text)) supply = next((w for w in SUPPLY if w in text), None) if supply is None: supply = ( "관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None ) return supply, [w for w in words if _region(w) and w not in NO_REGION] def _hit(r: dict[str, Any], regions: list[str]) -> bool: return any(w[:2] == x[:2] for w in regions for x in r["region"]) def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: """변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례.""" supply, regions = _wants(line) def rank(r: dict[str, Any]) -> tuple: hit = _hit(r, regions) return ( supply is not None and r["supply"] not in (None, supply), bool(regions) and not hit, bool(r["region"]), bool(r["region"]) or not any(w in r["one"]["이름"] for w in NO_REGION), # (전국) 먼저 r["at"], ) return sorted(rows, key=rank) def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool: return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"])) def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: """맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) · 아니면 ([], []).""" _, index = pool() rows = [ r for r in index.get(base(line.get("이름")), []) if hm.fits(line.get("단위"), r["one"]["단위"]) and _paren_ok(line, r) ] full = [r for r in rows if spec_fit(line, r) == 1.0] unit = mm.plain_unit(line.get("단위")) full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면) if not full or len({r["key"] for r in full}) > 1: return [], [] full = _prefer(full, line) supply, regions = _wants(line) if any(_hit(r, regions) for r in full): return full, [REGION_TAG] if len(full) == 1 or any(not r["region"] for r in full): return full, [] return [_national(full[0], supply), *full], [NATIONAL_TAG] def _national(row: dict[str, Any], supply: str | None) -> dict[str, Any]: """「전국」 자리표(361-1) — 지역 변형만 있는 품목 · 키 · 단가 없음 · 이름(전국)-관급/사급.""" one = row["one"] name = f"{_bare(one['이름'])}({NATIONAL})" + (f"-{supply}" if supply else "") spec = _nfkc(one["규격"]) for w in SKIP_WORDS: # 「조합공통품목, 25-21-120」(관급) → 25-21-120 spec = re.sub(rf"{w}[\s,]*", "", spec) return {"one": {"전국": True, "이름": name, "규격": spec.strip(" ,"), "단위": one["단위"]}} def national(line: dict[str, Any]) -> dict[str, Any] | None: """자재 · 별도계상 줄(다듬기 전)의 「전국」 자리표 — 없으면 None(만들기 · 미리 셈 재검사).""" if line.get("갈래") not in hm.MATERIAL_KINDS: return None rows, _ = sure(clean(line)[0]) return next((r["one"] for r in rows if r["one"].get("전국")), None) def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: """자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 · 뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수).""" rows, _ = pool() me = base(line.get("이름")) grams, mine = hm._grams(me), hm.words(line.get("이름")) parens = _paren_words(_nfkc(line.get("이름"))) scored: dict[tuple, list] = {} for r in rows: if not grams & r["grams"]: continue fit = spec_fit(line, r) if not hm.near(mine, r["one"]["words"]) and fit < 0.5: continue score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"])) score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"]) score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0) fold = (r["base"], *r["key"]) have = scored.setdefault(fold, [score, []]) have[0] = max(have[0], score) have[1].append(r) best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit] out = [] for score, group in best: first = _prefer(group, line)[0] one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"} out.append( {**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one ) return out def marks(line: dict[str, Any], name: str = "") -> dict[str, str]: """가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` → 줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본).""" _, apart = clean(line) out = {"별도계상": APART if apart else "직접계산"} supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0] if supply: out["자재구분"] = supply text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}")) if "운반비미포함" in text or "운반비별도" in text: out["운반비"] = "미포함" return out