"""M02 일위대가 실무자료 맞춤(PLAN 324-2 · 338) — 엑셀 구성 줄 → 우리 로직 · 자재 · 입력값 · 환산. 이름 · 근거 차례(`b_order` · 338 설계 2-4 B) = 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 · 읽기만)이 1위 · 단 엑셀 인용 조항이 다른 로직을 가리키면(근거 검사 · 옛 판 글이면 그대로) 후보 · 그 밖 = 이름 후보(글자 두 개 묶음 + 단위 + 산림 덤 · 뜻 낱말 안 겹치면 뺌 · 로직은 접지 않음) + 근거 후보(`M02_Hopyo_Basis` · 조항 같음 · 윗마디 · 직종) 합침 — 숫자 맞춤(`M02_Hopyo_Fit`)의 덤. 줄 · 호표 맞춤 응답(상태 · 체크 제안 · 대상 · 품 비교) = `M02_Hopyo_Line` · 별칭 장부 = 다 창. 규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) · 이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시. 환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B · m→㎥ × B × T = 「추정」(체크 안 함) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력. M01 은 읽기만(로직 별칭 안 씀). """ 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 from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as 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 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 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 # ── 환산 ───────────────────────────────────────────────────────────── def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]: """`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력.""" units = [u.strip() for u in str(want or "").split("·") if u.strip()] plain = mm.plain_unit(have) if len(units) > 1: ok = plain in {mm.plain_unit(u) for u in units} return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"} if plain == mm.plain_unit(want): return {"배": 1, "갈래": "같음"} a, b = mp.kind_of(have), mp.kind_of(want) if a is not None and a == b: scale = mp.UNITS[a] ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)]) return {"배": _json(ratio), "갈래": "단위표"} found = numbers(spec) width, thick = _meters(found.get("B")), _meters(found.get("T")) by = {("넓이", "부피"): thick, ("길이", "넓이"): width} if (a, b) == ("길이", "부피") and width is not None and thick is not None: by[(a, b)] = width * thick got = by.get((a, b)) if got: return {"배": _json(got), "갈래": "추정"} return {"배": None, "갈래": "입력"} 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 ())] def candidates( line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` (None = 모두).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") scored, seen = [], set() mine = words(line.get("이름")) for one in (got or targets())[pool]: if not grams & one["grams"] or not near(mine, one["words"]): continue score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) score += 1 if one["tidy"] == me else 0 score += 0.15 if fits(unit, one["단위"]) else 0 score += 0.05 if one.get("산림") else 0 score += 0.5 if one.get("spec") and one["spec"] in text else 0 scored.append((score, one)) scored.sort(key=lambda x: -x[0]) out = [] for score, one in scored: label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음 if label in seen: continue seen.add(label) out.append({**_public(one), "점수": round(score, 3)}) if len(out) == limit: break return out # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 _GENERIC = frozenset( "인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 " "설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 " "건설표준품셈 이하 이상".split() ) def fix_typo(theirs: list) -> list[list[str]]: """엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」.""" out = [] for book, n in theirs or []: m = _TYPO_CLAUSE.match(str(n)) out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n]) return out def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]: """(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5 · 나 창 `Basis.clause` 가 있으면 그것.""" if hasattr(hopyo_basis, "clause"): return hopyo_basis.clause(theirs, our) 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 = {n.rsplit("-", 1)[0] for n in nums} near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads) if near_: return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음" return 0.0, [], "" def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool: if cand["근거"]["직종"]: return True mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC} theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC} return any(a in b or b in a for a in mine for b in theirs) def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict: """이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15], 후보: [5], 근거: {로직: 근거}}` — 이름 후보 + 근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) · 옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리.""" got = got or targets() basis_ = dict(line.get("근거") or {}) if basis_.get("출처"): line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}} basis = hopyo_basis.candidates(line, got) named = candidates(line, "로직", got, None) merged_c = [c for c in hopyo_basis.merge(basis, named) if c.get("로직")] merged = [c["로직"] for c in merged_c] why = {c["로직"]: c["근거"] for c in merged_c if c.get("근거")} by = {c["로직"]: c for c in merged_c} pub = {x["로직"]: x for x in got["로직"]} def done(state: str, top: list[str]) -> dict: cands = [by.get(k) or {**_public(pub[k]), "점수": None} for k in top[:5] if k in pub] return {"상태": state, "top": top[:5], "합침": merged[:15], "후보": cands, "근거": why} found = _exact(line, "로직", got) if len(found) == 1: tgt = found[0]["로직"] theirs = (line.get("근거") or {}).get("출처") or [] our = hopyo_basis.ours().get(tgt) if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs: return done("같음", [tgt]) if our and clause(theirs, our)[0] >= 0.9: return done("같음", [tgt]) strong = [c for c in basis if c["근거"]["까닭"].startswith(("조항 같음", "윗마디 같음"))] strong = [c for c in strong if _strong_basis(line, c)] if not strong: return done("같음", [tgt]) top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]])) return done("후보", top) top = [x["로직"] for x in found] rest = [c for c in merged_c if c["로직"] not in top] first = next((c.get("로직") for c in named if c.get("로직") not in top), None) top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))] return done("후보" if top else "없음", top) # ── 줄 · 호표 맞춤(338 · 설계 3-3 · 3-4 — 몸은 `M02_Hopyo_Line`) ───────────────── def _logic_specs(code: str) -> list[dict[str, Any]]: got = labor_inputs.logic_inputs([code])["로직"] return got[0]["입력"] if got else [] def _line_mod(): from M02_MasterTemplete import M02_Hopyo_Line as line_mod # 줄 맞춤이 이 파일을 부름 return line_mod def units_of(doc: dict[str, Any]) -> dict[int, str]: return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []} def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]: """실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · 있으면 그 호표 줄마다 맞춤(3-3) · `to` = 만들 층(장부 · 확인된 사전조합 · 없으면 파일 층) · 맞춤셈이 없으면 뒤 셈을 넣음.""" lines = _line_mod() ctx = lines.Ctx(folder, key, to) doc = ctx.doc if no is None: hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []] keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행") brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc} return {**head, "호표": brief, "맞춤": ready} hopyo = ctx.units.get(int(no)) if hopyo is None: raise store.StoreError(404, f"없는 호표 「제{no}호표」") return {**hopyo_store.brief(doc), "호표": lines.hopyo_view(hopyo, ctx)} def guess( folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None ) -> dict[str, Any]: """미리 셈(3-4 · 저장 안 함) — 대상 목록(낱개도 받음) → {대상, 품, 글}.""" return _line_mod().guess(folder, key, no, at, target, to) def picks(folder: Path) -> list[dict[str, Any]]: """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 [체크 제안 수, 줄 수] (장부 · 확인된 사전조합 = 이 층).""" lines, out = _line_mod(), [] for one in chain(folder): layer, owner = refs.where(one) for row in hopyo_store.rows(one): ctx = lines.Ctx(one, row["키"], folder) for hopyo in ctx.doc.get("호표") or []: ready = lines.hopyo_view(hopyo, ctx, False)["맞춤"] head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} keep = ("번", "이름", "규격", "단위") out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) return out def chain(folder: Path) -> list[Path]: """실무자료를 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" layer, owner = refs.where(folder) out = [Path(folder)] if layer == "personal": out.append(refs.folder_of("company", str(owner).split("/")[0])) if layer in ("personal", "company"): out.append(Path(store.FOLDER)) return out