"""M02 일위대가 실무자료 단가산출 출처 풀이 · 조항 색인(PLAN 785-1 · 요구 642 · 설계 642-A ④-1) — 엑셀 「단가산출」 블록 품셈 번호 → M01 로직 후보(색인 · 순위 아님). 출처 풀이(`settle` · 781-1 · 옛 `M02_Hopyo_Basis` 에서 옮김) = 건설품셈(부문 모름) 번호 → 뜻 낱말로 부문 · 임도품셈(옛) → 대응표 현행 산림 번호 · 기초 없음 표지(`no_basis` · 781-2) = 출처 조항에 우리 로직 0 · 옛 번호. 조항 색인(`index`) = M01 로직 {(구분, 원문번호): [키]}. 블록 근거(`basis`) = 머리 줄(첫 절 앞) 출처 › 머리에 품셈 글이 없으면 몸 글 출처(절 제목 뺌) · 절 제목 출처(「4. 기초지정[2025년 표준품셈3-4-8]」)는 그 절 후보 · 색인: 같음 › 윗마디(엑셀 6-2 ⊃ 6-2-2 · 6-2-3) · 부문 모르는 건설품셈 = 건설 부문 모두 · 옛 책 = 없음 · 윗마디 후보 여럿 = 일 절(`Formula.works`) 제목 ↔ 후보 이름 뜻 낱말 많이 겹친 것을 그 절에(안 맞는 후보 뺌) · 결과단위 거름 = 후보 결과단위 성격(면적 · 체적 · 길이 · 무게 · 식) ≠ 블록 단위 성격이면 뺌 · 표지 = 번호 없음(머리 · 몸에 번호 없음) › 준용(머리 글 「준용」 · 후보 참고만) › 옛 번호 · 기초 없음(`no_basis`). 자동 확정 없음 · M01 · 원본 로직은 읽기만. """ from __future__ import annotations import csv import re from functools import lru_cache from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Hopyo_Formula as formula from M02_MasterTemplete import M02_Hopyo_ReadEst as est from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs def _flat(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")) # ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── @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 in (our["책"], 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, [], "" # ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ─────────── OLD = "(옛)" _ROAD = "임도품셈" + OLD _ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv" _OLD_NO = re.compile(r"^(\d+(?:-\d+)+)(\s+[가-하])?$") _STOP_GRAMS = frozenset("설치 해체 작업 인력 기계 장비 보완 신설 공사".split()) @lru_cache(maxsize=1) def road_map() -> dict[str, str]: """임도품셈(옛) 번호 → 현행 산림 번호 — 지식DB 대응표 「확정」 한 대응만(일대다 · 미확인 뺌) · 옛 번호가 가 · 나 항이면 현행 윗마디(9-13-7 → 9-13 · 그 항만 대응하므로).""" with open(_ROAD_MAP, encoding="utf-8-sig", newline="") as f: rows = list(csv.DictReader(f)) out = {} for row in rows: old, new = _OLD_NO.match(row["과거_절"].strip()), row["현행_절"].strip() if row["판정"] != "확정" or not old or not _OLD_NO.fullmatch(new) or " " in new: continue out[old.group(1)] = new.rsplit("-", 1)[0] if old.group(2) and new.count("-") > 1 else new return out def _grams(text: Any) -> set[str]: """뜻 낱말 — 낱말 안 한글 두 글자 묶음(괄호 글 포함 · 굴삭 = 굴착).""" words = re.findall(r"[가-힣]+", str(text or "").replace("굴삭", "굴착")) return {w[i : i + 2] for w in words for i in range(len(w) - 1)} - _STOP_GRAMS @lru_cache(maxsize=2) def _sections(book: str) -> dict[tuple[str, str], str]: """{(부문, 절 번호): 제목(괄호 글 · 「:」 뒤 뺌)} — 원문 본문.""" return { (s["division"], s["section"]): re.sub(r"\(.*?\)", "", s["title"]).split(":")[0].strip() for s in make._all_sections(book) } @lru_cache(maxsize=2) def _named(whole_id: int) -> dict[tuple[str, str], list[str]]: """{(구분, 원문번호): [로직 이름]} — M01 로직.""" out: dict[tuple[str, str], list[str]] = {} for data in m01.loaded()[0].values(): if data.get("그룹") == "로직": for row in data.get("줄") or []: key = (str(row.get("구분") or ""), str(row.get("원문번호") or "").split(" ")[0]) out.setdefault(key, []).append(str(row.get("이름") or "")) return out def _part(number: str, name: Any, text: Any = "") -> str: """건설품셈(부문 모름) 번호 → 원문에 그 번호가 있는 부문 중 블록 이름(안 맞으면 블록 글 · 두 묶음)이 원문 제목 · 같은 번호 로직 이름과 뜻 낱말을 나누는 부문이 하나 = 그 부문 · 둘 이상 = 모름 · 없음 = 「(옛)」(번호 뜻 안 맞음).""" if not _grams(name): return "건설품셈" named = _named(id(m01.loaded()[1])) texts = { part: [title, *named.get((f"건설품셈 {part}", no), [])] for (part, no), title in _sections("건설품셈").items() if no == number } hits = [] for mine, least in ((_grams(name), 1), (_grams(text), 2)): # 글은 두 묶음 이상 hits = [p for p, ts in texts.items() if any(len(mine & _grams(t)) >= least for t in ts)] if hits: break if len(hits) == 1: return f"건설품셈 {hits[0]}" return "건설품셈" if hits else "건설품셈" + OLD def resolve(theirs: list[list[str]], name: Any, text: Any = "") -> list[list[str]]: """블록 출처 → 부문 풀이 · 임도 옛 번호 대응(`road_map`) 뒤 출처.""" out: list[list[str]] = [] for book, number in theirs: one = [book, number] if book == _ROAD and number in road_map(): one = ["산림품셈", road_map()[number]] elif book == "건설품셈": one = [_part(number, name, text), number] if one not in out: out.append(one) return out def settle(book: dict[str, Any]) -> dict[str, Any]: """Book 단산 블록 출처를 제자리에서 풀이(줄 `근거.출처` 가 같은 리스트를 가리킴).""" for block in book.get("단산") or []: if block.get("출처"): text = " ".join(str(t) for t in block.get("글") or []) block["출처"][:] = resolve(block["출처"], block.get("이름"), text) return book def has_logic(kind: str, number: str) -> bool: """그 구분 · 조항(윗마디 · 아랫마디 포함)에 M01 로직이 있나.""" return _has(id(m01.loaded()[1]), kind, number) @lru_cache(maxsize=512) def _has(whole_id: int, kind: str, number: str) -> bool: named = _named(whole_id) parts = number.split("-") ups = {"-".join(parts[:i]) for i in range(2, len(parts) + 1)} return any(k == kind and (no in ups or no.startswith(number + "-")) for k, no in named) def no_basis(theirs: list | None) -> dict[str, str] | None: """출처 조항이 모두 우리 기초 밖이면 표지 `{판정, 글, 이름}` — 기초 없음(로직 0) › 옛 번호 (「(옛)」 · 지금 원문에 없는 번호) · 로직 있는 조항 · 건설품셈(부문 모름)이 하나라도 있으면 None.""" none, old = [], [] for book, number in theirs or []: base, _, part = book.partition(" ") if book == "건설품셈": return None if book.endswith(OLD) or base not in make.BOOKS: old.append((book.removesuffix(OLD), number)) elif has_logic(book, number): return None elif (title := _sections(base).get((part, number))) is None: old.append((book, number)) else: none.append((book, number, title)) if none: book, number, title = none[0] where = f"{book.replace('품셈', '')} {number} {title}" return { "판정": "기초 없음", "글": f"근거 조항 우리 기초에 없음 — {book} {number} {title}", "이름": f"기초 없음 — {where}", } if old: return { "판정": "옛 번호", "글": f"옛 번호 — 대응 미확인 — {old[0][0]} {old[0][1]}", "이름": "옛 번호 — 근거 못 씀", } return None # ── 조항 색인 · 블록 근거(785-1) ────────────────────────────────────────── _NATURE = { **dict.fromkeys(("㎡", "m2", "ha"), "면적"), **dict.fromkeys(("㎥", "m3", "공㎥"), "체적"), **dict.fromkeys(("m", "km"), "길이"), **dict.fromkeys(("ton", "t", "톤", "kg", "㎏"), "무게"), "식": "식", } def nature(unit: Any) -> str | None: """단위 → 성격(면적 · 체적 · 길이 · 무게 · 식) · 모름 = None · 「원/10㎡」 = ㎡ · 여러 단위 = 모름.""" text = str(unit or "").strip().removeprefix("원/") if any(c in text for c in "·(또"): return None return _NATURE.get(re.sub(r"^[\d,]+", "", text).strip().lower()) @lru_cache(maxsize=2) def _index(whole_id: int) -> dict[tuple[str, str], list[dict[str, Any]]]: out: dict[tuple[str, str], list[dict[str, Any]]] = {} for data in m01.loaded()[0].values(): if data.get("그룹") != "로직": continue for row in data.get("줄") or []: key = (str(row.get("구분") or ""), str(row.get("원문번호") or "").split(" ")[0]) one = {"키": str(row.get("키")), "이름": str(row.get("이름") or "")} one |= {"구분": key[0], "원문번호": key[1], "결과단위": row.get("결과단위")} out.setdefault(key, []).append(one) return out def index() -> dict[tuple[str, str], list[str]]: """{(구분, 원문번호): [로직 키]} — M01 로직.""" return {k: [o["키"] for o in v] for k, v in _index(id(m01.loaded()[1])).items()} def lookup(book: str, number: str) -> list[dict[str, Any]]: """[책, 번호] → 로직 줄 + 맞음 같음 · 윗마디 — 같은 것이 있으면 같음만.""" table = _index(id(m01.loaded()[1])) if book == "건설품셈": kinds = {k for k, _ in table if k.startswith("건설품셈 ")} elif book.endswith(OLD): return [] else: kinds = {book} same = [o for k in kinds for o in table.get((k, number), [])] if same: return [{**o, "맞음": "같음", "출처": [book, number]} for o in same] return [ {**o, "맞음": "윗마디", "출처": [book, number]} for (k, no), rows in table.items() if k in kinds and no.startswith(number + "-") for o in rows ] def _paths(nodes: list[dict[str, Any]], up: str = "") -> list[tuple[str, dict[str, Any]]]: out = [] for one in nodes: path = f"{up}.{one['번']}" if up else one["번"] out += [(path, one), *_paths(one["아래"], path)] return out def _sources(lines: list[str], name: Any, whole: str) -> list[list[str]]: raw: list[list[str]] = [] for line in lines: raw += [one for one in est.sources(line) if one not in raw] return resolve(raw, name, whole) def _split(found: list[dict[str, Any]], nodes: list[dict[str, Any]]) -> tuple[list, list]: """윗마디 후보 여럿 → 일 절 제목과 뜻 낱말 가장 많이 겹친 후보를 그 절에 · 남은 후보는 뺌.""" works = {id(w) for w in formula.works(nodes)} picked: dict[str, str] = {} for path, one in _paths(nodes): if id(one) not in works: continue mine = _grams(_flat(one["제목"])) score = {o["키"]: len(mine & _grams(_flat(o["이름"]))) for o in found} best = max(score.values(), default=0) for key, n in score.items(): if best and n == best: picked.setdefault(key, path) if not picked: return found, [] keep = [{**o, "절": picked[o["키"]]} for o in found if o["키"] in picked] drop = [{"키": o["키"], "까닭": "절 이름 안 맞음"} for o in found if o["키"] not in picked] return keep, drop def basis(block: dict[str, Any]) -> dict[str, Any]: """Book 단산 블록 → {표지, 글, 출처, 후보 [{키, 이름, 구분, 원문번호, 결과단위, 맞음, 출처, 절}], 뺌 [{키, 까닭}]}.""" text = [str(t) for t in block.get("글") or []] name, whole = block.get("이름"), " ".join(text) nodes = block.get("절") or formula.tree(text, block.get("글행")) top = [] for line in text: if formula._title(line): break top.append(line) head = " ".join(top) used = _sources(top, name, whole) if not used and not re.search(r"품[셈줌]", head): used = _sources([t for t in text if not formula._title(t)], name, whole) found = [{**o, "절": None} for book, no in used for o in lookup(book, no)] out: dict[str, Any] = {"표지": None, "글": "", "출처": list(used), "후보": [], "뺌": []} if len(found) > 1 and any(o["맞음"] == "윗마디" for o in found): found, out["뺌"] = _split(found, nodes) for path, one in _paths(nodes): for book, no in _sources([one["제목"]], one["제목"], whole): out["출처"] += [[book, no]] if [book, no] not in out["출처"] else [] found += [{**o, "절": path} for o in lookup(book, no)] if not out["출처"]: out["표지"], out["글"] = "번호 없음", "품셈 번호 없음 — 이름 후보로" elif "준용" in head: out["표지"], out["글"] = "준용", "준용 — 후보 참고만" elif mark := no_basis(out["출처"]): out["표지"], out["글"] = mark["판정"], mark["글"] mine = nature(block.get("단위")) for o in found: theirs = nature(o["결과단위"]) if any(o["키"] == c["키"] for c in out["후보"]): continue if mine and theirs and mine != theirs: why = f"결과단위 다름 — {o['결과단위']} ≠ {block.get('단위')}" out["뺌"].append({"키": o["키"], "까닭": why}) else: out["후보"].append(o) if out["표지"] is None and not out["후보"]: out["표지"], out["글"] = "기초 없음", "결과단위 맞는 우리 기초 없음" return out