From 030a843f0749bf9578b3970596caaca004a04321 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:36:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(815-1=20K1=C2=B7K2=C2=B7K3=C2=B7K5):=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20EST=20=EC=BD=94=EB=93=9C=20=C2=B7=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=EC=B9=B8=20=C2=B7=20=ED=91=9C=EA=B8=B0=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=94=EA=B8=B0=20=C2=B7=20=EA=B2=AC=EC=A0=81=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - K1 EST 원파일 자재 = 우리 자재 출처 칸 EST 코드로 바로 이음(새 M02_Elem_Alias · 자재 자원 24,219 중 코드 같음 23,981 모두 그 줄) - K2 M01 요소 줄 「별칭」 칸 읽기 — 기계 찾기(옛 별칭 장부 읽기 걷음) · 재료 · 인력 · 유가 · 일위대가 호표 줄(Units · Hopyo_Sangeun 장부 import 뗌) - K3 재료 표기 정규화 정확 일치(M/M · 곱셈표 · 지름 · 괄호 · 빈칸 · 전각 · 지역 꼬리) — 바탕 이름 길보다 먼저(치즐(0.7m3) → 0.2㎥ 줄로 잘못 잇던 것 바로잡음) - K5 견적 · 운임 · 수수료 줄 = 우리 자재면 재료 줄(우리 값) · 아니면 그대로 + 「견적」 표지 · 문서 설명 · 화면 「견적 n」 배지(못 이음 수에서 뺌) - 시험 test_815_1_import_link 새로 · test_812_9 = 옛 장부 별칭 대신 기계 줄 별칭 칸(마스터 다섯은 칸이 비면 시험 동안만 넣음) · 「디젤 엔진」 은 마스터 별칭 묶음으로 옮김 - test_778_5 기계 표(162 · 3 · 25)는 고치기 전 판과 같음 — 812-9 뒤부터 굳힌 수와 다름(이번 고침 무관 · 안 고침) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_Import.py | 5 +- M02_MasterTemplete/M02_Doc_NativeEst.py | 11 +- M02_MasterTemplete/M02_Elem_Alias.py | 106 +++++++++ M02_MasterTemplete/M02_Hopyo_Row.py | 19 ++ M02_MasterTemplete/M02_Hopyo_Sangeun.py | 6 +- M02_MasterTemplete/M02_Hopyo_Units.py | 34 +-- M02_MasterTemplete/M02_Machine_Find.py | 67 ++---- .../M02_MasterTemplete_Hopyo_Imported.ts | 2 +- .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 12 +- M02_MasterTemplete/M02_San_Link.py | 60 +++--- M02_MasterTemplete/M02_Sangeun_Imported.py | 4 +- resources/tester/test_812_9_machine_link.py | 50 +++-- resources/tester/test_815_1_import_link.py | 202 ++++++++++++++++++ 13 files changed, 449 insertions(+), 129 deletions(-) create mode 100644 M02_MasterTemplete/M02_Elem_Alias.py create mode 100644 resources/tester/test_815_1_import_link.py diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 3c9145ee7..b725339b6 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -332,7 +332,10 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None: if not note and not item.get("이음"): # 못 이은 까닭(814-1 — 흠 아님 · 우리 정보 없음) from M02_MasterTemplete import M02_San_Link as san_link - var["설명"] = f"이음 없음 — {san_link.why(item)} · 엑셀 단가 그대로" + why = san_link.why(item) + var["설명"] = ( + why if why == san_link.QUOTE else f"이음 없음 — {why} · 엑셀 단가 그대로" + ) continue if (opt := link.pop(OPTION, None)) and opt["열쇠"] not in { v.get("열쇠") for v in doc["변수"] diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py index a5dbaa797..d915b553e 100644 --- a/M02_MasterTemplete/M02_Doc_NativeEst.py +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -274,6 +274,10 @@ class Block: out["옮김"] = part if part != nat else None return out + def quote(self, r: dict) -> tuple[str | None, str | None]: + """견적 자원(갈래 고정) → 우리 자재 이음 · 표지(`M02_San_Link.quote`) · 그 밖 = (None, None).""" + return em._m01("고정", r) if self.keys and r.get("갈래") == "고정" else (None, None) + def register(self, ln: dict, call: str) -> None: if ln.get("결과"): self.done.append((ln["결과"][-1], call, ln)) @@ -314,6 +318,8 @@ class Block: prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows} r = self.res(rows[0], None, None) r |= {"단가": prices} + if quoted := self.quote(r)[1]: # 815-1 K5 — 세 줄 견적 = 값묶음 그대로 · 표지 한 번 + self.mark(rows[0], quoted.removeprefix(NOTE)) parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 r |= {"찾기": parts[0]} if parts else {} r |= {"조합첫": True} if len(parts) > 1 else {} @@ -363,10 +369,13 @@ class Block: parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 r |= {"찾기": parts[0]} if parts else {} r |= {"조합첫": True} if len(parts) > 1 else {} - one = em.resource(name, r.get("단위"), qty, r, parts[0]["키"] if parts else None) + link, many = (parts[0]["키"], None) if parts else self.quote(r) + one = em.resource(name, r.get("단위"), qty, r, link) em.first_spec(one, parts) one |= {"자리": place} if place else {} self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1]) + if many: + self.mark(ln, many.removeprefix(NOTE)) self.got["확정"] += bool(got) self.register(ln, self.parts(one, parts, ln, ln)) diff --git a/M02_MasterTemplete/M02_Elem_Alias.py b/M02_MasterTemplete/M02_Elem_Alias.py new file mode 100644 index 000000000..523dade54 --- /dev/null +++ b/M02_MasterTemplete/M02_Elem_Alias.py @@ -0,0 +1,106 @@ +"""M02 불러오기 요소 잇기 색인(PLAN 815-1 · 요구 755 ~ 764) — M01 요소 줄 「별칭」 칸 · EST 참조 코드 · +재료 정확 일치. 서버 단독 · 읽기만(M01 판이 바뀌면 다시 읽음 · `id(whole)`). + +- `keys(표, 이름, 규격)` = 별칭으로 이어지는 키들(풀이 = `master_alias` · 표 LB 인력 · EQ 기계 · MT 재료 · + MO 유가) — 부르는 곳: 단가산출 불러오기(`M02_San_Link.m01`) · 1차 줄 판정(`M02_Hopyo_Row.m01_picks`) · + 기계 찾기(`M02_Machine_Find.alias`) · 일위대가 호표 줄(`M02_Hopyo_Units`) +- `est_code(코드)` = EST 원파일 자재 자원 코드(M…) ↔ 우리 자재 줄 `출처` 칸 「자재품목 M…」 같은 줄 +- `exact(이름, 규격)` = 지역 꼬리 뗀 이름 · 규격이 `master_alias.norm` 으로 같은 자재 줄(가까운 규격 아님) +- `one(줄, 키들)` = 같은 품목(지역 꼬리 뗀 이름 · 규격 같음)의 지역 줄 여럿 → 줄 지역 › 서울 › 같은 단위 › + 키 차례 하나 · 다른 품목이 섞이면 None(후보 여럿) +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +import master_alias as am # noqa: E402 — M01 store 가 scripts 경로를 넣음 + +MARKET = "MT" +BASE_REGION = "서울" +_CODE = re.compile(r"\bM\d{10}\b") +_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 + + +def region_of(name: Any) -> tuple[str, str]: + """자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글).""" + text = str(name or "") + m = _REGION.search(text) + return (text[: m.start()], m.group(1)) if m else (text, "") + + +def _same(name: Any, spec: Any) -> tuple[str, str]: + return am.norm(region_of(name)[0]), am.norm(spec) + + +@lru_cache(maxsize=2) +def _book(_whole: int) -> dict[str, Any]: + whole = m01.loaded()[1] + tables = {t: whole.numbers.get(t, {}) for t in ("LB", "EQ", "MO")} + tables[MARKET] = whole.index.get(MARKET, {}) + rows = {t: {str(r.get("키") or k): r for k, r in got.items()} for t, got in tables.items()} + alias = {t: am.index({"키": k, "별칭": r.get("별칭")} for k, r in got.items()) for t, got in rows.items()} # fmt: skip + code: dict[str, list[str]] = {} + exact: dict[tuple[str, str], list[str]] = {} + for k, r in rows[MARKET].items(): + for c in _CODE.findall(str(r.get("출처") or "")): + code.setdefault(c, []).append(k) + exact.setdefault(_same(r.get("이름"), r.get("규격")), []).append(k) + return {"줄": rows, "별칭": alias, "코드": code, "같음": exact} + + +def book() -> dict[str, Any]: + return _book(id(m01.loaded()[1])) + + +def row(table: str, key: Any) -> dict[str, Any]: + return book()["줄"].get(table, {}).get(str(key)) or {} + + +def keys(table: str, name: Any, spec: Any = "") -> list[str]: + """별칭 칸으로 이어지는 키들(없음 = []).""" + b = book() + got = b["줄"].get(table, {}) + return am.match(b["별칭"].get(table, {}), lambda k: (got.get(k) or {}).get("규격"), name, spec) + + +def est_code(code: Any) -> list[str]: + """EST 자재 코드(M + 수 열) → 우리 자재 키들(출처 칸 같은 코드).""" + text = str(code or "").strip() + return list(book()["코드"].get(text, [])) if _CODE.fullmatch(text) else [] + + +def exact(name: Any, spec: Any) -> list[str]: + """지역 꼬리 뗀 이름 · 규격 정규화 같음 자재 키들.""" + return list(book()["같음"].get(_same(name, spec), [])) + + +def one(line: dict[str, Any], found: list[str]) -> str | None: + """같은 품목 지역 줄 여럿 → 하나(줄 지역 › 서울 › 같은 단위 › 키) · 다른 품목 섞임 = None.""" + rows = {k: row(MARKET, k) for k in found} + if not rows or len({_same(r.get("이름"), r.get("규격")) for r in rows.values()}) != 1: + return None + mine, unit = region_of(line.get("이름"))[1], am.norm(line.get("단위")) + + def rank(k: str) -> tuple: + where = region_of(rows[k].get("이름"))[1] + return (where != mine if mine else True, where != BASE_REGION, am.norm(rows[k].get("단위")) != unit, k) # fmt: skip + + return min(rows, key=rank) + + +def material(line: dict[str, Any], code: Any = None) -> tuple[str | None, str]: + """재료 줄 → (우리 자재 키, 길 「코드」 · 「별칭」 · 「정확 일치」) · 별칭 여럿 = (None, 「별칭 후보 여럿」) · + 없음 = (None, "").""" + if hit := est_code(code): + return one(line, hit) or hit[0], "코드" + hit = keys(MARKET, line.get("이름"), line.get("규격")) + if hit: + got = hit[0] if len(hit) == 1 else one(line, hit) + return (got, "별칭") if got else (None, "별칭 후보 여럿") + got = one(line, exact(line.get("이름"), line.get("규격"))) + return (got, "정확 일치") if got else (None, "") diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index 64107ecf7..f139d8e1f 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -77,6 +77,8 @@ def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]: @lru_cache(maxsize=4096) def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]: + if kind in ("인력", "재료") and (named := _alias(kind, name, spec)): + return named # 815-1 K2 — M01 줄 「별칭」 칸 먼저 if kind == "인력": trade = est.trade(name) hit = [r for r in _rows("LB").values() if trade and r.get("이름") == trade] @@ -111,6 +113,23 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[ return [] +def _alias(kind: str, name: str, spec: str) -> list[dict[str, Any]]: + """M01 별칭 칸 이음(`M02_Elem_Alias`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 · + 같은 품목 지역 줄은 하나.""" + from M02_MasterTemplete import M02_Elem_Alias as ea + + table = "LB" if kind == "인력" else ea.MARKET + keys = ea.keys(table, name, "" if kind == "인력" else spec) + if kind == "재료" and len(keys) > 1: + keys = [k] if (k := ea.one({"이름": name, "규격": spec}, keys)) else keys + if kind == "재료" and not keys: # 815-1 K3 — 표기 정규화 정확 일치(바탕 이름 길보다 먼저) + keys = [k] if (k := ea.one({"이름": name, "규격": spec}, ea.exact(name, spec))) else [] + rows = [(k, ea.row(table, k)) for k in keys] + if kind == "인력": + return [pick("인력", k, r.get("이름"), "", "인", 단가=_plain(r.get("값"))) for k, r in rows] + return [pick("재료", k, r.get("이름"), r.get("규격"), r.get("단위")) for k, r in rows] + + # ── M02 층 SK · SC(같은 이름 · 참고) ──────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py index 72eec1b85..481ba6f28 100644 --- a/M02_MasterTemplete/M02_Hopyo_Sangeun.py +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -23,7 +23,6 @@ from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Fixed as hfix -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Labor_Doc as labor @@ -144,9 +143,8 @@ class Maker: self._units: units.Units | None = None def units(self) -> units.Units: - if self._units is None: - books = ledger.books(self.source) - self._units = units.Units(books, ledger.doc_id(self.source, self.key)) + if self._units is None: # 815-1 — 옛 별칭 장부 안 읽음(M01 별칭 칸은 Units 가 읽음) + self._units = units.Units() return self._units def ref_of(self, sub: dict[str, Any]) -> dict[str, Any] | None: diff --git a/M02_MasterTemplete/M02_Hopyo_Units.py b/M02_MasterTemplete/M02_Hopyo_Units.py index 657fd2c55..914276e91 100644 --- a/M02_MasterTemplete/M02_Hopyo_Units.py +++ b/M02_MasterTemplete/M02_Hopyo_Units.py @@ -6,13 +6,13 @@ L 노무 = 직종 LB(이름 · 옛이름) · 같은 이름 여럿 = 건설업 먼저(작업반장 · 요구 624-1) · 단위 `{노임}` X 기계 = EQ(`Fixed.Linker._machine` · 778-1 괄호 낱말) → 기계경비 기초 로직(`mach_logics` · 778-2) · 단위 `{기계, 로직, 할증?}` · 없는 규격 = 가까운 규격 후보만(`Elem.machines` · 자동 고름 없음) - M 자재 = MT(`material` — 별칭 장부 › 이름 · 규격 같음 › 바탕 이름 · 숫자 규격 같음(치수 차례 · 구분자 + M 자재 = MT(`material` — M01 별칭 칸 › 이름 · 규격 같음 › 바탕 이름 · 숫자 규격 같음(치수 차례 · 구분자 무관) · 글자 든 규격 = 후보로만) · 손료율 = 방법 「수량 × 손료율 × 단가」 · 별도 = 단위 `{자재?, 별도계상}` · 단위 `{자재}` R 율 = 778-4 꼴 `{기준, 비목}` — 기준 = `기준꼴`(인력 · 재료 묶음 · 노무비 · 줄 목록 `{행: [...]}`) · 우리에 없는 율 항목(설치비 · 제경비)도 율 줄 · 기준 못 찾음 = 고를 것(글 기준 제안) S 하위 = `{하위 {종류 단산|일위, 번}}`(SK · SC 참조) · E 값 = `{값: 금액}`(고정 금액 줄) -별칭 장부 짝(자재) = 숫자보다 먼저. 서버 단독 · 읽기만. +M01 줄 「별칭」 칸(자재 · 노임 · 기계 = 기계 찾기 안) = 숫자보다 먼저(815-1). 서버 단독 · 읽기만. """ from __future__ import annotations @@ -22,7 +22,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Fixed as hfix -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Elem_Alias as ea from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Shares as shares @@ -109,6 +109,16 @@ def material(line: dict[str, Any], got: dict[str, Any], full: bool = True) -> di found = hm._exact(me, "자재", got) if len(found) == 1: return {"상태": LINKED, "찾음": found, "글": "이름 · 규격 같음", "꼬리표": tags, "후보": []} + hits = ea.exact( + me.get("이름"), me.get("규격") + ) # 815-1 K3 표기 정규화 같음 한 줄(지역 변형 = 아래 길) + if len(hits) == 1: + key = hits[0] + one = ea.row(ea.MARKET, key) + found = [ + {"자재": key, "이름": one.get("이름"), "규격": one.get("규격"), "단위": one.get("단위")} + ] + return {"상태": LINKED, "찾음": found, "글": "표기 정규화 같음", "꼬리표": tags, "후보": []} tries = [me, *_split(me)] for one in tries: sure, more = hmat.sure(one) @@ -150,7 +160,7 @@ def _text_base(one: dict[str, Any]) -> str: class Units: - """한 번 잇기 — M01 · 목록 · 장부를 한 번 읽음(`books` = `Ledger.books` · 없으면 장부 안 봄).""" + """한 번 잇기 — M01 · 목록을 한 번 읽음(`books` · `doc` = 옛 장부 자리 · 815-1 뒤 안 씀).""" def __init__(self, books: list | None = None, doc: str | None = None) -> None: self.linker = hfix.Linker(by_name=True) @@ -168,6 +178,9 @@ class Units: return {"방법": how, "후보": [], "꼬리표": [], "글": "", **got} def _L(self, one: dict[str, Any]) -> dict[str, Any]: + named = ea.keys("LB", one.get("직종") or one.get("이름")) # 815-1 M01 별칭 칸 먼저 + if len(named) == 1: + return {"상태": LINKED, "단위": {"노임": named[0]}, "꼬리표": ["별칭"]} name = shares.trade(one.get("직종") or one.get("이름")) or "" rows = self.lb.get(name, []) cands = [{"키": str(r.get("키")), "갈래": "노임", "이름": r.get("이름"), @@ -211,7 +224,7 @@ class Units: pair = self._alias(line) if pair: unit = {"자재": pair} | ({"별도계상": True} if apart else {}) - return {"상태": LINKED, "방법": how, "단위": unit, "꼬리표": ["별칭 장부 짝"]} + return {"상태": LINKED, "방법": how, "단위": unit, "꼬리표": ["별칭"]} got = material(line, self.got) apart = apart or hmat.APART in got["꼬리표"] found = [r for r in got["찾음"] if r.get("자재")] @@ -231,14 +244,9 @@ class Units: "글": got["글"], "꼬리표": got["꼬리표"]} # fmt: skip def _alias(self, line: dict[str, Any]) -> str | None: - """별칭 장부 자재 짝(자동 정확 열쇠 · 하나) — 숫자보다 먼저.""" - if self.books is None: - return None - got = ledger.recall(line, "", self.books, doc=self.doc) - if not got or got.get("제안") or got.get("규격다름") or got.get("갈래") != "짝": - return None - pairs = [p.get("자재") for p in got.get("짝") or []] - return pairs[0] if len(pairs) == 1 and pairs[0] else None + """M01 자재 줄 「별칭」 칸(815-1 · 이름 + 규격 · 같은 품목 지역 줄은 하나) — 숫자보다 먼저.""" + keys = ea.keys(ea.MARKET, line.get("이름"), line.get("규격")) + return keys[0] if len(keys) == 1 else ea.one(line, keys) if keys else None def _R(self, one: dict[str, Any]) -> dict[str, Any]: part = rate_part(one.get("이름")) diff --git a/M02_MasterTemplete/M02_Machine_Find.py b/M02_MasterTemplete/M02_Machine_Find.py index 8ac99e096..a70739a66 100644 --- a/M02_MasterTemplete/M02_Machine_Find.py +++ b/M02_MasterTemplete/M02_Machine_Find.py @@ -3,8 +3,8 @@ 과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) · 일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`) · 연결 찾기(`M02_Doc_Match_Note`). 차례: - 1. 별칭 장부(`M02_Hopyo_Ledger` · 마스터 층 + 지금 층 · 짝 갈래 기계) — 이름 + 규격 열쇠 = 그 EQ · - 이름만(규격 빈) 열쇠 = 그 EQ 이름으로 바꿔 2 로 + 1. 별칭(M01 기계 줄 「별칭」 칸 · 815-1 `M02_Elem_Alias`) — 「이름 규격」 통째 별칭 = 그 EQ(여럿 = + 후보 여럿) · 이름만 별칭 = 그 EQ 기종으로 바꿔 2 로 2. 기종 = EQ 이름(괄호 뗌) · 괄호 붙임 · 상세구분과 같은 글 › 그 글을 품은 EQ(표기 정규화 `norm`) 3. 괄호 낱말 — 기계표 괄호에 있는 낱말(무한궤도 · 타이어 · 자주식 …)만 거름 · 표에 없는 낱말(발파 · 리핑)은 안 봄 4. 규격 = 수 + 단위(범위 · 여럿) 모두 EQ 규격에 같은 값(단위 한쪽 없음 = 봄) › 없으면 EQ 범위 안 @@ -20,7 +20,6 @@ import re import time import unicodedata from functools import lru_cache -from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 @@ -222,38 +221,14 @@ def parts(name: Any, spec: Any = "") -> list[tuple[str, str]]: return [(n, s or first) for n, s in out] -# ── 별칭 장부 ───────────────────────────────────────────────────────── -def _alias_rows() -> dict[tuple[str, str], list[str]]: - """(이름, 규격) 정규화 → EQ 키들 — 마스터 층 + 지금 층 별칭 장부 기계 짝.""" - from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store - from M02_MasterTemplete import M02_MasterTemplete_Store as store - - here, top = Path(store.root()), Path(store.FOLDER) - folders = [here] + ([top] if here != top else []) - files = [(f, hopyo_store._dir(f) / f"{hopyo_store.MEMORY}.json") for f in folders] - stamp = tuple((str(f), p.stat().st_mtime_ns if p.is_file() else 0) for f, p in files) - return _alias_read(stamp) - - -@lru_cache(maxsize=8) -def _alias_read(stamp: tuple) -> dict[tuple[str, str], list[str]]: - from M02_MasterTemplete import M02_Hopyo_Ledger as ledger - - out: dict[tuple[str, str], list[str]] = {} - for folder, _ in stamp: - for row in ledger.read(folder).values(): - pairs = row.get("짝") or [] - eqs = [str(p["키"]) for p in pairs if p.get("갈래") == "기계" and p.get("키")] - if row.get("갈래") != "짝" or not eqs: - continue - origin = row.get("원래") or {} - out.setdefault((norm(origin.get("품명")), norm(_clean(origin.get("규격")))), eqs) - return out - - +# ── 별칭(M01 기계 줄 「별칭」 칸 · 815-1) ─────────────────────────────────── def alias(name: Any, spec: Any) -> list[str]: - """별칭 장부 기계 짝 — 이름 + 규격 열쇠(규격 속 할증 글은 뺌) · 없으면 [].""" - return _alias_rows().get((norm(name), norm(_clean(spec))), []) + """M01 기계 줄 별칭(`M02_Elem_Alias`) — 규격이 있으면 「이름 규격」 통째 별칭(규격 속 할증 글은 뺌) · + 규격 빔 = 이름 별칭 · 없으면 [].""" + from M02_MasterTemplete import M02_Elem_Alias as ea + + rows = ea.book()["별칭"].get("EQ", {}) + return list(rows.get(ea.am.norm(name) + ea.am.norm(_clean(spec)), [])) # ── 찾기 ────────────────────────────────────────────────────────────── @@ -280,18 +255,19 @@ def _ans(kind: str, rows: list[Row], query: str, why: str = "") -> dict[str, Any def find(name: Any, spec: Any = "") -> dict[str, Any]: """기계 하나(조합 아님) → `{판정, 키, 후보, 찾기어, 까닭, 수}`.""" rows_by = table()[2] - hit = alias(name, spec) - if len(hit) == 1 and hit[0] in rows_by: - r = rows_by[hit[0]] - return _ans(ALIAS, [r], str(r.raw.get("이름"))) + hit = [rows_by[k] for k in alias(name, spec) if k in rows_by] + if hit: # 별칭 하나 = 그 EQ · 여럿 = 후보 여럿 + if len(hit) == 1: + return _ans(ALIAS, hit, str(hit[0].raw.get("이름"))) + return _ans(MANY, hit, _query(hit, hit[0].base), f"후보 {len(hit)}") base = split_name(name)[0] _, groups, specs = split_name(f"{name or ''} {spec or ''}") # 규격 괄호 낱말 「3.6m(일반용)」 groups.append( _words(_TOKEN.sub(" ", _PAREN.sub(" ", _clean(spec)))) ) # 규격 글 낱말 「탑승유압식」 - named = alias(name, "") - if len(named) == 1 and named[0] in rows_by: # 이름만 별칭 = 그 EQ 기종으로 - base = rows_by[named[0]].base + named = [rows_by[k] for k in alias(name, "") if k in rows_by] + if named and len({r.base for r in named}) == 1: # 이름만 별칭 = 그 EQ 기종으로 + base = named[0].base mine = tokens(" ".join(specs)) kinds = kind_rows(base) known = table()[1] @@ -307,7 +283,7 @@ def find(name: Any, spec: Any = "") -> dict[str, Any]: same = [r for r in rows if spec_fit(mine, r.spec) == "같음"] got = same or [r for r in rows if spec_fit(mine, r.spec) == "안"] if len(got) == 1: # 이름만 별칭으로 기종을 정했으면 「별칭」 - return _ans(ALIAS if len(named) == 1 else LINKED, got, str(got[0].raw.get("이름"))) + return _ans(ALIAS if named else LINKED, got, str(got[0].raw.get("이름"))) if got: return _ans(MANY, got, _query(got, base), f"후보 {len(got)}") if not kinds: @@ -326,12 +302,7 @@ def _gap(mine: list, theirs: list) -> float: def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]: - """기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상) · 글 통째 별칭(짝 여럿)이 먼저.""" - whole = alias(name, spec) - rows_by = table()[2] - if len(whole) > 1 and all(k in rows_by for k in whole): - made = [_ans(ALIAS, [rows_by[k]], str(rows_by[k].raw.get("이름"))) for k in whole] - return [{**x, "이름": str(name or ""), "규격": str(spec or "")} for x in made] + """기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상).""" out: list[dict[str, Any]] = [] for n, s in parts(name, spec): if out and s and not split_name(n)[2] and s == out[0]["규격"] and out[0]["키"]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index b5d0d5a65..2ef66425b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -61,7 +61,7 @@ interface EditAnswer { 문서: ImportedDoc; 전?: Money4 | null; 후?: Money4 | null; - 이음없음?: { 자리: string; 이름: string; 까닭?: string }[]; + 이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[]; /** qty 답(797-2) */ 줄?: QtyRow[]; 집계?: QtyTally; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 48a17ca3e..d7b67663e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -42,6 +42,7 @@ const TEXT = { Excel: ["엑셀", "Excel"], Now: ["지금", "Now"], Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"], + Quote: ["견적 {n}", "Quotes {n}"], Link: ["이음 고르기", "Pick link"], Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"], QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"], @@ -54,7 +55,7 @@ export const tx = (k: keyof typeof TEXT): string => export interface PanelState { doc: ImportedDoc; /** 이음 없는 자원(서버 price 답) */ - unlinked: { 자리: string; 이름: string; 까닭?: string }[]; + unlinked: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[]; /** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */ qty: QtyTally | null; /** 마지막 단가 바꿈 전후 */ @@ -154,8 +155,13 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { b.dataset.importedLink = u.자리; return b; }); - const tag = createBadge(tx("Unlinked").replace("{n}", String(s.unlinked.length)), "warning"); - kids.push(createToolbar({ items: [tag, ...items] })); + const quotes = s.unlinked.filter((u) => u.견적).length; // 815-1 견적 = 그대로 쓰는 값 · 못 이음 수에서 뺌 + const left = s.unlinked.length - quotes; + const tags = [ + ...(left ? [createBadge(tx("Unlinked").replace("{n}", String(left)), "warning")] : []), + ...(quotes ? [createBadge(tx("Quote").replace("{n}", String(quotes)), "info")] : []), + ]; + kids.push(createToolbar({ items: [...tags, ...items] })); } if (here && s.qty?.자리) { const open = createButton({ label: tx("QtyOpen"), size: 24, variant: "ghost", onClick: a.qty }); diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 843b27ced..3723088d6 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -6,21 +6,20 @@ from __future__ import annotations -import re -import unicodedata - NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머리) OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값) -BASE_REGION = "서울" # 같은 이름 · 규격 자재가 지역마다 있을 때 기본(우리 기초 검색어 꼴) -_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 · 「,서울」 +QUOTE = "견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님" # 815-1 K5 · 요구 762-3 def m01(kind: str, src: dict) -> tuple[str | None, str | None]: """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · 환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" + if kind == "고정": + return quote(src) if kind not in ("인력", "재료", "기계"): return None, None + from M02_MasterTemplete import M02_Elem_Alias as ea from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Row as hrow @@ -34,16 +33,17 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: # 찾기 판정 없는 기계 줄 막음(814-1 · 줄 쌓기 표지 · 별칭) src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip return got["키"], mfind.mark(got, line["이름"], line["규격"]) + if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))): + return ea.one(line, hit) or hit[0], None # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드 + oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else [] if kind == "재료": - if elem.oil_rows(line["이름"]): + if oil or elem.oil_rows(line["이름"]): kind = "유가" elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}: kind = "환율" if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만 - got = [ - {"키": r["키"], "규격": str(r.get("상세구분") or "")} - for r in elem.oil_rows(line["이름"]) - ] + rows = [ea.row("MO", k) for k in oil] or elem.oil_rows(line["이름"]) # 815-1 별칭 먼저 + got = [{"키": r["키"], "규격": str(r.get("상세구분") or "")} for r in rows] spec = line["규격"].replace(" ", "") got = ( [x for x in got if x["규격"] == spec] @@ -59,6 +59,8 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: keys = {x["키"] for x in got if x["규격"] in OIL_BASE} or keys elif len(keys) > 1 and kind == "재료": # 814-1 — 지역 꼬리 뗀 이름 · 규격 정확 일치 keys = exact(line, got) or keys + if not keys and kind == "재료": # 815-1 K3 — 자재표 전체에서 표기 정규화 정확 일치 + keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys if len(keys) == 1: return keys.pop(), None if len(keys) > 1 and kind in ("유가", "환율"): @@ -66,32 +68,28 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: return None, None -def _flat(text: object) -> str: - return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or ""))).lower() +def quote(src: dict) -> tuple[str | None, str | None]: + """견적 · 운임 · 수수료 꼴 줄(갈래 밖 — EST 견적 · STmate 고정 코드 · 815-1 K5) → 우리 자재에 이어지면 + (코드 · 별칭 · 정확 일치 · 단가 줄일 때) 그 키 + 갈래 재료(우리 값 · 엑셀 값은 검산) · 아니면 그대로 + 표지.""" + from M02_MasterTemplete import M02_Elem_Alias as ea - -def region_of(name: object) -> tuple[str, str]: - """자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글).""" - text = str(name or "") - m = _REGION.search(text) - return (text[: m.start()], m.group(1)) if m else (text, "") + src["견적"] = True + line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} + key, _how = ea.material(line, src.get("코드")) + if key and src.get("비목"): + src["갈래"] = "재료" + return key, None + return None, f"{NOTE}{QUOTE}" def exact(line: dict, got: list[dict]) -> set[str]: - """재료 후보 가운데 지역 꼬리 뗀 이름 · 규격이 줄과 정확히 같은 것(빈칸 · 전각 · 대소문자 정규화) — + """재료 후보 가운데 지역 꼬리 뗀 이름 · 규격이 줄과 정확히 같은 것(표기 정규화 `master_alias.norm`) — 지역마다 여럿이면 줄 지역 › 서울 › 같은 단위 › 키 차례 첫째 하나 · 없음 = 빈 모음(사람 확인).""" - name, mine = region_of(line.get("이름")) - want = (_flat(name), _flat(line.get("규격"))) - hits = [x for x in got if x.get("키") and (_flat(region_of(x["이름"])[0]), _flat(x.get("규격"))) == want] # fmt: skip - if not hits: - return set() - unit = _flat(line.get("단위")) + from M02_MasterTemplete import M02_Elem_Alias as ea - def rank(x: dict) -> tuple: - where = region_of(x["이름"])[1] - return (where != mine if mine else True, where != BASE_REGION, _flat(x.get("단위")) != unit, str(x["키"])) # fmt: skip - - return {min(hits, key=rank)["키"]} + want = ea._same(line.get("이름"), line.get("규격")) + hits = [x["키"] for x in got if x.get("키") and ea._same(x.get("이름"), x.get("규격")) == want] + return {k} if (k := ea.one(line, hits)) else set() def hourly_text(key: str | None) -> str | None: @@ -154,7 +152,7 @@ def why(item: dict) -> str: return "노임 없음" if kind == "단가산출": return "안쪽 단가산출 못 이음" - return "갈래 밖(견적 · 고정 값)" + return QUOTE def tally(logic: dict) -> dict: diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index cda813d77..ae10be471 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -304,13 +304,15 @@ def same_part(doc: Any) -> dict | None: def unlinked(doc: dict) -> list[dict]: - """이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭}]`(까닭 = 814-1 `M02_San_Link.why`).""" + """이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭, 견적?}]`(까닭 = 814-1 `M02_San_Link.why` · + 견적 = 815-1 K5 그대로 쓰는 견적 · 운임 · 수수료 줄 — 못 이음 수에서 뺌).""" from M02_MasterTemplete import M02_San_Link as link row = row_of(doc) or {} ids = sd.line_ids(row) return [ {"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)} + | ({"견적": True} if (one.get("자원") or {}).get("갈래") == "고정" else {}) for n, one in enumerate(doc[KEY]["로직"].get("호표") or []) if not one.get("이음") and not one.get("참조") ] diff --git a/resources/tester/test_812_9_machine_link.py b/resources/tester/test_812_9_machine_link.py index 854e71477..f0900e419 100644 --- a/resources/tester/test_812_9_machine_link.py +++ b/resources/tester/test_812_9_machine_link.py @@ -5,8 +5,8 @@ (물탱크 · 크레인(무한궤도) · 머캐덤 · 콘크리트 펌프차 …) · 지어 맞추기 없음(1.0Ton = 우리 표에 없음) (b) 표기 정규화 · 괄호 낱말(무한궤도 · 타이어 · 일반용 · 표에 없는 낱말 발파 = 안 봄) · 범위 · 복수 규격 (c) 조합 기계 = 둘(대형브레이카 +굴착기 · 0.7+브레이카 · +부착용집게 · +진동콤팩터 · 덤프트럭(자동덮개)) -(d) 별칭 먼저 — 지금 층 장부 기계 짝 = 그 EQ · 이름만 열쇠 = 그 기종으로 · 후보 여럿 표지 · - [저장] 때 고른 EQ = 지금 층 장부 줄(`learn`) +(d) 별칭 먼저 — M01 기계 줄 「별칭」 칸(815-1 · 시험 안 임시 별칭) · 이름 + 규격 통째 = 그 EQ · 이름만 = + 그 기종으로 · 후보 여럿 표지 · [저장] 때 고른 EQ = 지금 층 옛 장부 줄(`learn` — 승인 길 바뀔 때까지) (e) 금액 전후 같음 — 조합 줄 둘(첫 줄 엑셀 값 · 다음 줄 0) · 원파일 저장 금액 그대로 """ @@ -23,10 +23,12 @@ for p in (ROOT, HERE): if str(p) not in sys.path: sys.path.insert(0, str(p)) +from M02_MasterTemplete import M02_Elem_Alias as ea # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Ledger as ledger # noqa: E402 from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402 from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층 +from test_815_1_import_link import aliases # noqa: E402 — M01 별칭 칸 임시 def one(name: str, spec: str = "") -> tuple[str, str, str]: @@ -55,7 +57,6 @@ LINKED = [ # 낱말 모름 · 후보 여럿이던 예(812-8) → 하나 ("경운기", "1000kg", "경운기", "1,000㎏"), ("건설용펌프(자흡식)", "50mm", "건설용펌프(자흡식)", "50(1.49×10)"), ("강연선인장기", "120Ton", "강연선인장기", "120ton"), - ("디젤 엔진", "11.19kW", "엔진(디젤)", "기종 디젤 엔진 · 규격 11.19㎾"), ("물탱크", "5500ℓ", "물탱크(살수차)", "5,500ℓ"), ("물탱크", "16000ℓ", "물탱크(살수차)", "16,000ℓ"), ("콘크리트 펌프차", "32m,80~95㎥/hr", "콘크리트 펌프차", "32 [80∼95]"), @@ -92,9 +93,16 @@ MASTER = [ # 마스터 층 승인 별칭(요구 735-5 · 736) — 이름 별칭 ] +# 마스터 기계 별칭 다섯(815-2 가 기계 줄 「별칭」 칸으로 옮김) — 칸이 비었으면 시험 동안만 넣음 +FIVE = {"EQ000193": "아스팔트페이버(피니셔)", "EQ000500": "디젤 엔진", "EQ000400": "워터제트", + "EQ000425": "버킷준설기", "EQ000248": '"바이브레이트 봉상후렉시블"'} # fmt: skip + + @pytest.mark.parametrize("name,spec,eq", MASTER) def test_master_alias(name: str, spec: str, eq: str) -> None: - got = mf.find(name, spec) + rows = ea.book()["줄"]["EQ"] + with aliases("EQ", {k: v for k, v in FIVE.items() if not rows[k].get("별칭")}): + got = mf.find(name, spec) assert got["키"] == eq and got["판정"] in (mf.LINKED, mf.ALIAS) @@ -153,39 +161,29 @@ def test_combo_many_part() -> None: assert mf.find_all("덤프트럭", "15Ton") == [{**mf.find("덤프트럭", "15Ton"), "이름": "덤프트럭", "규격": "15Ton"}] # fmt: skip -def _pair(eq: str, name: str, spec: str, unit: str = "hr") -> dict: - full = ledger.key({"이름": name, "규격": spec, "단위": unit}) - row = {"원래": {"품명": name, "규격": spec, "단위": unit}, "갈래": "짝"} - return {full: row | {"짝": [{"갈래": "기계", "키": eq, "입력값": {}, "배": 1}]}} - - def test_alias_first_and_learn(world) -> None: # noqa: F811 me = world["personal"] me.mkdir(parents=True, exist_ok=True) with store.at(me): assert mf.find("아스팔트페이버(피니셔)", "3.0m")["판정"] == mf.NONE - ledger.write(me, _pair("EQ000193", "아스팔트페이버(피니셔)", ""), {"id": "sub3임시"}) - got = mf.find("아스팔트페이버(피니셔)", "3.0m") # 이름만 열쇠 = 그 기종 → 규격으로 하나 - assert (got["판정"], got["후보"][0]["이름"], got["후보"][0]["규격"]) == ( - mf.ALIAS, "아스팔트 피니셔", "3m") # fmt: skip - ledger.write(me, _pair("EQ000129", "크레인(트럭)", "15Ton"), {"id": "sub3임시"}) - got = mf.find("크레인(트럭)", "15Ton") # 이름 + 규격 열쇠 = 그 EQ(찾기보다 먼저) - assert (got["판정"], got["키"]) == (mf.ALIAS, "EQ000129") - assert ( - mf.find("크레인(트럭)", "15Ton(할증125%)")["키"] == "EQ000129" - ) # 규격 속 할증 글 = 안 봄 - assert mf.same_kind("아스팔트페이버(피니셔)", "아스팔트 피니셔") is True + pairs = {"EQ000193": "아스팔트페이버(피니셔)", "EQ000129": '"크레인(트럭) 15Ton"'} + with aliases("EQ", pairs): + got = mf.find("아스팔트페이버(피니셔)", "3.0m") # 이름만 별칭 = 그 기종 → 규격으로 하나 + assert (got["판정"], got["후보"][0]["이름"], got["후보"][0]["규격"]) == ( + mf.ALIAS, "아스팔트 피니셔", "3m") # fmt: skip + got = mf.find("크레인(트럭)", "15Ton") # 이름 + 규격 별칭 = 그 EQ(찾기보다 먼저) + assert (got["판정"], got["키"]) == (mf.ALIAS, "EQ000129") + assert mf.find("크레인(트럭)", "15Ton(할증125%)")["키"] == "EQ000129" # 할증 글 안 봄 + assert mf.same_kind("아스팔트페이버(피니셔)", "아스팔트 피니셔") is True doc = { "변수": [{"열쇠": "P_크", "기호": "P", "아래": "크", "연결": { "갈래": "단가산출", "키": "GC000270", "입력": {"기계": "'2104-0010'"}}}], "가져온": {"기계": [{"기호": "P_크", "판정": mf.MANY, "키": None, "원래": {"이름": "크레인(트럭)", "규격": "10Ton", "단위": "hr"}}]}, } # fmt: skip - assert mf.learn(doc, {"id": "sub3임시"}) == 1 - got = mf.find("크레인(트럭)", "10Ton") - assert got["판정"] == mf.ALIAS and got["후보"][0]["원문번호"] == "2104-0010" - assert mf.learn(doc, {"id": "sub3임시"}) == 0 # 이미 같은 EQ = 안 씀 - assert mf.find("크레인(트럭)", "10Ton")["판정"] == mf.MANY # 다른 층(마스터) = 안 봄 + assert mf.learn(doc, {"id": "sub3임시"}) == 1 # 옛 층 장부 줄(이음은 별칭 칸만 봄) + assert any(r.get("원래", {}).get("규격") == "10Ton" for r in ledger.read(me).values()) + assert mf.find("크레인(트럭)", "10Ton")["판정"] == mf.MANY def test_combo_money_same() -> None: diff --git a/resources/tester/test_815_1_import_link.py b/resources/tester/test_815_1_import_link.py new file mode 100644 index 000000000..5140a2ed5 --- /dev/null +++ b/resources/tester/test_815_1_import_link.py @@ -0,0 +1,202 @@ +"""815-1 지킴 — 불러오기 이음(방향 장부 `_방향_불러오기.md` K 장 · 요구 753 ~ 765). + +K1 EST 원파일 자재 = 우리 자재 줄 `출처` 칸 EST 코드로 바로(글 대조 없음) · 코드 없음 = 이름 길 +K2 M01 요소 줄 「별칭」 칸 풀이(`master_alias` — 콤마 · 전각 콤마 · 큰따옴표 조각 · 규격) → 기계 · 재료 · + 인력 · 유가 이음(시험 안 임시 별칭 — 마스터 자료 안 고침) +K3 표기 정규화(M/M = mm · 곱셈표 · 지름 · 괄호 · 빈칸 · 전각 · 지역 꼬리) · 정확 일치만 +K5 견적 줄 = 우리 자재에 이어지면 우리 값(재료 줄) · 아니면 그대로 + 「견적」 표시 · 못 이음 수에서 뺌 +금액 = 엑셀 단가 그대로 셈이 원파일 저장 금액과 같음(견적 · 코드 이음 블록). +원파일 없으면 그 시험만 skip(다른 PC). +""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import test_810_2_native_doc as t # noqa: E402 + +from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402 +from M02_MasterTemplete import M02_Elem_Alias as ea # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Row as hrow # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Units as units # noqa: E402 +from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402 +from M02_MasterTemplete import M02_San_Link as link # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Imported as imp # noqa: E402 + +am = ea.am +GONE = object() + + +@contextmanager +def aliases(table: str, pairs: dict[str, str]): + """M01 줄 `별칭` 칸을 시험 동안만 바꿈(읽은 판 안 · 파일 안 씀) — 끝나면 되돌림.""" + rows = ea.book()["줄"][table] + old = {k: rows[k].get("별칭", GONE) for k in pairs} + + def clear() -> None: + ea._book.cache_clear() + hrow._m01_picks.cache_clear() + + for k, v in pairs.items(): + rows[k]["별칭"] = v + clear() + try: + yield + finally: + for k, v in old.items(): + if v is GONE: + rows[k].pop("별칭", None) + else: + rows[k]["별칭"] = v + clear() + + +# ── 별칭 풀이 ───────────────────────────────────────────────────────────── +def test_alias_names_rule() -> None: + assert am.names("") == [] and am.names(None) == [] + assert am.names(" 철선 , 결속선,보통철선 ,, 철선") == ["철선", "결속선", "보통철선"] + assert am.names('"결속선(철선) #20, 0.9M/M", 철선') == ["결속선(철선) #20, 0.9M/M", "철선"] + assert am.names("“크레인(트럭) 15Ton, 할증”") == ["크레인(트럭) 15Ton, 할증"] + idx = am.index([{"키": "A", "별칭": "철선, 결속선"}, {"키": "B", "별칭": "결속선"}]) + assert idx == {"철선": ["A"], "결속선": ["A", "B"]} + + +def test_norm_samples() -> None: + same = [ + ("0.9M/M", "0.9mm", "0.9m/m", "0.9㎜", "0.9 MM"), + ("600×1200", "600x1200", "600X1200", "600*1200", "600 × 1200"), + ("Φ100", "φ100", "ø100", "D=100", "d = 100"), + ("유로폼(패널)", "유로폼 패널", "유로폼[패널]", "유로폼패널"), + ("#20", "#20", "# 20"), + ] + for group in same: + assert len({am.norm(x) for x in group}) == 1, group + assert am.norm("결속선(철선)") != am.norm("철선(결속선)") # 낱말 순서 = 별칭 몫 + + +# ── K1 EST 코드 ──────────────────────────────────────────────────────────── +def test_est_code_first() -> None: + assert ea.est_code("M0005250069") == ["MT000011"] # 치 즐(0.7㎥) + src = {"꼴": "EST", "종류": "자재", "갈래": "재료", "코드": "M0005250069", "이름": "엉뚱한 글", "규격": "", "단위": "EA"} # fmt: skip + assert link.m01("재료", src) == ("MT000011", None) # 글 대조 없이 코드 + gone = {**src, "코드": "M0000000123", "이름": "치즐(0.7m3)"} # 공사가 넣은 코드 = 이름 길 + assert link.m01("재료", gone) == ("MT000011", None) + assert ea.est_code("자재품목 M0005250069") == [] and ea.est_code(None) == [] + + +# ── K2 별칭 → 요소 ─────────────────────────────────────────────────────────── +def test_alias_machine() -> None: + assert mf.find("에어 호스", "1.91cm")["판정"] == mf.NONE + with aliases("EQ", {"EQ000129": '"크레인(트럭) 15Ton"', "EQ000193": "아스팔트페이버(피니셔)"}): + got = mf.find("크레인(트럭)", "15Ton") # 이름 + 규격 통째 별칭 = 그 EQ + assert (got["판정"], got["키"]) == (mf.ALIAS, "EQ000129") + assert mf.find("크레인(트럭)", "15Ton(할증125%)")["키"] == "EQ000129" # 할증 글 안 봄 + got = mf.find("아스팔트페이버(피니셔)", "3.0m") # 이름 별칭 = 그 기종 → 규격으로 하나 + assert (got["판정"], got["후보"][0]["이름"], got["후보"][0]["규격"]) == (mf.ALIAS, "아스팔트 피니셔", "3m") # fmt: skip + with aliases("EQ", {"EQ000129": "크레인 갑", "EQ000130": "크레인 갑"}): + assert mf.find("크레인 갑")["판정"] == mf.MANY # 같은 별칭 둘 = 후보 여럿 + assert mf.find("크레인(트럭)", "15Ton")["판정"] != mf.ALIAS # 되돌림 + + +def test_alias_material() -> None: + line = {"이름": "결속선(철선)", "규격": "#20, 0.9M/M", "단위": "kg"} + assert link.m01("재료", dict(line))[0] is None # 별칭 전 = 못 이음(낱말 순서 다름) + with aliases("MT", {"MT000174": "결속선(철선)", "MT000175": "결속선(철선)"}): + assert link.m01("재료", dict(line)) == ("MT000174", None) # 같은 품목 지역 줄 = 서울 + assert hrow.m01_picks("재료", line)[0]["키"] == "MT000174" + assert units.Units()._M({"묶음": "M", **line})["단위"] == {"자재": "MT000174"} + assert link.m01("재료", {**line, "규격": "#8"})[0] is None # 규격 다름 = 안 이음 + with aliases("MT", {"MT000194": '"결속선(철선) #20, 0.9M/M(200m/kg)"'}): + got = link.m01("재료", {**line, "규격": "#20, 0.9M/M(200m/kg)", "단위": "M"}) + assert got == ("MT000194", None) # 이름 + 규격 통째 별칭 + + +def test_alias_labor_oil() -> None: + with aliases("LB", {"LB000003": "산림특인, 특인"}): + assert [x["키"] for x in hrow.m01_picks("인력", {"이름": "특인"})] == ["LB000003"] + assert link.m01("인력", {"이름": "산림특인", "규격": "", "단위": "인"}) == ( + "LB000003", + None, + ) + assert units.Units()._L({"이름": "특인"})["단위"] == {"노임": "LB000003"} + with aliases("MO", {"MO000002": "디젤유"}): + assert link.m01("재료", {"이름": "디젤유", "규격": "", "단위": "L"}) == ("MO000002", None) + + +# ── K3 표기 맞추기 ────────────────────────────────────────────────────────── +def test_exact_norm() -> None: + assert link.m01("재료", {"이름": "치즐(0.7m3)", "규격": "", "단위": "EA"}) == ("MT000011", None) + got = link.m01("재료", {"이름": "보통철선(결속선)", "규격": "#20, 0.9M/M", "단위": "kg"}) + assert got == ("MT000174", None) + got = link.m01("재료", {"이름": "유로폼 패널", "규격": "600X1200mm", "단위": "매"}) + assert ( + got[0] is None or got[0] == "MT000467" + ) # 규격 단위 꼬리(㎜) 다름 = 정확 일치 아님 · 지어 맞추기 없음 + assert ( + link.m01("재료", {"이름": "보통철선(결속선)", "규격": "#20, 1.0mm", "단위": "kg"})[0] + is None + ) + + +# ── K5 견적 ─────────────────────────────────────────────────────────────── +def test_quote_line() -> None: + ours = {"꼴": "STmate", "갈래": "고정", "코드": "Q1", "이름": "치즐(0.7m3)", "규격": "", "단위": "EA", "비목": "M", "단가": {"M": 9}} # fmt: skip + assert link.m01("고정", ours) == ("MT000011", None) + assert ( + ours["갈래"] == "재료" and ours["견적"] is True + ) # 우리 자재 = 재료 줄(우리 값 · 엑셀 = 검산) + keep = {"꼴": "EST", "갈래": "고정", "종류": "견적", "코드": "W0000001", "이름": "도선료", "규격": "8.0ton", "비목": "S"} # fmt: skip + key, mark = link.m01("고정", keep) + assert key is None and mark == f"{link.NOTE}{link.QUOTE}" and keep["갈래"] == "고정" + assert link.why({"종류": "로직", "자원": keep}) == link.QUOTE + + +def test_quote_money_and_tag() -> None: + saved = t.native("EST", "거창") + no = next(n for n, b in fn.numbers(saved).items() if b.get("이름") == "국가지점번호판") + out = fn.emit(saved, no) + quotes = [h for h in out["로직"]["호표"] if (h.get("자원") or {}).get("견적")] + assert len(quotes) == 2 and all(not h.get("이음") for h in quotes) + assert sum(str(m).endswith(link.QUOTE) for m in out["원본"]["표지"]) == 2 + _sk, doc, got = t.doc_and_money(out) + want = fn.numbers(saved)[no]["저장"] + assert all(abs(float(got[k]) - float(want.get(c) or 0)) < 0.5 for c, k in t.COST) + free = [x for x in imp.unlinked(_sk) if x.get("견적")] + assert len(free) == 2 and all(x["까닭"] == link.QUOTE for x in free) + notes = [v.get("설명") for v in doc["변수"] if v.get("설명") == link.QUOTE] + assert len(notes) == 2 + + +def test_quote_linked_money_same() -> None: + saved = t.native("STC", "소광22") + out = fn.emit(saved, 96) # 구조물헐기 — 치즐(0.7m3) 견적 = 우리 자재 MT000011 + hit = [h for h in out["로직"]["호표"] if (h.get("자원") or {}).get("견적")] + assert [(h["종류"], h["이음"]) for h in hit] == [("재료", "MT000011")] + _sk, _doc, got = t.doc_and_money(out) + want = fn.numbers(saved)[96]["저장"] + assert all(abs(float(got[k]) - float(want.get(c) or 0)) < 0.5 for c, k in t.COST) + + +def test_est_code_block_money() -> None: + saved = t.native("EST", "거창") + linked = 0 + for no in list(fn.numbers(saved))[:20]: + out = fn.emit(saved, no) + for h in out["로직"]["호표"]: + r = h.get("자원") or {} + if r.get("종류") == "자재" and (hit := ea.est_code(r.get("코드"))): + assert h["이음"] in hit + linked += 1 + _sk, _doc, got = t.doc_and_money(out) + want = fn.numbers(saved)[no]["저장"] + assert all(abs(float(got[k]) - float(want.get(c) or 0)) < 0.5 for c, k in t.COST), no + assert linked >= 2 # 거창 앞 스무 블록 자재 줄 셋 중 코드 같음 둘