Merge remote-tracking branch 'origin/dev' into sub_laptop_5

This commit is contained in:
2026-11-02 13:40:03 +09:00
2 changed files with 319 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""M02 서고 문서 내용 지문 · 같은 것 찾기(PLAN 733-1 · 요구서_572 3장 1 · 서버 단독).
지문 = 단가산출(SK) · 일위대가(SC) 셈에 닿는 내용만 정규화해 `derived_key` —
뺌 = 키 · 이름 · 구분 · 상세구분 · 본뜬 · 메모 · 별칭 · 바탕판 · 원값 · 계산값 · 열 · 일위대가번 · 종류 ·
날짜(수정일) · 고정 줄 출처
수 = Decimal 정규화(0.20 = 0.2 = 「0.2」) · 글 = 겹 빈칸 정리 · 빈 값 칸 뺌 · 단위 = 소문자 + m3 → ㎥
차례 무시 = 더한줄 · 담은로직 · 자재 · 뺀줄 · 열린입력 정렬 · 더한줄 요소 줄 이름 뺌(요소 키가 정체)
SC 줄 · 자재 수량 = null(723-1 `refs.no_qty` · 비율 줄 율만) · 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 |= {"일위대가번", "종류", "수정일", "출처"}
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
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)