"""M02 서고 문서 내용 지문 · 같은 것 찾기(PLAN 733-1 · 요구서_572 3장 1 · 서버 단독). 지문 = 단가산출(SK) · 일위대가(SC) 셈에 닿는 내용만 정규화해 `derived_key` — 뺌 = 키 · 이름 · 구분 · 상세구분 · 본뜬 · 메모 · 별칭 · 바탕판 · 원값 · 계산값 · 열 · 일위대가번 · 종류 · 날짜(수정일) · 고정 줄 출처 수 = Decimal 정규화(0.20 = 0.2 = 「0.2」) · 글 = 겹 빈칸 정리 · 빈 값 칸 뺌 · 단위 = 소문자 + m3 → ㎥ 차례 무시 = 더한줄 · 담은로직 · 자재 · 뺀줄 · 열린입력 정렬 · 더한줄 요소 줄 이름 뺌(요소 키가 정체) SC 줄 · 자재 수량(742-1 저장 수량) · SK 더한줄 수량 넣음 참조 = 가리킨 문서의 지문(깊이 `refs.DEPTH` · 층 넘어 사본도 같음) 빈 문서(SK 바탕 · 더한줄 없음 · SC 줄 · 자재 없음) = 지문 None(검사 안 함). 저장 칸 없음 — 그때그때 셈(`Same` 한 벌 = 한 요청 안 메모). `find_same(folder, kind, doc)` = 같은 층 같은 내용 키(작은 번호 = 먼저 만든 것 먼저) · `find_above(folder, kind, doc)` = 위 층(회사 · 마스터) 같은 내용 참조 `[{층, 주인, 키}]`. """ from __future__ import annotations import json from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any from common_util.common_util_derived_store import derived_key from M02_MasterTemplete import M02_Labor_Ref as refs DROP = {"키", "이름", "구분", "상세구분", "본뜬", "메모", "별칭", "바탕판", "원값", "계산값", "열"} DROP |= {"일위대가번", "종류", "수정일", "출처", "처음수량", "뺀더한줄"} # 744-1 되돌림 기억 UNIT = {"m3": "㎥", "m2": "㎡", "m³": "㎥", "m²": "㎡"} EMPTY = (None, "", [], {}) def unit(value: Any) -> str: text = "".join(str(value or "").split()).lower() return UNIT.get(text, text) def _num(text: str) -> str | None: try: got = Decimal(text.replace(",", "")) except (InvalidOperation, ValueError): return None return format(got.normalize(), "f") if got.is_finite() else None def norm(value: Any) -> Any: if isinstance(value, dict): out = {str(k): norm(x) for k, x in value.items() if k not in DROP} return {k: x for k, x in out.items() if x not in EMPTY} if isinstance(value, list): return [norm(x) for x in value] if isinstance(value, bool) or value is None: return value if isinstance(value, (int, float)): return _num(str(value)) if isinstance(value, str): text = " ".join(value.split()) got = _num(text) if text and (text[0].isdigit() or text[0] in "-.") else None return text if got is None else got return value def _sorted(lines: list) -> list: return sorted(lines, key=lambda x: json.dumps(x, ensure_ascii=False, sort_keys=True)) def _bare(cover: Any) -> dict: return { c: {k: v for k, v in (d or {}).items() if k != "원값"} for c, d in (cover or {}).items() } class Same: """지문 한 벌 — 참조 지문 · 층 목록 메모(한 요청 안).""" def __init__(self) -> None: self.memo: dict[str, str] = {} self.lists: dict[tuple[str, str], dict[str, str]] = {} # ── 지문 ────────────────────────────────────────────────────────── def finger(self, kind: str, doc: Any, trail: tuple = ()) -> str | None: if not isinstance(doc, dict): return None if not trail: # 740-1 — 옛 참조 줄 = 펼친 꼴로 대조(펼쳐 담은 문서와 같은 지문) from M02_MasterTemplete import M02_Library_Flat as flat doc = flat.flat_doc(kind, doc) body = self._sk(doc, trail) if kind == "sangeun" else self._sc(doc, trail) if kind == "sangeun" and not body.get("바탕") and not body.get("더한줄"): return None if kind != "sangeun" and not body.get("담은로직") and not body.get("자재"): return None return f"{refs.PREFIX.get(kind, kind)}:{derived_key(body)}" def _ref(self, ref: Any, trail: tuple) -> Any: if not isinstance(ref, dict): return norm(ref) rid = refs.ref_id(ref) if rid not in self.memo: doc = refs.read_ref(ref) if rid not in trail and len(trail) <= refs.DEPTH else None got = self.finger(refs.kind_of(ref.get("키")), doc, (*trail, rid)) if doc else None self.memo[rid] = got or f"없음:{rid}" return {"내용": self.memo[rid]} def _line(self, one: Any, trail: tuple, qty: bool) -> Any: if not isinstance(one, dict): return norm(one) line = dict(one) if isinstance(line.get("참조"), dict): line["참조"] = self._ref(line["참조"], trail) if not qty and not refs.is_rate(line): line["수량"] = None if line.get("요소"): line.pop("이름", None) if isinstance(line.get("덮개"), dict): line["덮개"] = _bare(line["덮개"]) return norm(line) def _sk(self, doc: dict, trail: tuple) -> dict: base = doc.get("바탕") if isinstance(base, dict) and isinstance(base.get("참조"), dict): base = {"참조": self._ref(base["참조"], trail)} body = { "단위": unit(doc.get("단위")), "바탕": norm(base), "입력값": norm(doc.get("입력값") or {}), "환산": norm(doc.get("환산")), "켜쌓기율": norm(doc.get("켜쌓기율")), "열린입력": sorted(map(str, doc.get("열린입력") or [])), "고름": norm(doc.get("고름") or {}), "자재표시": norm(doc.get("자재표시") or {}), "덮개": norm(_bare(doc.get("덮개"))), "더한줄": _sorted([self._line(x, trail, True) for x in doc.get("더한줄") or []]), "뺀줄": sorted(map(str, doc.get("뺀줄") or [])), } return {k: v for k, v in body.items() if v not in EMPTY} def _sc(self, doc: dict, trail: tuple) -> dict: mats = [ norm({k: v for k, v in m.items() if k != "수량"} if isinstance(m, dict) else m) for m in doc.get("자재") or [] ] body = { "단위": unit(doc.get("단위")), "담은로직": _sorted([self._line(x, trail, False) for x in doc.get("담은로직") or []]), "자재": _sorted(mats), "규격": norm(doc.get("규격")), } return {k: v for k, v in body.items() if v not in EMPTY} # ── 층 훑기 ─────────────────────────────────────────────────────── def listed(self, folder: str | Path, kind: str) -> dict[str, str]: """층 `{키: 지문}`(빈 문서 뺌 · 키 차례).""" at = (str(Path(folder).resolve()), kind) if at not in self.lists: out = {} for path in sorted((Path(folder) / kind).glob(f"{refs.PREFIX[kind]}-*.json")): try: got = self.finger(kind, json.loads(path.read_bytes())) except (OSError, ValueError): continue if got: out[path.stem] = got self.lists[at] = out return self.lists[at] def add(self, folder: str | Path, kind: str, key: str, doc: Any) -> None: """새로 쓴 문서를 메모 목록에 더함(다시 훑지 않음).""" got = self.finger(kind, doc) if got: self.listed(folder, kind)[key] = got def find_same( self, folder: str | Path, kind: str, doc: Any, skip: str | None = None ) -> list[str]: """같은 층 같은 내용 키(작은 번호 먼저 · `skip` = 자기 키 뺌).""" want = self.finger(kind, doc) if not want: return [] return [k for k, v in self.listed(folder, kind).items() if v == want and k != skip] def find_above(self, folder: str | Path, kind: str, doc: Any) -> list[refs.Ref]: """위 층(회사 · 마스터) 같은 내용 참조 `[{층, 주인, 키}]`.""" want = self.finger(kind, doc) out: list[refs.Ref] = [] for layer, owner in above(refs.where(folder)) if want else []: here = refs.folder_of(layer, owner) for key, got in self.listed(here, kind).items(): if got == want: out.append({"층": layer, "주인": owner, "키": key}) return out def above(at: refs.Here) -> list[refs.Here]: """위 층 — 개인 C/U → 회사 C · 마스터 / 회사 → 마스터 / 마스터 · 프로젝트 → 없음.""" layer, owner = at if layer == "personal": return [("company", str(owner).split("/")[0]), ("system", None)] return [("system", None)] if layer == "company" else [] def finger(kind: str, doc: Any) -> str | None: return Same().finger(kind, doc) def find_same(folder: str | Path, kind: str, doc: Any, skip: str | None = None) -> list[str]: return Same().find_same(folder, kind, doc, skip) def find_above(folder: str | Path, kind: str, doc: Any) -> list[refs.Ref]: return Same().find_above(folder, kind, doc)