feat(master_data): 관급 다산 줄 우선 채움 · 못 채움 800 → 495 · 못 채운 까닭 비고

- 아스콘 지역 포함(시군 붙은 글 끊기) · R3-H/A1-H 규격 번호 떼기 · 파형강관 ×t·1RS·PE 피복 · 교량받침·합성목재·혼합골재 제조사 괄호 짝
- 못 채운 495줄 비고에 까닭(조달청 종합쇼핑몰에 없음 · 짝 못 지음) · 경질폴리염화비닐관 받기
- 다산 4,552줄 기준 채움 4,057 · 맞음 3,442 · 다름 615 · 못 채움 495 · 로직 일괄 시험 멈춤 327 그대로
- 시험 13건 · 설계 문서 대조표 갱신

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 18:40:55 +09:00
co-authored by Claude Sonnet 5
parent 3b37f9d24d
commit a905edd87f
8 changed files with 977 additions and 826 deletions
@@ -76,6 +76,8 @@ JOBS = [ # 분류명은 부분 글자 조회 — 「철근」은 암거블록
(MAS, "비료"),
(MAS, "압륜"),
(THPTY, "부직포"),
(MAS, "경질폴리염화비닐관"),
(THPTY, "경질폴리염화비닐관"),
]
# 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 MB)
KEEP = (
@@ -213,6 +215,10 @@ def region_pick(cands: dict, want: frozenset):
hit = [k for k in cands if k <= want and k - PROVINCE]
if towns <= set().union(*hit) if hit else False:
return [r for k in hit for r in cands[k]], f"지역 묶음 {len(hit)}"
# 다산 묶음의 시군이 조달청 한 줄의 공급 지역 안에 모두 들어 있음 — 그 줄들의 최저가(값이 갈리면 pick 이 최저)
sup = [k for k in cands if towns and towns <= k]
if sup:
return [r for k in sup for r in cands[k]], f"지역 포함 {len(sup)}"
return None, "지역 못 찾음"
@@ -243,7 +249,7 @@ def _dia(t):
def _thick(t):
m = re.search(r"[×xX*](\d+(?:\.\d+)?)", t or "")
m = re.search(r"[×xX*]t?(\d+(?:\.\d+)?)", t or "")
return float(m.group(1)) if m else None
@@ -254,6 +260,10 @@ def _size(t):
return tuple(float(x) for x in m.groups()) if m else None
CORR_WORDS = ("PE양면피복", "PE내면피복", "아연도금", "유공관", "접속티", "커플링밴드", "직관")
CORR_EXTRA = ("내부평활", "플랜지", "세라믹", "복합", "PE+PP", "PE/PP", "내식")
class Matcher:
"""자재품목 줄 → 조달청 줄. 짝이 서면 (줄, 방식), 못 서면 (None, 까닭)."""
@@ -287,8 +297,16 @@ class Matcher:
def _region(table: dict, name: str):
want = places(name.split(")-", 1)[1] if ")-" in name else name)
cands, why = region_pick(table, want)
if cands is None: # 구분 없이 붙은 시군(「진주사천남해…」)은 두 글자씩 끊어 다시
cut = frozenset(
q
for w in want
for q in ([w[i : i + 2] for i in range(0, len(w), 2)] if len(w) >= 6 else [w])
)
if cut != want:
cands, why = region_pick(table, cut)
if cands is None or (
why.startswith("지역 묶음") and "제외" in name
why.startswith(("지역 묶음", "지역 포함")) and "제외" in name
): # 「~ 제외」 묶음은 짝짓지 않음
return None, why if cands is None else "지역 못 찾음"
corp = re.findall(
@@ -299,6 +317,12 @@ class Matcher:
return cands, why
def find(self, row: dict):
r, why = self._find(row)
if r is None:
why = misc.absent(row["이름"], why)
return r, why
def _find(self, row: dict):
n, spec = row["이름"], row["규격"]
if n.startswith("레미콘(관급)-"):
k = strength(spec)
@@ -312,7 +336,15 @@ class Matcher:
if re.match(r"(순환)?아스콘\(관급\)-", n):
k = (
n.startswith("순환"),
re.sub(r"\s+", "", re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1]),
re.sub(
r"\s+",
"",
re.sub(
r"^\s*[A-Z]\d-H-[^,]*,",
"",
re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1],
),
), # 「R3-H-BB2, BB-2, …」 = 앞의 순환 관리 번호를 떼고 「BB-2, …」
)
if k not in self.asc:
return None, "규격 없음(원문 규격이 조달청에 없음)"
@@ -358,18 +390,31 @@ class Matcher:
if re.match(r"파형강관\(관급\)", n):
dia, th = _dia(spec), _thick(spec)
rs = re.search(r"(\d)RS", spec)
c = [
if not (dia and th and rs):
return None, "후보 없음"
coat = [w for w in ("PE양면피복", "PE내면피복") if w in spec]
c = [ # 먼저 「(nRS)」 꼴 — 이미 채운 값이 흔들리지 않게 · PE 피복 줄은 피복 글자가 맞아야
r
for r in self.corr
if dia
and th
and rs
and _dia(r["prdctSpecNm"]) == dia
if _dia(r["prdctSpecNm"]) == dia
and re.search(rf"t{th:g}mm", r["prdctSpecNm"])
and f"({rs.group(1)}RS)" in r["prdctSpecNm"]
and all(re.search(rf"(?<!복합)(?<!\+)(?<!/){w}", r["prdctSpecNm"]) for w in coat)
]
r, how = pick(c)
return r, how
if c:
return pick(c)
kinds = [w for w in CORR_WORDS if w in spec] # 없을 때만 — 종류·피복 글자가 맞는 줄
for r in self.corr:
t = r["prdctSpecNm"]
if (
_dia(t) == dia
and re.search(rf"t{re.escape(f'{th:g}')}(\.0)?mm", t)
and re.search(rf"(?<!\d){rs.group(1)}RS", t)
and all(re.search(rf"(?<!복합)(?<!\+)(?<!/){w}", t) for w in kinds)
and all(w in spec or w not in t for w in CORR_EXTRA) # 다산에 없는 덧붙임은 뺌
):
c.append(r)
return pick(c)
if re.match(r"\s*관", n):
dia = _dia(spec)
c = [
@@ -38,6 +38,43 @@ FAMILIES = (
["MASCntrct_폴리에틸렌", "ThptyUcntrct_폴리에틸렌"],
),
)
# 조달청에 그 품목·규격이 없거나 다산 규격으로는 물건을 가릴 수 없는 것 — 「관급」 못 채움의 까닭 글
ABSENT = (
(
r"^벤치플륨",
"조달청 종합쇼핑몰에 없음 — 벤치플륨 품명 없음(수로관 유사품이 같은 물건인지 확인 못 함)",
),
(r"^무게수로관", "조달청 종합쇼핑몰에 없음 — 무게수로관 품명 없음"),
(r"^HI-VP", "조달청 종합쇼핑몰에 없음 — 삼중벽 품명 없음(단층 HIVG 만 있음)"),
(r"^KP 접합부속", "조달청 종합쇼핑몰에 없음 — 접합부속 세트 품명 없음"),
(
r"^소형고압블록",
"조달청 종합쇼핑몰에 없음 — 소형고압블록 품명 없음(보차도용콘크리트블록은 형·색 표기가 달라 안 지음)",
),
(
r"^(PE직관\(수도용\)|PE이중벽)",
"짝 못 지음 — 조달청은 압력·강성 등급별 값인데 다산 규격에 등급 없음",
),
(r"^VR관", "조달청 종합쇼핑몰에 없음 — VR관 품명 없음"),
(
r"^(순환)?아스콘.*제외",
"짝 못 지음 — 다산 묶음이 「~구 제외」 꼴이라 조달청 공급 지역과 안 맞음",
),
(r"^측구수로관", "짝 못 지음 — 조달청은 길이·형별 표기(가로×세로×길이)"),
)
GONE = ("후보 없음", "강도 없음(원문 규격이 조달청에 없음)", "규격 없음(원문 규격이 조달청에 없음)")
def absent(name: str, why: str) -> str:
"""못 채운 까닭 → 조달청에 없음 · 짝 못 지음 을 가려 적음."""
for pat, text in ABSENT:
if re.search(pat, name):
return text
if why == "지역 못 찾음":
return "조달청 종합쇼핑몰에 그 지역 묶음의 줄 없음 — 지역 못 찾음"
return f"조달청 종합쇼핑몰에 없음 — {why}" if why in GONE else why
SKIP_WORDS = {"관급", "포장품", "", "각종"}
@@ -49,6 +86,8 @@ def norm(t: str) -> str:
t = (t or "").lower().replace("", "mm").replace("", "m3").replace("", "kg")
t = re.sub(r"[φ∅øᴓф]", "d", t)
t = re.sub(r"(?<=\d)mm", "", t)
t = re.sub(r"(?<=\d),(?=\d{3})", "", t) # 「1,000」
t = re.sub(r"(?<=[×x*\s,])t(?=\d)", "", t).replace("solid", "솔리드") # 「×t30」 = 「×30」
t = re.sub(r"(?<=[\d.])[×*x](?=[\d.d])", "x", t)
return re.sub(r"\s+", "", t).replace("", "")
@@ -67,7 +106,7 @@ def tail_of(name: str) -> str | None:
"""이름 끝의 「-지역」 또는 「-제조사」 · 「(충남지역」 · 「시멘트, 제주」."""
m = re.search(r"[\)\s]-([가-힣,·]+)$", name) or re.search(r"\(([가-힣]+)지역", name)
if m:
return m.group(1)
return None if m.group(1) == "관급" else m.group(1)
return "제주" if ", 제주" in name else None
@@ -83,6 +122,9 @@ def find(row: dict, raw: dict, places, pick, PROVINCE=frozenset()):
tail = tail_of(n)
reg = tail if tail and places(tail) & PROVINCE else None # 지역 이름이 아니면 제조사
maker = norm(tail) if tail and not reg else None
if fam[0] == "골재": # 「토금산업(홍성), 75mm」 — 공장 이름 + 굵기 · 지역은 공장이 정함
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종)도 맞아야
need += [p for p in pieces(n) if re.search(r"\d", p)]
@@ -93,6 +135,10 @@ def find(row: dict, raw: dict, places, pick, PROVINCE=frozenset()):
s = norm(r["prdctSpecNm"])
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(
r["prdctSpecNm"] + r["cntrctCorpNm"] + str(r["prdctMakrNm"])
):
continue
if maker and maker not in norm(
r["prdctSpecNm"] + r["cntrctCorpNm"] + str(r["prdctMakrNm"])
):