From 030a843f0749bf9578b3970596caaca004a04321 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 14:36:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(815-1=20K1=C2=B7K2=C2=B7K3=C2=B7K5):?= =?UTF-8?q?=20=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 # 거창 앞 스무 블록 자재 줄 셋 중 코드 같음 둘 From 8edd8ad6ee18ea15a5603036e23bc00e1d3c7f74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:00:17 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(815-1=20K1=C2=B7K5):=20=EA=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=A7=81=EC=A0=91=20=EB=B2=88=ED=98=B8(M0000000?= =?UTF-8?q?=E2=80=A6)=20=EC=BD=94=EB=93=9C=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=EB=BA=8C=20=C2=B7=20=EA=B2=AC=EC=A0=81=EC=9D=80=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=92=20=EC=9E=88=EB=8A=94=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EST 공사 파일 자재 239 중 235 = 공사 직접 번호 — 파일마다 다른 품목(거창 치즐(0.7㎥) M0000000001 ↔ 우리 표 Air Hammer) · 이름 길로 - 견적 줄이 값 칸 다섯이 빈 우리 자재(치즐 0.7 MT000011)로 이어지면 0 원 — 견적 값 그대로 + 표지 - 시험 test_815_1 — 공사 직접 번호 · 값 빈 견적 · 거창 전 블록 금액 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Elem_Alias.py | 14 ++++++- M02_MasterTemplete/M02_San_Link.py | 5 ++- resources/tester/test_815_1_import_link.py | 47 +++++++++++++++------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/M02_MasterTemplete/M02_Elem_Alias.py b/M02_MasterTemplete/M02_Elem_Alias.py index 523dade54..26b7c2e37 100644 --- a/M02_MasterTemplete/M02_Elem_Alias.py +++ b/M02_MasterTemplete/M02_Elem_Alias.py @@ -4,7 +4,8 @@ - `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…」 같은 줄 +- `est_code(코드)` = EST 원파일 자재 자원 코드(M…) ↔ 우리 자재 줄 `출처` 칸 「자재품목 M…」 같은 줄 · + 공사 직접 번호(M0000000…)는 빼고 이름 길 - `exact(이름, 규격)` = 지역 꼬리 뗀 이름 · 규격이 `master_alias.norm` 으로 같은 자재 줄(가까운 규격 아님) - `one(줄, 키들)` = 같은 품목(지역 꼬리 뗀 이름 · 규격 같음)의 지역 줄 여럿 → 줄 지역 › 서울 › 같은 단위 › 키 차례 하나 · 다른 품목이 섞이면 None(후보 여럿) @@ -21,8 +22,10 @@ from M01_MasterData import M01_MasterData_Store as m01 import master_alias as am # noqa: E402 — M01 store 가 scripts 경로를 넣음 MARKET = "MT" +PRICES = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재 값 칸 다섯 BASE_REGION = "서울" _CODE = re.compile(r"\bM\d{10}\b") +LOCAL = "M0000000" # 공사 직접 번호(파일마다 다른 품목 · 거창 치즐 = 우리 표 Air Hammer) = 코드로 안 이음 _REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 @@ -68,10 +71,17 @@ def keys(table: str, name: Any, spec: Any = "") -> list[str]: return am.match(b["별칭"].get(table, {}), lambda k: (got.get(k) or {}).get("규격"), name, spec) +def priced(key: Any) -> bool: + """우리 자재 줄에 값 칸 다섯 중 하나라도 있음.""" + return any(row(MARKET, key).get(c) is not None for c in PRICES) + + def est_code(code: Any) -> list[str]: """EST 자재 코드(M + 수 열) → 우리 자재 키들(출처 칸 같은 코드).""" text = str(code or "").strip() - return list(book()["코드"].get(text, [])) if _CODE.fullmatch(text) else [] + if not _CODE.fullmatch(text) or text.startswith(LOCAL): + return [] + return list(book()["코드"].get(text, [])) def exact(name: Any, spec: Any) -> list[str]: diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 3723088d6..43731d4d8 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -70,13 +70,14 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: def quote(src: dict) -> tuple[str | None, str | None]: """견적 · 운임 · 수수료 꼴 줄(갈래 밖 — EST 견적 · STmate 고정 코드 · 815-1 K5) → 우리 자재에 이어지면 - (코드 · 별칭 · 정확 일치 · 단가 줄일 때) 그 키 + 갈래 재료(우리 값 · 엑셀 값은 검산) · 아니면 그대로 + 표지.""" + (코드 · 별칭 · 정확 일치 · 단가 줄 · 우리 값 있을 때) 그 키 + 갈래 재료(우리 값 · 엑셀 값은 검산) · + 아니면 그대로 + 표지.""" from M02_MasterTemplete import M02_Elem_Alias as ea src["견적"] = True line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} key, _how = ea.material(line, src.get("코드")) - if key and src.get("비목"): + if key and src.get("비목") and ea.priced(key): # 우리 값 없는 자재 = 견적 값 그대로 src["갈래"] = "재료" return key, None return None, f"{NOTE}{QUOTE}" diff --git a/resources/tester/test_815_1_import_link.py b/resources/tester/test_815_1_import_link.py index 5140a2ed5..9f5677f95 100644 --- a/resources/tester/test_815_1_import_link.py +++ b/resources/tester/test_815_1_import_link.py @@ -91,6 +91,10 @@ def test_est_code_first() -> None: gone = {**src, "코드": "M0000000123", "이름": "치즐(0.7m3)"} # 공사가 넣은 코드 = 이름 길 assert link.m01("재료", gone) == ("MT000011", None) assert ea.est_code("자재품목 M0005250069") == [] and ea.est_code(None) == [] + # 공사 직접 번호 = 파일마다 다른 품목(거창 치즐 ↔ 우리 표 Air Hammer) → 코드로 안 이음 · 이름 길 + assert ea.est_code("M0000000001") == [] + local = {**src, "코드": "M0000000001", "이름": "치즐(0.7㎥)"} + assert link.m01("재료", local) == ("MT000011", None) # ── K2 별칭 → 요소 ─────────────────────────────────────────────────────────── @@ -149,11 +153,18 @@ def test_exact_norm() -> 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 - ) # 우리 자재 = 재료 줄(우리 값 · 엑셀 = 검산) + ours = {"꼴": "STmate", "갈래": "고정", "코드": "Q1", "이름": "치즐(0.6m3)", "규격": "", "단위": "EA", "비목": "M", "단가": {"M": 9}} # fmt: skip + assert link.m01("고정", ours) == ( + "MT000010", + None, + ) # 우리 값 있는 자재 = 재료 줄(우리 값 · 엑셀 = 검산) + assert ours["갈래"] == "재료" and ours["견적"] is True + empty = { + **ours, + "갈래": "고정", + "이름": "치즐(0.7m3)", + } # 우리 줄 값 칸 빔(MT000011) = 견적 값 그대로 + assert link.m01("고정", empty)[0] is None and empty["갈래"] == "고정" 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["갈래"] == "고정" @@ -176,27 +187,33 @@ def test_quote_money_and_tag() -> None: assert len(notes) == 2 -def test_quote_linked_money_same() -> None: +def test_quote_kept_money_same() -> None: saved = t.native("STC", "소광22") - out = fn.emit(saved, 96) # 구조물헐기 — 치즐(0.7m3) 견적 = 우리 자재 MT000011 + out = fn.emit( + saved, 96 + ) # 구조물헐기 — 치즐(0.7m3) 견적 · 우리 치즐 0.7 값 칸 빔 = 견적 값 그대로 + 표지 hit = [h for h in out["로직"]["호표"] if (h.get("자원") or {}).get("견적")] - assert [(h["종류"], h["이음"]) for h in hit] == [("재료", "MT000011")] + assert [(h["종류"], h["이음"]) for h in hit] == [("로직", None)] + assert any(str(m).endswith(link.QUOTE) for m in out["원본"]["표지"]) _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: +def test_est_local_code_block_money() -> None: + """거창 = 자재가 모두 공사 직접 번호(M0000000…) → 이름 길 · 치즐(0.7㎥) = 우리 치즐 0.7(Air Hammer 아님) · + 금액 = 원파일 저장 그대로(전 블록).""" saved = t.native("EST", "거창") - linked = 0 - for no in list(fn.numbers(saved))[:20]: + chisel = set() + for no in fn.numbers(saved): 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 + if r.get("종류") == "자재": + assert str(r.get("코드")).startswith(ea.LOCAL) or r.get("코드") == "M0004113991" + if r.get("코드") == "M0000000001": + chisel.add(h["이음"]) _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 # 거창 앞 스무 블록 자재 줄 셋 중 코드 같음 둘 + assert chisel == {"MT000011"} From f34c63ef461f963b45c240592e3b3a1798c979b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 15:09:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?knowledge(815-1):=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EC=9D=B4=EC=9D=8C=20=EC=A0=84=ED=9B=84=20?= =?UTF-8?q?=EC=A0=84=EC=88=98=20=EB=B3=B4=EA=B3=A0=20=E2=80=94=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=2088.0=20%=20=C2=B7=20=EB=AA=BB=20=EC=9D=B4=EC=9D=80?= =?UTF-8?q?=20=EC=9E=AC=EB=A3=8C=20244=20=EC=A4=84=20=C2=B7=20=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=2059=20=EC=A4=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20EST=20=EC=BD=94=EB=93=9C=20=3D=20main=20DB=20=ED=8C=90?= =?UTF-8?q?=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1104/_목록_815-1_정합성.xlsx | Bin 0 -> 32154 bytes .../raw/요구_1104/_보고_815-1_불러오기이음.md | 77 ++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 docs/raw/요구_1104/_목록_815-1_정합성.xlsx create mode 100644 docs/raw/요구_1104/_보고_815-1_불러오기이음.md diff --git a/docs/raw/요구_1104/_목록_815-1_정합성.xlsx b/docs/raw/요구_1104/_목록_815-1_정합성.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fda3d2639c3dea682a195235840c0477f62c7814 GIT binary patch literal 32154 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 CD6q5u literal 0 HcmV?d00001 diff --git a/docs/raw/요구_1104/_보고_815-1_불러오기이음.md b/docs/raw/요구_1104/_보고_815-1_불러오기이음.md new file mode 100644 index 000000000..efab0e38e --- /dev/null +++ b/docs/raw/요구_1104/_보고_815-1_불러오기이음.md @@ -0,0 +1,77 @@ +# 815-1 불러오기 이음 — 전후 전수 보고(요구 753 ~ 765 · 방향 장부 K 장 K1 · K2 · K3 · K5) + +- 대상 = 814-1 과 같은 자 — 원파일 EST 6 · STC 7(산근 2,220 블록) + 실무 엑셀 11(1,021 블록) +- 전 = 814-1 뒤 판(지금 코드로 다시 잼 · 814-1 보고 「후」와 수 같음) · 후 = 815-1 판(커밋 55ad1bf · 030a843 · 8edd8ad) +- 자세한 표 = `_목록_815-1_정합성.xlsx`(요약 전후 · 못 이은 줄 = 별칭 넣을 후보 · 견적 줄 · 0 원 줄) +- 마스터 자료 · 별칭 값 · storage 층 사본 안 건드림 · 별칭 이음은 시험 안 임시 별칭으로만 확인 + +## 요약 + +| 잣대 | 전(814-1 뒤) | 후(815-1) | 판정 | +|---|---|---|---| +| 금액(엑셀 단가 그대로 셈 = 원본) | 원파일 2,220 · 엑셀 1,021 = 100 % | 같음 100 % | 그대로 | +| 못 읽음 표지 | 0 | 0 | 그대로 | +| 인력 · 형제 · 유가 이음 | 100 % · 100 % · 100 % | 같음 | 그대로 | +| 기계 이음 | 95.7 %(4,601 / 4,807) | 94.9 %(4,564) | 옛 마스터 별칭 다섯이 아직 기계 줄 「별칭」 칸에 안 옮겨짐(815-2 몫) — 옮기면 37 줄 돌아와 95.7 % | +| 재료 이음 | 87.6 %(1,784 / 2,036) | **88.0 %**(1,792) | 표기 맞추기로 8 줄 더 | +| 못 이은 재료 | 252 줄 · 105 종 | **244 줄 · 100 종** | 다섯 종 풀림 | +| 견적 · 운임 · 수수료 줄 | 59 줄 = 「갈래 밖」 못 이음 | **59 줄 = 「견적」 그대로**(못 이음에서 뺌) | 우리 값 있는 자재로 이어진 것 0 | +| [우리 단가로] 모두 우리 값 | 원파일 1,899(85.5 %) · 엑셀 814(79.7 %) | 원파일 1,853(83.5 %) + 견적만 남음 19 · 엑셀 815(79.8 %) + 12 | 아래 「줄어든 까닭」 | +| [우리 단가로] 0 원 줄 있는 블록 | 원파일 115 · 엑셀 4 | 원파일 155 · 엑셀 9 | 아래 「줄어든 까닭」 | +| 차림 왕복 · 시험 | 100 % | 100 %(812-3 · 810-2 · 810-3 통과) | 그대로 | + +### 「모두 우리 값」이 줄어든 까닭(블록 옮김 전수) + +| 옮김 | 블록 | 까닭 | +|---|---|---| +| 우리 값 → 엑셀 단가 남음 | 30 | 아스팔트페이버(피니셔) 29 · 버킷준설기 1 — 옛 마스터 별칭 장부를 안 읽게 됨 · 815-2 가 다섯을 기계 줄 칸으로 옮기면 그대로 돌아옴 | +| → 0 원 줄 | 45 | 치즐(0.7㎥) 39 · 치즐(0.4㎥) 6 — 전에는 「바탕 이름」 길이 **엉뚱한 치즐 0.2㎥ 줄**로 이어 값이 있어 보였음(지어 맞추기) · 이제 정확히 같은 치즐 0.7 · 0.4 줄로 잇는데 그 줄 값 칸 다섯이 빔(EST DB 자체에 값 없음 · 비고에만 「가격정보 223,000」) = 자재표 보강(J 장) 대상 | +| 엑셀 단가 남음 → 우리 값 | 4 | 표기 맞추기 | + +## K1 EST 원파일 자재 = 참조 코드 + +| 원파일 | 자재 자원 | 공사 직접 번호(M0000000…) | 우리 표 코드와 같음 → 그 줄로 이음 | +|---|---|---|---| +| main.estx(EST DB 판 그 자체) | 23,980 | 8 | **23,965 / 23,965**(다른 줄 0) | +| 거창 · 장수 · 진안 · 임도용역 · 통영 | 239 | **235** | 0 — 나머지 4 도 우리 표에 없는 코드(아연도철선 M0004113991 …) | + +- 찾은 것: 방향 장부 G4 의 「24,219 중 23,981(99 %)」은 거의 다 main.estx(우리 자재표를 만든 바로 그 EST DB 파일) 몫 · **실제 공사 파일 다섯의 자재는 99 % 가 공사가 매긴 직접 번호** → 코드로 잇는 몫 0 · 이름 · 별칭 길로 이음 +- 공사 직접 번호는 파일마다 다른 품목(거창 「치즐(0.7㎥)」 = M0000000001 ↔ 우리 표 M0000000001 = 「Air Hammer」) → 코드 잇기에서 뺌(시험에 굳힘) +- 화면: 거창 .estx 단산10 줄떼심기 = 천연잔디 이름 길로 이음 · 이음 없는 자원 0 + +## K2 「별칭」 칸 읽기(풀이 정본 `master_alias.py` · 잇기 `M02_Elem_Alias.py`) + +- 풀이 = 콤마 · 전각 콤마 「,」 · 큰따옴표 조각 통째(속 콤마 보존) · 앞뒤 빈칸 뗌 · 같은 것 한 번 +- 뜻 = 이름만 적은 별칭 → 가져온 규격이 그 줄 규격과 정규화해 같을 때 · 「이름 규격」을 적은 별칭 → 통째 같을 때 · 같은 별칭이 여러 줄 = 같은 품목 지역 줄이면 줄 지역 › 서울 · 아니면 「후보 여럿」 +- 읽는 곳(모두 같은 길) = 단가산출 불러오기 재료 · 인력 · 유가 · 기계 찾기 · 일위대가 호표 줄(자재 · 노임 · 기계) +- 옛 층 별칭 장부를 요소 잇기에서 읽는 곳 걷음 — 기계 찾기 · 호표 자재 짝 · 단가산출 호표(장부 import 뗌) +- 남긴 곳(815-2 몫) — 기계 [저장] 때 고른 짝을 옛 장부에 쓰는 `M02_Machine_Find.learn` · 일위대가 [확정]의 장부 쓰기(`M02_Hopyo_Build`) · 장부 화면 · 길 +- 시험(임시 별칭): 「"크레인(트럭) 15Ton"」 → 그 크레인 · 「아스팔트페이버(피니셔)」 → 규격으로 피니셔 3m · 「결속선(철선)」(서울 · 부산 줄) + #20, 0.9M/M → 서울 줄 · 규격 #8 = 안 이음 · 「특인」 → 특별인부 · 「디젤유」 → 경유 전국평균 · 같은 별칭 두 기계 = 후보 여럿 + +## K3 재료 표기 맞추기 + +- 정규화 = M/M · m/m · ㎜ = mm · 곱셈표 × · x · X · * · 지름 Φ · φ · ø · D= · 괄호 · 빈칸 · 전각 · # · 지역 꼬리(「, 서울」) — 정확히 같을 때만(가까운 규격 대신 잇지 않음) · 낱말 순서 뒤집힘(결속선(철선) ↔ 보통철선(결속선))은 별칭 몫 +- 정확 일치를 옛 「바탕 이름」 길보다 먼저 봄(엉뚱한 치즐 0.2 잇기 바로잡음) +- 풀린 다섯 종 = PVC파이프(일반관),서울 D=100mm(VG1) · 강관동바리 Φ48.6x2.4,L=2.6~4.0 · 보통철선(결속선) #20 , 0.9M/M · 산림종자(비수리) · 산림종자(안고초) +- 남은 못 이은 재료 244 줄 · 100 종 = 후보 여럿 230 · 자재표에 없음 14 → 목록 「못 이은 줄」 시트(가져온 이름 · 규격 · 쓰임 수 · 가까운 우리 줄 셋 · 넣을 별칭 예) + +## K5 견적 줄 + +- 대상 = 원파일 EST 견적(국가지점번호판 · 측량 수수료 · 도선료) · STmate · 엑셀 고정 코드 줄(구역화물 · 하차비 · 덤프자동덮개 · 치즐 견적) — 59 줄 +- 우리 자재에 이어지고(코드 · 별칭 · 정확 일치) **우리 값이 있으면** 재료 줄(우리 값 · 엑셀 값은 검산) · 아니면 그대로(이름 · 값) + 표지 「견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님」 · 문서 줄 설명 같은 글 · 화면 「견적 n」 배지(「이음 없는 자원」 수에서 뺌) +- 지금 59 줄 모두 그대로 — 치즐(0.7m3) 견적 5 줄은 우리 치즐 0.7 줄이 있으나 값 칸이 비어 견적 값 유지(0 원 막음) + +## 화면(8005 · 5178 · 일반 계정 개인 층 · 「sub3임시_거창.estx」) + +- 단산22 국가지점번호판 → 띠 「견적 2」(파랑) · 「국가지점번호판 (견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님) · 이음 고르기」 · 검산 같음 · 이음 없는 자원 배지 없음 +- 밝음 · 어두움 · 1366×768 가로 넘침 0 · 콘솔 오류 0(로그인 전 세션 401 하나 뺌) · ORCA 자기 탭 확인 · 사진 = tmp(셋) +- 만든 SK 셋 · 올린 HP 둘 지움(같은 계정 다른 창이 만든 SK-000004 ~ 7 은 그대로) + +## 남은 흠 · 알림 + +| # | 내용 | 갈래 | +|---|---|---| +| 1 | 옛 마스터 기계 별칭 다섯이 칸으로 옮겨지기 전까지 기계 37 줄 · 30 블록 못 이음 | 815-2 옮김 뒤 풀림(시험은 칸이 비면 다섯을 임시로 넣어 봄) | +| 2 | 치즐 0.7 · 0.4 등 우리 자재 줄 값 칸 빔 — 정확히 이으니 0 원 줄 102 → 153 | 자재표 보강(J 장 · 값 출처 미정) | +| 3 | 견적 값묶음 줄 공식 칸에 「[object Object]」 글(814-1 전부터 · 값묶음 줄 꼴) | 화면 흠 · 이번 고침 밖 | +| 4 | test_778_5 일위대가 기계 줄 표(이어짐 162 · 고를 것 3 · 못 이음 25)가 굳힌 수(180 · 10 · 0)와 다름 — 고치기 전 판도 같은 수(812-9 뒤부터) | 낡은 굳힘 수 · 이번 고침 무관 |