feat(815-1 K1·K2·K3·K5): 불러오기 이음 — EST 코드 · 별칭 칸 · 표기 맞추기 · 견적 줄

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 14:36:58 +09:00
co-authored by Claude Opus 5.5
parent 55ad1bfee0
commit 030a843f07
13 changed files with 449 additions and 129 deletions
+4 -1
View File
@@ -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["변수"]
+10 -1
View File
@@ -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))
+106
View File
@@ -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, "")
+19
View File
@@ -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(같은 이름 · 참고) ────────────────────────────────────────
+2 -4
View File
@@ -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:
+21 -13
View File
@@ -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("이름"))
+19 -48
View File
@@ -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]["키"]:
@@ -61,7 +61,7 @@ interface EditAnswer {
문서: ImportedDoc;
전?: Money4 | null;
후?: Money4 | null;
이음없음?: { 자리: string; 이름: string; 까닭?: string }[];
이음없음?: { 자리: string; 이름: string; 까닭?: string; 견적?: boolean }[];
/** qty 답(797-2) */
줄?: QtyRow[];
집계?: QtyTally;
@@ -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 });
+29 -31
View File
@@ -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:
+3 -1
View File
@@ -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("참조")
]
+24 -26
View File
@@ -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:
+202
View File
@@ -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 # 거창 앞 스무 블록 자재 줄 셋 중 코드 같음 둘