"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · 우리 돈 로직과 견준 근거 후보. 읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 한 블록 · 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · 글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열). 견줌(`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 import re from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs SHEET = "단가산출" COMMON = "보통인부" BONUS_PARTS = ("공통", "토목") _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 GLINES, GCHARS, WIDE = 200, 300, 140 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} def _flat(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")) # ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── def _cell_text(value: Any) -> str: if isinstance(value, float) and value == int(value): value = int(value) return str(value).strip() if value is not None else "" @lru_cache(maxsize=2) def _trade_names(whole_id: int) -> dict[str, str]: """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" out: dict[str, str] = {} for row in m01.loaded()[1].numbers.get("LB", {}).values(): name = str(row.get("이름") or "") out[_flat(name)] = name for old in row.get("옛이름") or []: out.setdefault(_flat(old), name) return out def trade_names() -> dict[str, str]: return _trade_names(id(m01.loaded()[1])) def sources(text: str) -> list[list[str]]: """글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」.""" out = [] for hit in _BOOK.finditer(text): num = labor_inputs._NUMBER.search(text, hit.end()) if not num or num.start() - hit.end() > 20: continue before = text[max(0, hit.start() - 12) : hit.start()] book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈" typo = _TYPO_CLAUSE.match(num.group()) number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group() if [book, number] not in out: out.append([book, number]) return out def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) · 돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽).""" for at, row in enumerate(rows[:10]): flat = [_flat(v) for v in row] if "산출근거" in flat: last = len(flat) - 1 money = { key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat } return at + 1, flat.index("산출근거"), money return 0, 1, {} def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}` (행 = 엑셀 1부터).""" if not rows: return {} start, col, money = _columns(rows) end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE names = trade_names() blocks: dict[int, dict[str, Any]] = {} block: dict[str, Any] | None = None for at, row in enumerate(rows[start:], start + 1): text = row[col] if col < len(row) else None head = None if isinstance(text, str) and text.strip(): head = None if text[:1].isspace() else _HEAD.match(text) else: text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v)) if not text: continue if head: block = blocks.setdefault( int(head.group(1)), { "번": int(head.group(1)), "이름": head.group(2), "단위": head.group(3).strip(), "행": [at, at], "출처": [], "직종": [], "글": [], "금액": { key: v for key, c in money.items() for v in [row[c] if c < len(row) else None] if isinstance(v, (int, float)) and not isinstance(v, bool) }, }, ) continue if block is None: continue block["행"][1] = at if len(block["글"]) < GLINES: block["글"].append(text.rstrip()[:GCHARS]) block["출처"] += [one for one in sources(text) if one not in block["출처"]] trade = _TRADE.match(text) name = names.get(_flat(trade.group(1))) if trade else None if name and name not in block["직종"]: block["직종"].append(name) return blocks # ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── @lru_cache(maxsize=2) def _ours(whole_id: int) -> dict[str, dict[str, Any]]: """{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 · 직종 = 호표 인력(LB) 요소.""" files, whole = m01.loaded() lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()} out = {} for data in files.values(): if data.get("그룹") != "로직": continue for row in data.get("줄") or []: kind = str(row.get("구분") or "") book, _, part = kind.partition(" ") trades = { lb[str(h.get("요소"))] for h in row.get("호표") or [] if isinstance(h, dict) and str(h.get("요소")) in lb } clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or ""))) out[str(row.get("키"))] = { "책": book, "부문": part, "구분": kind, "조항": clauses, "직종": trades, } return out def ours() -> dict[str, dict[str, Any]]: return _ours(id(m01.loaded()[1])) def _head(number: str) -> str: return number.rsplit("-", 1)[0] def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: """엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만 · 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5.""" nums = [n for book, n in theirs if book == our["책"]] same = sorted(n for n in nums if n in our["조항"]) if same: return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")}) if kids: return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음" heads = {_head(n) for n in nums} near = sorted(n for n in our["조항"] if _head(n) in heads) if near: return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음" return 0.0, [], "" 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)]