diff --git a/M02_MasterTemplete/M02_Hopyo_MatSame.py b/M02_MasterTemplete/M02_Hopyo_MatSame.py new file mode 100644 index 000000000..32fb4fac3 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_MatSame.py @@ -0,0 +1,256 @@ +"""M02 불러오기 재료 표기 다름 찾기(PLAN 817-1 · 요구 773-5) — `M02_Hopyo_Row._m01_picks` 재료 길이 부름. + +서버 단독 · 읽기만 · 별칭 · 정확 일치 · 바탕 이름 유일 짝(`M02_Hopyo_Material.sure`)이 못 찾은 줄만. +- `same(줄)` ㉮ 이음 = 이름이 같은 품목(표기만 다름) · 규격 수 묶음 같음 · 규격 낱말 안 부딪힘 · 그런 우리 + 품목 하나(지역 · 관급/사급 줄은 한 품목) → 그 자재 줄 · 아니면 None. +- `narrow(줄)` ㉯ 후보 좁힘 = 이름 같은 품목 묶음을 규격 낱말(어근 「초본류」 → 초본)을 품은 줄 · 수 묶음 + 닮은 줄 · 덧말 적은 줄 차례로(자동 이음 아님 · 1 순위 맨 위). +- `spec_first(후보, 줄)` = 후보 가운데 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저. + +이름 같음: 바탕(괄호 · 지역 · 관급/사급 꼬리 · 등급 「1급」 뗌 · 빈칸 · 점 「P.V.C」 · 소리 표기 `SPELL`) 같음 · +괄호 차례 「패널(유로폼)」 = 「유로폼패널」(글자 묶음 같음) · 괄호 이름 「서비스볼트(고장력볼트)」 = 고장력볼트 · +(약한 같음 — 수 묶음이 같을 때만) 끝 낱말 같음 「지수판」 ↔ 「PVC지수판」 · 앞 낱말 「구역화물자동차」 ↔ 구역화물 · +규격에 든 이름 「안고초」 ↔ 산림종자 안고초(가져온 규격 빔). +수 묶음: 규격 + 이름 괄호의 수(차례 무관) · KS 번호 · 무게(kg · ㎏/포 · m/kg) · 제곱 꼬리(㎡ · ㎠) 뗌 · +천 단위 쉼표 · 같음 > 낱마다 단위 배(1 · 10 · 100 · 1000 — 3.6×3.0×3.0 m = 30×30×3600 mm) > 한쪽이 다른 쪽에 듦 +(이름 같을 때만 · 덧글 「2000 길이 · 20~30mm」). +규격 낱말 부딪힘 = 두 쪽 다 괄호 · 규격 한글 낱말이 있고 두 글자 묶음이 하나도 안 겹침(「연강용」 ↔ 「주철용」) — +상대 이름에 든 낱말은 뺌. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache +from itertools import permutations +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Material as hmat + +SPELL = ( + (r"칠렌", "틸렌"), + (r"매쉬", "메쉬"), + (r"신나(?![가-힣])", "신너"), + (r"pipe", "파이프"), +) # 소리 · 영문 표기 +NOISE = frozenset({"이내", "이하", "이상", "국산", "포함"}) +SCALES = (1, 10, 100, 1000) +_STEM = re.compile(r"(?<=..)[류형용]$") +_KS = re.compile(r"ks\s*[a-z]?\s*-?\s*\d+") +_SQUARE = re.compile(r"(?:c|m)?m[23](?![\d.])") +_THOUSAND = re.compile(r"(?<=\d),(?=\d{3}(?!\d))") +_NUM = re.compile(r"\d+(?:\.\d+)?") +_PAREN = re.compile(r"\(([^()]*)\)") + + +def _low(text: Any) -> str: + t = unicodedata.normalize("NFKC", str(text or "")).lower().replace("m/m", "mm") + return hmat._APART.sub("", t) + + +def _tidy(text: str) -> str: + t = re.sub(r"(?<=[a-z])[.\s]+(?=[a-z])", "", text) + for a, b in SPELL: + t = re.sub(a, b, t) + t = re.sub(r"\d+급", "", t) + return re.sub(r"[^가-힣a-z0-9]", "", t) + + +def _bag(text: str) -> tuple[float, ...]: + t = _THOUSAND.sub("", _SQUARE.sub("", _KS.sub("", text))) + return tuple(sorted(float(x) for x in _NUM.findall(t))) + + +def _quals(text: str) -> frozenset[str]: + words = (w for w in re.findall(r"[가-힣]{2,}", text) if not hmat._region(w)) + return frozenset(w for w in words if w not in hmat.SUPPLY and w not in NOISE) + + +def _item(name: Any, spec: Any) -> dict[str, Any]: + """이름 · 규격 → 바탕 · 괄호 조각 · 수 묶음 · 규격 낱말 · 온 이름 글.""" + low, spec = _low(name), _low(spec) + parens = _PAREN.findall(low) + pieces = tuple(p for p in (_tidy(x) for x in parens for x in x.split(",")) if p) + core = _tidy(hmat._bare(low)) + return { + "core": core, + "pieces": pieces, + "chars": "".join(sorted(core + "".join(pieces))), + "bag": _bag(f"{spec} {' '.join(parens)}"), + "quals": _quals(f"{' '.join(pieces)} {spec}"), + "whole": core + "".join(pieces), + "spec": _tidy(spec), + } + + +@lru_cache(maxsize=2) +def _book(_got: int) -> dict[str, Any]: + rows, _ = hmat.pool() + out: list[dict[str, Any]] = [] + by: dict[str, dict[str, list[int]]] = { + "core": {}, + "chars": {}, + "piece": {}, + "tail": {}, + "spec": {}, + } + for at, r in enumerate(rows): + one = {**_item(r["one"]["이름"], r["one"]["규격"]), "row": r} + out.append(one) + c = one["core"] + by["core"].setdefault(c, []).append(at) + by["chars"].setdefault(one["chars"], []).append(at) + by["spec"].setdefault(one["spec"], []).append(at) + for p in one["pieces"]: + by["piece"].setdefault(p, []).append(at) + for i in range(1, len(c) - 1): # 끝 낱말(두 글자 이상) + by["tail"].setdefault(c[i:], []).append(at) + return {"줄": out, "찾기": by} + + +def book() -> dict[str, Any]: + return _book(id(hm.targets())) + + +def _related(me: dict[str, Any]) -> dict[int, bool]: + """이름 같은 우리 줄 → {차례: 굳셈(True) · 약함(False)}.""" + by, c = book()["찾기"], me["core"] + strong = [*by["core"].get(c, []), *by["chars"].get(me["chars"], []), *by["piece"].get(c, [])] + if ( + c not in by["core"] + ): # 괄호 이름(「서비스볼트(고장력볼트)」) — 바탕 이름이 우리 표에 없을 때만 + strong += [i for p in me["pieces"] for i in by["core"].get(p, [])] + weak = ( + [*by["tail"].get(c, [])] if len(c) >= 2 else [] + ) # 우리 이름 끝 = 가져온 이름(「PVC지수판」) + for k in range( + 2, len(c) - 1 + ): # 우리 이름 = 가져온 이름 앞(「구역화물자동차」) · 뒤 덧말이 우리 낱말 아님 + if c[k:] not in by["core"] and c[k:] not in by["tail"]: + weak += by["core"].get(c[:k], []) + out = dict.fromkeys(weak, False) + out.update(dict.fromkeys(strong, True)) + return out + + +def _bigrams(words: frozenset[str]) -> set[str]: + return {w[i : i + 2] for w in words for i in range(len(w) - 1)} + + +def _qual_fit(me: dict[str, Any], x: dict[str, Any]) -> tuple[bool, bool, bool]: + """(부딪힘, 겹침, 낱말 있음) — 상대 온 이름에 든 낱말은 뺌.""" + a = frozenset(w for w in me["quals"] if _tidy(w) not in x["whole"]) + b = frozenset(w for w in x["quals"] if _tidy(w) not in me["whole"]) + share = bool(_bigrams(a) & _bigrams(b)) + return bool(a and b and not share), share, bool(a or b) + + +def _near(x: float, y: float) -> bool: + return any(abs(x * s - y) <= 1e-6 * max(abs(y), 1) or abs(y * s - x) <= 1e-6 * max(abs(x), 1) for s in SCALES) # fmt: skip + + +def num_fit(a: tuple[float, ...], b: tuple[float, ...]) -> tuple[int, int]: + """(갈래 3 같음 · 2 단위 배 · 1 한쪽이 듦 · 0 다름, 맞은 수 개수).""" + if a == b: + return 3, len(a) + if a and len(a) == len(b) and len(a) <= 6: + if any(all(_near(x, y) for x, y in zip(a, p)) for p in permutations(b)): + return 2, len(a) + small, big = sorted((list(a), list(b)), key=len) + if small and len(small) < len(big): + rest = list(big) + for x in small: + if x not in rest: + return 0, 0 + rest.remove(x) + return 1, len(small) + return 0, 0 + + +def _fold(x: dict[str, Any]) -> tuple: + r = x["row"] + return r["base"], r["key"][0], r["key"][2] + + +def _scored(line: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[tuple, dict[str, Any]]]]: + me = _item(line.get("이름"), line.get("규격")) + rows = book()["줄"] + out = [] + for at, strong in _related(me).items(): + x = rows[at] + clash, share, words = _qual_fit(me, x) + if not clash: + out.append( + ((strong, share, *num_fit(me["bag"], x["bag"]), words, x["core"] == me["core"]), x) + ) + return me, out + + +def _pick(group: list[dict[str, Any]], line: dict[str, Any]) -> dict[str, Any]: + """한 품목의 변형 줄들 → 하나(줄 관급/사급 › 줄 지역 › 서울 › 같은 단위 › 엑셀 차례 — `M02_Elem_Alias.one` 과 같은 차례).""" + supply, regions = hmat._wants(line) + unit = hm.mm.plain_unit(line.get("단위")) + + def rank(r: dict[str, Any]) -> tuple: + return ( + supply is not None and r["supply"] not in (None, supply), + bool(regions) and not hmat._hit(r, regions), + "서울" not in r["region"], + r["key"][1] != unit, + r["at"], + ) + + return min((x["row"] for x in group), key=rank)["one"] + + +def same(line: dict[str, Any]) -> dict[str, Any] | None: + """㉮ 표기만 다름 — 우리 품목 하나면 그 자재 줄(`hm.targets` 꼴) · 아니면 None.""" + me, scored = _scored(line) + if not me["spec"]: # 가져온 규격 빔 = 규격에 든 이름(「안고초」)만 · 그 밖은 ㉯ + hits = [book()["줄"][i] for i in book()["찾기"]["spec"].get(me["whole"], [])] + hits = [x for x in hits if not x["bag"] and not me["bag"]] + return _pick(hits, line) if hits and len({_fold(x) for x in hits}) == 1 else None + ok = [] + for (strong, share, tier, common, words, same_core), x in scored: + if not tier or (tier == 1 and not strong): + continue + if not me["bag"] and not x["bag"] and not share and words: + continue # 수 없는 규격 = 규격 낱말이 겹쳐야 + ok.append(((tier, common, share, strong, same_core), x)) + if not ok: + return None + best = max(k for k, _ in ok) + group = [x for k, x in ok if k == best] + return _pick(group, line) if len({_fold(x) for x in group}) == 1 else None + + +def _stems(me: dict[str, Any]) -> set[str]: + return {_STEM.sub("", w) for w in me["quals"]} + + +def narrow(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: + """㉯ 이름 같은 품목 묶음(굳센 같음) → 규격 낱말 어근 품은 줄 › 수 묶음 닮음 › 덧말 적음 차례(품목마다 하나).""" + me, scored = _scored(line) + stems = _stems(me) + rows = [(k, x) for k, x in scored if k[0]] + rank = [] + for (_strong, _share, tier, common, _words, same_core), x in rows: + text = x["whole"] + x["spec"] + hit = sum(s in text for s in stems) + rank.append(((-hit, -tier, -common, not same_core, len(x["spec"]), x["row"]["at"]), x)) + seen, out = set(), [] + for _k, x in sorted(rank, key=lambda t: t[0]): + if (f := _fold(x)) not in seen: + seen.add(f) + out.append(_pick([y for _, y in rank if _fold(y) == f], line)) + return out[:limit] + + +def spec_first(cands: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: + """후보 차례 — 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저(「중하중그레이팅 395×995×52」).""" + mine = _item(line.get("이름"), line.get("규격"))["bag"] + if len(mine) < 2: + return cands + return sorted(cands, key=lambda c: _item(c.get("이름"), c.get("규격"))["bag"] != mine) diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index f139d8e1f..9f4f24a20 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -103,7 +103,7 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[ clean, apart = hmat.clean({"이름": name, "규격": spec, "단위": unit}) sure, _ = hmat.sure(clean) got = [r["one"] for r in sure[:1] if not r["one"].get("전국")] - got = got or hmat.candidates(clean, TOP) + got = got or _found(clean) more = {"별도계상": True} if apart else {} return [ pick("재료", x.get("자재"), x["이름"], x.get("규격"), x.get("단위"), **more) @@ -113,6 +113,23 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[ return [] +def _found(line: dict[str, Any]) -> list[dict[str, Any]]: + """817-1 재료 — ㉮ 표기만 다름 하나 › 옛 후보 하나 › ㉯ 이름 같은 묶음 좁힘 + 규격 수 같은 후보 먼저 + 옛 후보.""" + from M02_MasterTemplete import M02_Hopyo_MatSame as ms + + if one := ms.same(line): + return [one] + cands = hmat.candidates(line, 4 * TOP) + if len({x.get("자재") for x in cands[:TOP]}) == 1: + return cands[:TOP] + out, seen = [], set() + for x in [*ms.narrow(line, TOP), *ms.spec_first(cands, line)[:TOP], *cands[:TOP]]: + if x.get("자재") not in seen: + seen.add(x.get("자재")) + out.append(x) + return out + + def _alias(kind: str, name: str, spec: str) -> list[dict[str, Any]]: """M01 별칭 칸 이음(`M02_Elem_Alias`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 · 같은 품목 지역 줄은 하나.""" diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 43731d4d8..986f4e0b2 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -9,6 +9,7 @@ from __future__ import annotations NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머리) OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값) QUOTE = "견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님" # 815-1 K5 · 요구 762-3 +APART = "별도계상 · 품목은 프로젝트에서" # 817-1 — 일위대가 길(`M02_CheckApart`)과 같은 글 · 요소 잇기 없음 def m01(kind: str, src: dict) -> tuple[str | None, str | None]: @@ -24,6 +25,10 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: from M02_MasterTemplete import M02_Hopyo_Row as hrow line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} + if kind == "재료" and apart( + line + ): # 817-1 — 원가 합 밖(원본 금액 그대로) · 품목은 프로젝트에서 이음 + return None, f"{NOTE}{APART}" if ( kind == "기계" ): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음) @@ -68,6 +73,13 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: return None, None +def apart(line: dict) -> bool: + """줄 이름 · 규격에 「별도계상」(`M02_Hopyo_Material.clean`).""" + from M02_MasterTemplete import M02_Hopyo_Material as hmat + + return hmat.clean(line)[1] + + def quote(src: dict) -> tuple[str | None, str | None]: """견적 · 운임 · 수수료 꼴 줄(갈래 밖 — EST 견적 · STmate 고정 코드 · 815-1 K5) → 우리 자재에 이어지면 (코드 · 별칭 · 정확 일치 · 단가 줄 · 우리 값 있을 때) 그 키 + 갈래 재료(우리 값 · 엑셀 값은 검산) · @@ -143,11 +155,13 @@ def why(item: dict) -> str: from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Row as hrow + if apart({"이름": name, "규격": res.get("규격") or ""}): + return APART if elem.oil_rows(name): return "유가 후보 여럿 — 고름" line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""} - if hrow.m01_picks("재료", line): - return "자재 후보 여럿 — 고름" + if picks := hrow.m01_picks("재료", line): # 817-1 ㉯ 1 순위를 글에(고르기 창은 이름 찾기) + return f"자재 후보 여럿 — 고름 · 1 순위 {picks[0]['이름']} {picks[0]['규격']}".strip() return "운임표 없음" if any(w in name for w in FREIGHT) else "자재 없음(우리 자재표)" if kind == "인력": return "노임 없음" diff --git a/resources/tester/test_817_1_material_find.py b/resources/tester/test_817_1_material_find.py new file mode 100644 index 000000000..768b8bc2d --- /dev/null +++ b/resources/tester/test_817_1_material_find.py @@ -0,0 +1,192 @@ +"""817-1 불러오기 재료 찾기 — 표기만 다름 ㉮ 이음 · ㉯ 후보 좁힘 · 틀린 이음 막기 · 별도계상 표지(요구 773-5 · 774). + +과녁 = 사용자 시트 「수동연결(자재)」 100 줄(브레인 과녁 `_과녁_773_자재표기.tsv` 를 우리 자재표로 다시 확인한 결과). +「후보」 줄 키 = 1 순위를 굳힌 줄만(종자 · 그레이팅 · 표기만 다름인데 수 묶음 · 이름이 안 맞아 ㉯ 로 둔 줄) · None = 이음만 안 됨. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "master_data" / "scripts")] + +from M02_MasterTemplete import M02_Hopyo_MatSame as ms # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Row as hrow # noqa: E402 +from M02_MasterTemplete import M02_San_Link as sl # noqa: E402 + +TABLE = [ + ("접착제", "", "후보", None), # 1 + ("패널(유로폼)", "600*1200mm", "이음", "MT000467"), # 2 + ("각재", "외송", "후보", "MT000393"), # 3 + ("요소", "", "후보", "MT006763"), # 4 + ("내부코너패널(유로폼)", "(100x200)x1200mm", "이음", "MT000470"), # 5 + ("결속선(철선)", "#20 , 0.9M/M", "후보", None), # 6 + ("각재 (외송)", "", "후보", "MT000393"), # 7 + ("파형강관", "별도계상", "후보", None), # 8 + ("금강사", "", "없음", None), # 9 + ("고무테이프", "0.5mm×19mm×10m", "이음", "MT009202"), # 10 + ("낙석방지책", "H2500×W2000(표준)", "후보", None), # 11 + ("낙석방지책", "H2500×W2000(단부)", "후보", None), # 12 + ("종자", "초본류", "후보", "MT006646"), # 13 + ("종자", "목본류", "후보", "MT006648"), # 14 + ("어닐링철선(결속선)", "#20 0.9mm 200.4m/kg", "이음", "MT000209"), # 15 + ("내수합판1급", "12mmx1220x2440", "이음", "MT000018"), # 16 + ("어닐링철선(결속선)", "#20 0.9M/M", "이음", "MT000209"), # 17 + ("유로폼(패널)", "600*1200", "이음", "MT000467"), # 18 + ("내부코너패널", "(200+200)*1200", "이음", "MT000472"), # 19 + ("합판(내수)", "12×1220×2440", "이음", "MT000018"), # 20 + ("조임철물", "(직교자재)", "이음", "MT000499"), # 21 + ("에폭시수지", "YD-128", "후보", None), # 22 + ("종자", "생태복원형,초본류", "후보", "MT006646"), # 23 + ("종자", "생태복원형,목본류", "후보", "MT006648"), # 24 + ("내부코너패널(유로폼)", "(100+200)*1200mm", "이음", "MT000470"), # 25 + ("유로폼(패널)", "600×1200", "이음", "MT000467"), # 26 + ("콘테이너하우스", "창고용(3.0m×6.0m×2.6m)", "이음", "MT007111"), # 27 + ("전기뇌관(MS)", "3.5m×500개(하이데토)", "이음", "MT005677"), # 28 + ("BUTTON BIT", "Ø38×76㎜", "후보", None), # 29 + ("유압드릴샹크", "T38×CM470", "후보", None), # 30 + ("유압식드릴스리브", "T38×190mm", "후보", "MT005687"), # 31 + ("미송(판재)", "", "후보", None), # 32 + ("산림종자", "참싸리(박피)", "이음", "MT006664"), # 33 + ("유기질퇴비", "", "후보", None), # 34 + ("PVC지수판", "200x5T KSM3805", "이음", "MT006544"), # 35 + ("PVC용접봉", "Ø3.2 (국산)", "후보", None), # 36 + ("PVC파이프(일반용VG2)", "50㎜ × 4m", "이음", "MT002926"), # 37 + ("혼합골재", "영월", "후보", None), # 38 + ("이형철근", "별도계상", "후보", None), # 39 + ("안전표지판", "삼각,D=900", "후보", "MT003202"), # 40 + ("표지판지주", "D=76.3mm, 3.5M", "후보", None), # 41 + ("블레이드(커터)", 'd=320 400mm 14"xt3.2', "이음", "MT005749"), # 42 + ("떼", "", "없음", None), # 43 + ("강관비계", "φ48.6m/m×2.3mm", "이음", "MT000481"), # 44 + ("철물", "", "후보", None), # 45 + ("각재(말뚝)", "Φ60", "후보", None), # 46 + ("강관써포트", "V4 2600-4000mm 14.3kg", "후보", None), # 47 + ("안고초", "", "이음", "MT006667"), # 48 + ("비수리", "", "이음", "MT006665"), # 49 + ("내수합판1급", "12m/m 1220×2440", "이음", "MT000018"), # 50 + ("각재(외송)", "3.6×3.0×3.0", "이음", "MT000393"), # 51 + ("내수합판1급", "12mm 1220×2440", "이음", "MT000018"), # 52 + ("각재(거푸집용,외송)", "3.6×3.0×3.0", "이음", "MT000393"), # 53 + ("다이아몬드톱날", 'Ø14"×3.2t(콘크리트용)', "후보", None), # 54 + ("아세틸렌", "용접용", "후보", "MT003163"), # 55 + ("산소", "고압가스", "이음", "MT003166"), # 56 + ("신나", "KSM6060 2종(조합페인트류)", "이음", "MT003398"), # 57 + ("초류종자(국내산)", "참싸리,비수리,낭아초등", "후보", None), # 58 + ("종 자", "초본형", "후보", "MT006646"), # 59 + ("소나무우드칩(멀칭재)", "", "후보", None), # 60 + ("3단코어비트", '5"(125mm)', "후보", None), # 61 + ("P V C PIPE", "Φ50M/M,VG1", "이음", "MT002861"), # 62 + ("콘테이너(창 고)", "3×6×2.6", "이음", "MT007111"), # 63 + ("강관비계", "48.6mm×2.3mm×6m", "후보", None), # 64 + ("철물", "직교자재", "이음", "MT000499"), # 65 + ("문양거푸집(ABS코팅품)", "600×1200", "이음", "MT000453"), # 66 + ("와이어매쉬", "#6 100×100", "이음", "MT007136"), # 67 + ("지수판", "T5×200", "이음", "MT006544"), # 68 + ("용접봉(연강용)", "3.2m/m", "후보", None), # 69 + ("어닐링철선 (결속선)", "#6 4.0mm 10.1m/kg", "후보", None), # 70 + ("구역화물자동차(4.5Ton)", "80km이내", "이음", "MT023493"), # 71 + ("구역화물자동차(4.5Ton)", "60km이내", "이음", "MT023491"), # 72 + ("산소(6000ℓ)", "99%,120kg/㎠", "이음", "MT003165"), # 73 + ("아세칠렌", "AC 98%,용접용", "이음", "MT003163"), # 74 + ("서비스볼트(고장력볼트)", "M20×60", "이음", "MT002536"), # 75 + ("보조강재(열연강판)", "T=9-12", "후보", None), # 76 + ("구역화물자동차(8Ton)", "10km이내", "이음", "MT023654"), # 77 + ("구역화물자동차(8Ton)", "20km이내", "이음", "MT023655"), # 78 + ("참싸리(박피)", "", "없음", None), # 79 + ("부산물비료", "20kg/포", "후보", None), # 80 + ("참싸리", "박피", "없음", None), # 81 + ("복합비료", "21-17-17 20kg/포", "이음", "MT006490"), # 82 + ("퇴비(천연부엽토)", "20㎏/포", "후보", None), # 83 + ("문양거푸집(ABS코팅폼)", "600×1200×20~30mm", "이음", "MT000453"), # 84 + ("P.V.C PIPE", "VG1(50mm)", "이음", "MT002861"), # 85 + ("다웰바(DOWEL BAR)", "Ø32×1,000", "이음", "MT006540"), # 86 + ("P.V.C PIPE", "VG1(35mm)", "이음", "MT002859"), # 87 + ("휠라", "T=15mm,1000×1000mm", "없음", None), # 88 + ("측구수로관", "300×300×2000×120,앵글포함", "이음", "MT015883"), # 89 + ("중하중그레이팅", "395×995×52", "후보", "MT007025"), # 90 + ("측구수로관", "500×500×2000×150,앵글포함", "이음", "MT015887"), # 91 + ("중하중그레이팅", "595×995×67", "후보", "MT007032"), # 92 + ("잔디", "0.18×0.18×0.01", "이음", "MT006796"), # 93 + ("강관비계", "4000×48.6mm×2.3mm", "이음", "MT000481"), # 94 + ("삼각표지판", "삼각,900mm", "후보", None), # 95 + ("표지판지주", "D=76.3mm×3.0m", "이음", "MT003241"), # 96 + ("공사안내표지판", "300×1500", "후보", None), # 97 + ("공사안전PE드럼", "500×800", "후보", "MT003311"), # 98 + ("윙카호스", "10cm", "후보", None), # 99 + ("조합페인트", "KSM6020 2급,백색", "이음", "MT003372"), # 100 +] + + +def _link(name, spec): + key, mark = sl.m01("재료", {"이름": name, "규격": spec, "단위": "", "갈래": "재료"}) + return key, mark, hrow.m01_picks("재료", {"이름": name, "규격": spec, "단위": ""}) + + +@pytest.mark.parametrize("name,spec,want,key", TABLE) +def test_target_rows(name, spec, want, key): + got, _mark, picks = _link(name, spec) + if want == "이음": + assert got == key + else: + assert got is None + assert bool(picks) == (want == "후보") + if key: + assert picks[0]["키"] == key + + +def test_counts(): + linked = sum(1 for *_x, w, _k in TABLE if w == "이음") + assert linked == 51 + + +@pytest.mark.parametrize( + "name,spec,bad", + [ + ("용접봉(연강용)", "3.2m/m", "MT003195"), # 연강용 ↔ 주철용 + ("PVC용접봉", "Ø3.2 (국산)", "MT003195"), # PVC 용접봉 ↔ 용접봉 + ("어닐링철선 (결속선)", "#6 4.0mm 10.1m/kg", "MT000199"), # #6 ↔ #8 + ("잔디", "0.18×0.18×0.01", "MT019017"), # 0.01 ↔ 0.03 + ("표지판지주", "D=76.3mm, 3.5M", "MT003241"), # 3.5M ↔ 3000 + ("신나", "KSM6060 2종(조합페인트류)", "MT018835"), # 신나 ↔ 신나무 + ("강관비계", "48.6mm×2.3mm×6m", "MT000481"), # 6m ↔ 4.0m + ], +) +def test_no_wrong_link(name, spec, bad): + got, _mark, picks = _link(name, spec) + assert got != bad + if name == "신나": + assert got == "MT003398" # 신너(조합페인트용) + + +def test_seed_narrow(): + assert [x["자재"] for x in ms.narrow({"이름": "종자", "규격": "초본류"})][:3] == [ + "MT006646", + "MT006647", + "MT006649", + ] + + +def test_spec_forms(): + assert ms.num_fit(ms._bag("3.6×3.0×3.0"), ms._bag("30×30×3600mm"))[0] == 2 + assert ms.num_fit(ms._bag("T5×200"), ms._bag("200×5T"))[0] == 3 + assert ms.num_fit(ms._bag("Ø32×1,000"), ms._bag("Φ32*1000"))[0] == 3 + assert ms._item("PVC지수판", "200x5T KSM3805")["bag"] == ms._item("PVC지수판", "200×5T")["bag"] + assert ms._item("유로폼(패널)", "")["chars"] == ms._item("유로폼패널", "")["chars"] + assert ms._item("P.V.C PIPE", "")["core"] == ms._item("PVC파이프(일반관),서울", "")["core"] + assert ms._item("아세칠렌", "")["core"] == "아세틸렌" + + +def test_apart_mark(): + key, mark, _ = _link("파형강관", "별도계상") + assert key is None and mark == f"{sl.NOTE}{sl.APART}" + assert ( + sl.why({"자원": {"갈래": "재료", "이름": "철근(별도계상)", "규격": "별도계상"}}) == sl.APART + ) + + +def test_why_shows_first(): + got = sl.why({"자원": {"갈래": "재료", "이름": "종자", "규격": "초본류"}}) + assert got == "자재 후보 여럿 — 고름 · 1 순위 종 자 초본위주형"