feat(master_data): 벤치플륨관 · 소형고압블록 · 무게수로관 조달청 품명으로 짝 — 관급 못 채움 495 → 355

- 벤치플륨관 = 조달청 「벤치플룸관」 (「플룸」 조회 2,640줄 받음) · 모델 · 가로×세로 · 종별로 짝
- 소형고압블록 = 보차도용콘크리트블록 · 형 · 두께 · 색으로 짝 · 무게수로관 = 모델명 · 치수 글자 일치
- 「전지역(제주 제외)」 줄과 「대구경북」 식 붙여 쓴 두 도 지역 읽음
- 설계 문서 대조표 (채움 4,197 · 맞음 3,461) · 시험 16건

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-20 20:09:01 +09:00
co-authored by Claude Sonnet 5
parent 572fb8e5ee
commit 465574a189
7 changed files with 337 additions and 240 deletions
@@ -78,6 +78,7 @@ JOBS = [ # 분류명은 부분 글자 조회 — 「철근」은 암거블록
(THPTY, "부직포"),
(MAS, "경질폴리염화비닐관"),
(THPTY, "경질폴리염화비닐관"),
(MAS, "플룸"), # 벤치플룸관 — 분류 「배수로」 조회에는 안 나옴
]
# 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 MB)
KEEP = (
@@ -18,7 +18,8 @@ FAMILIES = (
("조경석", r"^조경석", ["MASCntrct_조경석"]),
("골재", r"^도로용혼합골재", ["MASCntrct_골재"]),
("시멘트", r"^시멘트", ["MASCntrct_시멘트"]),
("수로관", r"^측구수로관", ["MASCntrct_배수로", "ThptyUcntrct_배수로"]),
("수로관", r"^(측구수로관|무게수로관-)", ["MASCntrct_배수로", "ThptyUcntrct_배수로"]),
("벤치플룸", r"^벤치플륨관(?!뚜껑)", ["MASCntrct_플룸"]), # 조달청 표기 「벤치플룸」
(
"블록",
r"^(소형고압블록|생태옹벽블록|생태어소블록)",
@@ -41,16 +42,11 @@ FAMILIES = (
# 조달청에 그 품목·규격이 없거나 다산 규격으로는 물건을 가릴 수 없는 것 — 「관급」 못 채움의 까닭 글
ABSENT = (
(
r"^벤치플륨",
"조달청 종합쇼핑몰에 없음 — 벤치플륨 품명 없음(수로관 유사품이 같은 물건인지 확인 못 함)",
r"^(벤치플륨관뚜껑|무게수로관뚜껑)",
"짝 못 지음 — 조달청 뚜껑은 (부품) 치수 표기가 달라 다산 규격과 안 맞음",
),
(r"^무게수로관", "조달청 종합쇼핑몰에 없음 — 무게수로관 품명 없음"),
(r"^HI-VP", "조달청 종합쇼핑몰에 없음 — 삼중벽 품명 없음(단층 HIVG 만 있음)"),
(r"^KP 접합부속", "조달청 종합쇼핑몰에 없음 — 접합부속 세트 품명 없음"),
(
r"^소형고압블록",
"조달청 종합쇼핑몰에 없음 — 소형고압블록 품명 없음(보차도용콘크리트블록은 형·색 표기가 달라 안 지음)",
),
(
r"^(PE직관\(수도용\)|PE이중벽)",
"짝 못 지음 — 조달청은 압력·강성 등급별 값인데 다산 규격에 등급 없음",
@@ -110,9 +106,32 @@ def tail_of(name: str) -> str | None:
return "제주" if ", 제주" in name else None
SHAPE = {
"I": "200x100",
"U": "220x110",
} # 소형고압블록 형 → 가로×세로 (조달청 「I형」 200×100 · 「U형」 220×110 줄에서)
COLORS = ("적색", "녹색", "회색", "갈색", "고동색", "백색", "청색", "황색", "흑색", "컬러")
def _small_block(spec: str, color: str) -> bool:
"""소형고압블록 줄인지(잡초방지 뺌) · 색이 맞는지 — 회색은 색 안 적힌 줄도(기본 색)."""
if "소형고압블록" not in spec or "잡초방지" in spec:
return False
if color in spec:
return True
return color == "회색" and not any(c in spec for c in COLORS)
def find(row: dict, raw: dict, places, pick, PROVINCE=frozenset()):
"""(줄, 방식) 또는 (None, 까닭) — `places` · `pick` 은 본 스크립트 것을 받음."""
n, spec = row["이름"], row["규격"]
base = places
def places(text): # 「대구경북」 · 「광주전남」 — 붙여 쓴 두 도는 2글자씩 끊어 읽음
w = base(text)
cut = frozenset(y for x in w for i in range(0, len(x), 2) for y in base(x[i : i + 2]))
return cut if w and not w & PROVINCE and cut <= PROVINCE else w
fam = next(((k, f) for k, pat, f in FAMILIES if re.search(pat, n)), None)
if not fam:
return None, "이 갈래는 아직 짝짓기 없음"
@@ -126,13 +145,32 @@ def find(row: dict, raw: dict, places, pick, PROVINCE=frozenset()):
spec, reg = re.sub(r"\([^)]*\)", "", spec), None
paren = re.search(r"\(([가-힣]+)", n) if fam[0] in ("교량받침", "합성목재") else None
need = pieces(spec)
if fam[0] == "주철관": # 이름의 종별(2종 · 3종)도 맞아야
keep = lambda r: True # noqa: E731
if fam[0] in ("주철관", "벤치플룸"): # 이름의 종별(2종 · 3종)도 맞아야
need += [p for p in pieces(n) if re.search(r"\d", p)]
if fam[0] == "벤치플룸": # 「140kg」 은 조달청 표기에 없음 · 뚜껑(부품) 줄은 뺌
need = [p for p in need if not p.endswith("kg")]
keep = lambda r: "(부품)" not in r["prdctSpecNm"] # noqa: E731
if fam[0] == "수로관" and (m := re.match(r"M-(\w+)\((\d+)\*(\d+)", spec)):
need = [
f"m-{m[1].lower()}",
f"{m[2]}x{m[3]}",
] # 모델 · 가로×세로 — 무개 「M-400C」 는 조달청 모델명과 같음
keep = lambda r: re.search(r"(?<![a-z])m-", norm(r["prdctSpecNm"])) is not None # noqa: E731
if fam[0] == "블록" and n.startswith("소형고압블록"):
m = re.match(r"([IU])형\s*T=(\d+)cm,\s*(\S+)", spec)
if not m:
return None, "규격 없음"
need = [f"{SHAPE[m[1]]}x{int(m[2]) * 10}"]
color = m[3].replace("칼라", "컬러")
keep = lambda r: _small_block(r["prdctSpecNm"], color) # noqa: E731
if not pieces(spec):
return None, "규격 없음"
hits, seen_reg = [], False
for r in rows:
s = norm(r["prdctSpecNm"])
if not keep(r):
continue
if not all(has(s, p) if re.search(r"\d", p) else p in s for p in need):
continue
if paren and norm(paren.group(1)) not in norm(
@@ -150,8 +188,9 @@ def find(row: dict, raw: dict, places, pick, PROVINCE=frozenset()):
elif want:
ok = (
want <= have
or r["prdctSplyRgnNm"].startswith("전지역")
and "제주" not in r["prdctSplyRgnNm"]
or r["prdctSplyRgnNm"].startswith(
"전지역"
) # 「전지역(제주,도서 제외)」 — 제주는 뺀다는 뜻
)
else:
ok = True