"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산. 차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 · 읽기만) ③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · 뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). 규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) · 이름 붙은 수 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 datetime import datetime 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_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 = ("자재", "별도계상") READY = ("기억", "같음", "호표") 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 = 5 ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" 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["이름"], 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 # ── 층 기억 ─────────────────────────────────────────────────────────── 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 def memories(folder: Path) -> list[dict[str, dict[str, Any]]]: return [hopyo_store.memory(one) for one in chain(folder)] def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None: key = line_key(line) return next((one[key] for one in memo if key in one), None) # ── 줄 하나 맞춤 ─────────────────────────────────────────────────────── def _logic_specs(code: str) -> list[dict[str, Any]]: got = labor_inputs.logic_inputs([code])["로직"] return got[0]["입력"] if got else [] def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None: if remembered.get("로직"): return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None) if remembered.get("자재"): return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None) return None def match_line( line: dict[str, Any], memo: list[dict[str, dict[str, Any]]], units: dict[int, str], full: bool = True, got: dict[str, Any] | None = None, ) -> dict[str, Any]: """줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` — `units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만).""" out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []} kind = line.get("갈래") pool = "자재" if kind in MATERIAL_KINDS else "로직" remembered = recall(line, memo) got = got or targets() if kind == "%": return {**out, "상태": "%", "환산": None, "들어감": False} if remembered and remembered.get("빼기"): return {**out, "상태": "빼기", "환산": None, "들어감": False} if kind == "호표": no = int((line.get("참조") or {}).get("번") or 0) conv = factor(line.get("단위"), units.get(no), line.get("규격")) target = {"호표": no, "단위": units.get(no)} ready = no in units and conv["배"] is not None and conv["갈래"] != "추정" return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready} if remembered and (target := _target_of(remembered, got)): conv = {"배": remembered.get("환산"), "갈래": "기억"} state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}} return {**out, **state, "환산": conv, "들어감": conv["배"] is not None} found = _exact(line, pool, got) if len(found) == 1: target = found[0] conv = factor(line.get("단위"), target["단위"], line.get("규격")) out.update(상태="같음", 대상=_public(target), 환산=conv) out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정" if full and "로직" in target: out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"])) return out out.update(상태="후보" if found else "없음", 환산=None, 들어감=False) if not full: return out top = [{**_public(x), "점수": 9} for x in found] same = [_public(x) for x in found] rest = [ c for c in candidates(line, pool, got) if {k: v for k, v in c.items() if k != "점수"} not in same ] out["후보"] = (top + rest)[:5] return {**out, "상태": "후보" if out["후보"] else "없음"} def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]: """후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기).""" line = _line(folder, key, no, at) found = _target_of(target if isinstance(target, dict) else {}, targets()) if found is None: raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") target = _public(found) conv = factor(line.get("단위"), target.get("단위"), line.get("규격")) if not target.get("로직"): return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv} values, unread = spec_inputs(line, _logic_specs(str(target["로직"]))) return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv} def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]: doc = hopyo_store.read(folder, key) hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) lines = (hopyo or {}).get("줄") or [] if not 0 <= int(at) < len(lines): raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」") return lines[int(at)] def match_hopyo( hopyo: dict[str, Any], memo: list[dict[str, dict[str, Any]]], units: dict[int, str], full: bool = True, got: dict[str, Any] | None = None, ) -> dict[str, Any]: """호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수].""" got = got or targets() lines = [ {**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or [] ] ready = sum(1 for one in lines if one["맞춤"]["들어감"]) return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]} 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) -> dict[str, Any]: """실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보.""" doc = hopyo_store.read(folder, key) memo, units = memories(folder), units_of(doc) if no is None: got = targets() hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) if hopyo is None: raise store.StoreError(404, f"없는 호표 「제{no}호표」") return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} def picks(folder: Path) -> list[dict[str, Any]]: """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m (이 층 기억 · 만드는 층).""" memo, got, out = memories(folder), targets(), [] for one in chain(folder): layer, owner = refs.where(one) for row in hopyo_store.rows(one): doc = hopyo_store.read(one, row["키"]) units = units_of(doc) for hopyo in doc.get("호표") or []: ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"] head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} keep = ("번", "이름", "규격", "단위") out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) return out # ── 확정 저장 ───────────────────────────────────────────────────────── def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. 로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄).""" if not isinstance(entries, dict) or not entries: raise store.StoreError(400, "확정할 줄이 없음") got = targets() logics = {x["로직"] for x in got["로직"]} mats = {x["자재"] for x in got["자재"]} stamp = datetime.now().isoformat(timespec="seconds") clean: dict[str, Any] = {} for key, raw in entries.items(): if raw is None: clean[str(key)] = None continue if not isinstance(raw, dict) or str(key).count("|") != 2: raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」") if raw.get("빼기") is True: clean[str(key)] = {"빼기": True, "확정": stamp} continue code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat): raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」") times = raw.get("환산") number = None if isinstance(times, bool) else _number(times) if number is None or number <= 0: raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」") values = raw.get("입력값") or {} if not isinstance(values, dict): raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」") head = {"로직": code} if code else {"자재": mat} clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp} return {"기억": hopyo_store.remember(folder, clean)} def _number(raw: Any) -> Decimal | None: try: got = Decimal(str(raw).replace(",", "").strip()) except ArithmeticError: return None return got if got.is_finite() else None