diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py new file mode 100644 index 000000000..991ed02b4 --- /dev/null +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -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) diff --git a/resources/tester/test_572_library_same.py b/resources/tester/test_572_library_same.py new file mode 100644 index 000000000..1dcb59ee4 --- /dev/null +++ b/resources/tester/test_572_library_same.py @@ -0,0 +1,113 @@ +"""서고 문서 내용 지문 규칙 · 같은 층 · 위 층 찾기(PLAN 733-1a · 요구서_572 3장 1). + +지문 규칙 = 이름 · 본뜬 · 메모 · 바탕판 · 원값 · 고정 출처 뺌 · 수 · 단위 정규화 · 줄 차례 무시 · SC 수량 null · +빈 문서 None · 참조 = 가리킨 문서 지문 · 같은 층 찾기 · 위 층 찾기(임시 층). +""" + +import json + +from M02_MasterTemplete import M02_Library_Same as same +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import client # noqa: F401 + +SK = { + "종류": "단가산출", + "키": "SK-000001", + "이름": "유로폼(보통)", + "단위": "m2", + "구분": "실무자료", + "상세구분": "가.xlsx", + "본뜬": {"층": "실무자료", "키": "HP-000001 단산3"}, + "바탕": {"로직": "GF000113"}, + "입력값": {"횟수": 6}, + "열린입력": [], + "고름": {}, + "덮개": {"labor:LB000002#0": {"qty": 0.20, "원값": 0.1}}, + "더한줄": [ + {"종류": "인력", "요소": "LB000001", "이름": "보통인부", "단위": "인", "수량": 0.5}, + {"종류": "로직", "로직": "GC000206", "입력값": {}, "수량": 1, "바탕판": "abc"}, + ], + "뺀줄": ["labor:LB000003#0", "machine:EQ000001#0"], + "바탕판": "x1", +} +SC = { + "종류": "사전조합", + "키": "SC-000001", + "이름": "수로BOX 4.0×2.0", + "단위": "m", + "본뜬": {"층": "실무자료", "키": "HP-000001 제3호표"}, + "담은로직": [ + {"로직": "GF000001", "입력값": {"두께": "0.20"}, "수량": {"손": 3}, "메모": "가"}, + {"고정": {"이름": "잡비", "단위": "%", "비율": True, "출처": {"파일": "가"}}, "수량": 3}, + ], + "자재": [{"코드": "MT000001", "수량": {"손": 2}}], +} + + +def _put(folder, kind, key, doc): + (folder / kind).mkdir(parents=True, exist_ok=True) + (folder / kind / f"{key}.json").write_text(json.dumps({**doc, "키": key}), encoding="utf-8") + + +def test_지문_규칙(): + f = same.finger + base = f("sangeun", SK) + assert base and base.startswith("SK:") + other = {**SK, "키": "SK-000009", "이름": "유로폼(Euro Form)(6회, 보통)", "상세구분": "나.xls"} + other |= {"본뜬": {"층": "실무자료", "키": "HP-000002 단산7"}, "바탕판": "y2", "메모": "다"} + other["단위"] = " M2 " + other["입력값"] = {"횟수": "6.0"} + other["덮개"] = {"labor:LB000002#0": {"qty": "0.2", "원값": 9}} + other["더한줄"] = [ + {"종류": "로직", "로직": "GC000206", "입력값": {}, "수량": "1.00", "바탕판": "zz"}, + {"종류": "인력", "요소": "LB000001", "이름": "인부(보통)", "단위": "인", "수량": 0.50}, + ] + other["뺀줄"] = list(reversed(SK["뺀줄"])) + assert f("sangeun", other) == base # 이름 · 본뜬 · 바탕판 · 원값 · 차례 · 수 꼴 · 단위 꼴 뺌 + for diff in ( + {"입력값": {"횟수": 4}}, + {"단위": "m3"}, + {"덮개": {}}, + {"뺀줄": []}, + {"바탕": {"로직": "GF000114"}}, + {"더한줄": SK["더한줄"][:1]}, + ): + assert f("sangeun", {**SK, **diff}) != base, diff + assert f("sangeun", {**SK, "바탕": None, "더한줄": []}) is None # 빈 문서 + # SC — 수량 null(비율 줄 율만) · 고정 출처 · 메모 뺌 + sc = f("precombo", SC) + size = {**SC, "이름": "수로BOX 2.5×2.5", "자재": [{"코드": "MT000001", "수량": {"손": 9}}]} + size["담은로직"] = [ + {**SC["담은로직"][1], "고정": {**SC["담은로직"][1]["고정"], "출처": {"파일": "나"}}}, + {**SC["담은로직"][0], "수량": {"손": 7}, "메모": "나"}, + ] + assert f("precombo", size) == sc + rate = [SC["담은로직"][0], {**SC["담은로직"][1], "수량": 5}] + assert f("precombo", {**SC, "담은로직": rate}) != sc # 비율 줄 율은 넣음 + assert f("precombo", {**SC, "담은로직": [], "자재": []}) is None + + +def test_같은층_위층_찾기_참조_지문(client): + me, firm = layers.personal_dir(7, 42), layers.company_dir(7) + _put(me, "sangeun", "SK-000001", SK) + _put(me, "sangeun", "SK-000002", {**SK, "이름": "다른 이름"}) + _put(me, "sangeun", "SK-000003", {**SK, "입력값": {"횟수": 4}}) + _put(me, "sangeun", "SK-000004", {**SK, "바탕": None, "더한줄": []}) + _put(firm, "sangeun", "SK-000005", {**SK, "이름": "회사 것"}) + _put(client.system, "sangeun", "SK-000006", SK) + new = {**SK, "이름": "새것", "본뜬": {"층": "실무자료", "키": "HP-000003 단산1"}} + assert same.find_same(me, "sangeun", new) == ["SK-000001", "SK-000002"] + assert same.find_same(me, "sangeun", new, skip="SK-000001") == ["SK-000002"] + assert same.find_above(me, "sangeun", new) == [ + {"층": "company", "주인": "7", "키": "SK-000005"}, + {"층": "system", "주인": None, "키": "SK-000006"}, + ] + assert same.find_above(firm, "sangeun", new) == [ + {"층": "system", "주인": None, "키": "SK-000006"} + ] + assert same.find_same(me, "sangeun", {**SK, "바탕": None, "더한줄": []}) == [] + # 참조 = 가리킨 문서 지문 — 층 넘어 같은 내용 사본을 가리키면 같음 + a = {**SK, "바탕": {"참조": {"층": "company", "주인": "7", "키": "SK-000005"}}, "덮개": {}} + b = {**a, "바탕": {"참조": {"층": "system", "주인": None, "키": "SK-000006"}}} + c = {**a, "바탕": {"참조": {"층": "personal", "주인": "7/42", "키": "SK-000003"}}} + assert same.finger("sangeun", a) == same.finger("sangeun", b) != same.finger("sangeun", c)