"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · 우리 돈 로직과 견준 근거 후보. 읽기(`read` · `sources` · `trade_names` · `SHEET` · `GLINES` · `GCHARS`) = EST 어댑터 `M02_Hopyo_ReadEst`(776-2 · 이름은 그대로 다시 냄). 출처 풀이 · 조항 · 기초 없음(`settle` · `resolve` · `ours` · `clause` · `no_basis` …) = 785-1 부터 `M02_Hopyo_Clause` 정본(여기는 다시 냄 · 옛 맞춤 층이 지워질 때 785-7 이 걷음). 견줌(`candidates`) = 같은 책(부문을 알면 같은 구분) 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · 같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이 하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 · 다섯 안에 이름 1순위 하나는 늘 남김(`pick`). 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. M01 · 원본 로직은 읽기만. """ from __future__ import annotations from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 from M02_MasterTemplete.M02_Hopyo_Clause import ( # noqa: F401 — 785-1 정본 · 옛 이름 그대로 OLD, _flat, _grams, _has, _head, _named, _ours, _part, _sections, clause, has_logic, no_basis, ours, resolve, road_map, settle, ) from M02_MasterTemplete.M02_Hopyo_ReadEst import ( # noqa: F401 — 읽기는 EST 어댑터(776-2) · 옛 이름 그대로 GCHARS, GLINES, SHEET, read, sources, trade_names, ) COMMON = "보통인부" BONUS_PARTS = ("공통", "토목") _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 def _text(line: dict[str, Any]) -> str: text = f"{line.get('이름') or ''} {line.get('규격') or ''}" for old, new in _TYPOS: text = text.replace(old, new) return text def _loose_words(text: str) -> frozenset[str]: """괄호 글까지 뜻 낱말 — 방법 낱말 뺌.""" return hopyo_match.words(text.replace("(", " ").replace(")", " ")) - _PLAIN_WORDS def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 근거, _겹침, _낱말}` 점수 차례(`_` 칸은 `merge` 가 쓰고 뺌).""" basis = line.get("근거") or {} theirs = [list(one) for one in basis.get("출처") or []] if not theirs: return [] trades = set(basis.get("직종") or []) - {COMMON} text = _text(line) mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 loose = _loose_words(text) table = ours() scored = [] for one in got["로직"]: our = table.get(one["로직"]) if not our: continue score, clauses, tag = clause(theirs, our) if not score: continue shared = sorted(trades & our["직종"]) if not shared and not any(a in b or b in a for a in mine for b in one["words"]): continue tags = [tag] if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS: score += 0.1 tags.append(f"건설 {our['부문']}") if shared: score += 0.5 tags.append("직종 " + " · ".join(shared)) unit = hopyo_match.fits(line.get("단위"), one["단위"]) if unit: score += 0.15 tags.append("단위 맞음") dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} theirs_loose = _loose_words(one["이름"]) overlap = any(a in b or b in a for a in loose for b in theirs_loose) full = tag != "앞 마디 같음" and unit and (bool(shared) or not trades) # 근거 맞음 why["까닭"] = " · ".join(tags) extra = {"_겹침": dice, "_낱말": overlap or full} scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra})) scored.sort(key=lambda x: (-x[0], -x[1])) return [one for _, _, one in scored] def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례 · 같으면 이름 겹침 큰 쪽 · 이름 · 낱말 안 겹치는 근거 후보는 겹치는 근거 후보 뒤로.""" if not basis: return named by_name = {c.get("로직"): c["점수"] for c in named} out = [] for c in basis: named_too = c["로직"] in by_name score = round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3) out.append({**c, "점수": score, "_낱말": c["_낱말"] or named_too}) out += [c for c in named if c.get("로직") in by_name] out.sort(key=lambda c: (-c["점수"], -c.get("_겹침", 0))) loose = [c for c in out if c.get("_낱말") is False] if loose and any(c.get("_낱말") for c in out): out = [c for c in out if c.get("_낱말") is not False] last = max(at for at, c in enumerate(out) if c.get("_낱말")) out[last + 1 : last + 1] = loose return [{k: v for k, v in c.items() if not k.startswith("_")} for c in out] def pick(ordered: list[dict[str, Any]], first: Any, room: int) -> list[dict[str, Any]]: """후보 `room` 개 — 이름 1순위(`first` 로직 키) 하나는 늘 남기고 나머지는 차례대로.""" if room <= 0: return [] if first is None or all(c.get("로직") != first for c in ordered): return ordered[:room] others = [c for c in ordered if c.get("로직") != first][: room - 1] return [c for c in ordered if c.get("로직") == first or any(c is o for o in others)]