feat(817-1): 불러오기 재료 찾기 — 표기만 다름 자동 이음 · 같은 이름 후보 좁힘 · 별도계상 표지

- 새 M02_Hopyo_MatSame — ㉮ 이름 같은 품목(빈칸 · 괄호 차례 · 등급 · 점 · 소리 표기 넷) + 규격 수 묶음 같음(차례 · 단위 배 · 한쪽 듦) + 규격 낱말 안 부딪힘 + 우리 품목 하나 → 이음 · ㉯ 이름 같은 묶음 = 규격 어근 품은 줄 · 덧말 적은 줄 1 순위 · 규격 수 같은 후보 먼저
- M02_Hopyo_Row 재료 길 = 바탕 이름 유일 짝 뒤 ㉮ › 옛 후보 하나 › ㉯ + 옛 후보
- M02_San_Link — 별도계상 줄 = 요소 잇기 없음 + 표지 「별도계상 · 품목은 프로젝트에서」 · 못 이은 재료 까닭에 1 순위 이름 · 규격
- 전수(원파일 2,220 · 엑셀 1,021) 재료 이음 88.0 → 93.5 % · 새 이음 112 줄 51 종 · 끊긴 이음 0 · 금액 그대로 · 치즐 0.7 이 엉뚱한 0.2 줄에서 0.7 줄로 옮김
- 시험 test_817_1_material_find(과녁 100 줄 · 틀린 이음 막기 · 별도계상 표지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 20:24:56 +09:00
co-authored by Claude Opus 5.5
parent 98ba72319f
commit bb243af270
4 changed files with 482 additions and 3 deletions
+256
View File
@@ -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)
+18 -1
View File
@@ -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`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 ·
같은 품목 지역 줄은 하나."""
+16 -2
View File
@@ -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 "노임 없음"
@@ -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 순위 종 자 초본위주형"