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:
@@ -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"])
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user