From 8d95041a9358781adf8f81d0ee8500827e524b03 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 23:37:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(master=5Fdata):=20=EA=B4=80=EA=B8=89=20?= =?UTF-8?q?=EB=AA=BB=20=EC=B1=84=EC=9B=80=20320=20=E2=86=92=20108=20?= =?UTF-8?q?=E2=80=94=20=EB=8A=90=EC=8A=A8=ED=95=9C=20=EB=91=90=20=EB=B2=88?= =?UTF-8?q?=EC=A7=B8=20=EC=A7=9D=EC=A7=93=EA=B8=B0=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=82=B0=20=EC=98=9B=20=EA=B0=92=20=C2=B15%=20=EC=95=88?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_관급단가_느슨.py 신설 — 엄격한 짝이 안 선 줄을 갈래 안에서 규격 글자를 느슨하게 맞춰 다시 봄 (아스콘 · H형강 · HI-VP · PE · VR관 · 돌망태 · 무게수로관 뚜껑 · 벤치플룸 · 유공관 · 이음관) - 아스콘 관리번호 A3-W 떼기 · 못 채운 까닭 비고를 지금 말로 다듬음 - 자재품목 관급 열 212줄 채움(다른 열·줄 그대로) · 시험 · 설계 문서 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- .../master_data/ref/_설계_관급단가_갱신.md | 20 + .../master_data/scripts/update_관급단가.py | 13 +- .../scripts/update_관급단가_기타.py | 28 +- .../scripts/update_관급단가_느슨.py | 366 ++++++++++ resources/master_data/재료_자재품목.json | 640 +++++++++--------- resources/tester/test_masterdata_procure.py | 178 ++++- 6 files changed, 911 insertions(+), 334 deletions(-) create mode 100644 resources/master_data/scripts/update_관급단가_느슨.py diff --git a/resources/master_data/ref/_설계_관급단가_갱신.md b/resources/master_data/ref/_설계_관급단가_갱신.md index ca4c874f..91ea16db 100644 --- a/resources/master_data/ref/_설계_관급단가_갱신.md +++ b/resources/master_data/ref/_설계_관급단가_갱신.md @@ -156,3 +156,23 @@ - 새로 더 받는 분류는 다산 줄 채움이 끝난 뒤 — 받아 둔 분류(블록 · 교량받침 · 폴리에틸렌 · 합성목재 · 그레이팅 · 낙석 · 시트 · 비료 · 매트 · 경질폴리염화비닐관)는 다산 규격과 표기가 달라 짝 규칙을 더 만들거나 새 줄로 들임. - 품셈재료 → 자재품목 연결(343 줄 미연결)은 화면에서 사람이 이음. - 짝짓기 시험 = `resources/tester/test_masterdata_procure.py`(견본 응답 · 보강 합침 포함). + +## 느슨한 두 번째 짝짓기 (일감 19 · 못 채움 320 → 108) + +- 엄격한 짝이 안 선 줄만 `update_관급단가_느슨.py` 가 다시 봄 — 갈래 안에서 규격 글자를 느슨하게 맞춰 후보를 찾고 **다산소프트 옛 값과 ±5% 안일 때만** 채움(밖이면 null · 까닭에 조달청 값 범위). 옛 값 = 원문 estx `Cost1`(키 → 원문번호는 `_키대장.json`) — 정본엔 없음. +- 후보 고르기 — 같은 규격 여럿이면 **최저가**(제조사 비고) · 최저가가 ±5% 밖이고 후보가 등급·형이 달라 글자로 못 가를 때만 **다산 값에 가장 가까운 줄**(비고 「느슨 · … 가장 가까운 줄」 · 55줄 — 값 차이 0% 49 · 1% 미만 4 · 5% 미만 2). +- 채움 212 · `apply 2026-09-20` 로 정본 반영(바뀐 줄 320 = 관급 212 + 비고 320 · 다른 열·줄 그대로). + +| 갈래 | 채움 | 느슨하게 맞춘 것 | +|---|---|---| +| 아스콘 | 83 | 관리번호 「A3-W-」 떼기(10) · 「~구 제외」 묶음 = 뺀 구가 조달청 묶음 제외 목록에 드는 줄 · 줄임 글을 도 이름으로 · 묶음 값이 같거나 제외 목록으로 가려질 때만 | +| H형강 | 30 | 치수가 조달청에 없어도 강종별 톤당 단가가 치수와 무관(제조사당 값 하나)이면 그 값 | +| HI-VP 삼중벽 | 23 | 압력용 HIVP 삼중벽 · 지름×길이 — 압력등급이 다산 규격에 없어 같은 지름 후보 함께 봄 | +| PE 수도용 · 이중벽 | 28 | 수도용 = 「신관」 지름(KP식은 6m 한 본 값) · 이중벽 = 본 값 ÷ 길이(m 당) — 등급 후보 함께 봄 | +| VR관 | 11 | VR관 = 조달청 「진동및전압철근콘크리트관」 보통관(콘크리트관 분류) · 최저가 | +| 돌망태 · 개비온 | 14 | m · cm 치수를 mm 로(1.5L → 1500 · 45×45 → 450×450) · 몸통 · 뚜껑 · 철선 지름 | +| 무게수로관 뚜껑 | 8 | 모델(M-400T) + 치수 3% 안(조달청 「(부품) 가로×높이×길이」 순서) | +| 벤치플룸 | 5 | 다산 모델 글자(400A · 500C)가 조달청과 달라 치수 · 종별 · 지역으로 | +| 파형강관 유공관 · 이음관 | 10 | 유공관 = RS 표기 뺌 · 이음관 = 「PE」 뺀 피복 글자 · (부품) 포함 | + +- 남은 108 — 소형고압블록 49(조달청은 제조사 몇 줄뿐 · 색 · 지역 묶음 줄 없음 — 색 없는 줄을 색 있는 줄에 안 붙임) · 중온 아스콘 지역 없음 28(전북 · 세종 · 충남 일부 — 조달청에 그 지역 중온 줄 없음) · KP 접합부속 17(세트 품명 없음) · 생태블록 3 · 본우드 3 · 슈퍼셀 1 · 레미콘 25-3-150 1(다산 강도 오기로 보임 · 조달청 25-33-150) · 아스콘 「강남 · 강동 · 송파 제외」 묶음 3 · 전주 중온 3(조달청 값이 다산과 5% 넘게 다름). diff --git a/resources/master_data/scripts/update_관급단가.py b/resources/master_data/scripts/update_관급단가.py index 8bbfc202..bb041a1d 100644 --- a/resources/master_data/scripts/update_관급단가.py +++ b/resources/master_data/scripts/update_관급단가.py @@ -24,6 +24,7 @@ sys.path.insert(0, str(PIPE)) import collect_cost_sources as cc # noqa: E402 (키 읽기 · fetch) import master_keys as mk # noqa: E402 (새 줄 키) import update_관급단가_기타 as misc # noqa: E402 (「그 밖」 갈래 짝짓기) +import update_관급단가_느슨 as slow # noqa: E402 (엄격한 짝이 안 선 줄 · 느슨한 두 번째 짝) import update_관급단가_합침 as boost # noqa: E402 (보강 후보 합치기) BASE = "https://apis.data.go.kr/1230000/at/ShoppingMallPrdctInfoService/" @@ -346,11 +347,11 @@ class Matcher: r"\s+", "", re.sub( - r"^\s*[A-Z]\d-H-[^,]*,", + r"^\s*[A-Z]\d-[HW]-[^,]*,", "", re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1], ), - ), # 「R3-H-BB2, BB-2, …」 = 앞의 순환 관리 번호를 떼고 「BB-2, …」 + ), # 「R3-H-BB2」 · 「A3-W-BB2」 = 앞의 관리 번호를 떼고 「BB-2, …」 ) if k not in self.asc: return None, "규격 없음(원문 규격이 조달청에 없음)" @@ -480,6 +481,8 @@ def refill(rows: list[dict], raw: dict) -> list[dict]: ): # 지난번에 못 채운 줄도 다시 시도 continue r, why = m.find(x) + if r is None: # 못 채운 줄은 규격 글자를 느슨하게 다시 봄 — 다산 옛 값 ±5% 안일 때만 채움 + r, why = slow.find(x, m, why, pick, places, PROVINCE) out.append( { "키": x["키"], @@ -541,10 +544,12 @@ def decide(x: dict, r: dict, day: str) -> tuple[int | None, str]: if r["새값"] is None: return None, f"관급: 조달청 값 못 채움 — {r['까닭']} ({day})" c = r["조달청"] - who = f" {c['제조사']} 최저" if "최저" in r["까닭"] else "" + loose = r["까닭"].startswith("느슨") # 느슨한 짝 — 제조사와 까닭을 함께 적음 + who = f" {c['제조사']}" if loose else (f" {c['제조사']} 최저" if "최저" in r["까닭"] else "") who += " · 묶음 시군 일부만 조달청에 있음" if "지역 일부" in r["까닭"] else "" + tail = f" · {r['까닭']}" if loose else "" return r["새값"], ( - f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}" + f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}{tail}" ) diff --git a/resources/master_data/scripts/update_관급단가_기타.py b/resources/master_data/scripts/update_관급단가_기타.py index 3d69bf41..0ca402ae 100644 --- a/resources/master_data/scripts/update_관급단가_기타.py +++ b/resources/master_data/scripts/update_관급단가_기타.py @@ -42,23 +42,35 @@ FAMILIES = ( # 조달청에 그 품목·규격이 없거나 다산 규격으로는 물건을 가릴 수 없는 것 — 「관급」 못 채움의 까닭 글 ABSENT = ( ( - r"^(벤치플륨관뚜껑|무게수로관뚜껑)", - "짝 못 지음 — 조달청 뚜껑은 (부품) 치수 표기가 달라 다산 규격과 안 맞음", + r"^벤치플륨관뚜껑", + "짝 못 지음 — 조달청 뚜껑은 (부품) 표기라 다산 규격과 안 맞음", ), - (r"^HI-VP", "조달청 종합쇼핑몰에 없음 — 삼중벽 품명 없음(단층 HIVG 만 있음)"), - (r"^KP 접합부속", "조달청 종합쇼핑몰에 없음 — 접합부속 세트 품명 없음"), + ( + r"^소형고압블록", + "조달청 소형고압블록은 제조사 몇 줄뿐이라 다산의 색 · 지역 묶음 줄이 없음", + ), + ( + r"^생태(옹벽|어소)블록", + "조달청에 그 품명이 없음 — 호안 · 옹벽블록은 모양 · 단위(㎡)가 달라 안 맞음", + ), + (r"^합성목재\(본우드\)", "조달청 본우드 줄과 규격(치수 · 형)이 안 맞음"), + (r"^콘크리트보강섬유\(슈퍼셀\)", "조달청에 셀룰로오스 보강섬유 품명이 없음"), + (r"^KP 접합부속", "조달청에 접합부속 세트 품명이 없음(고무링 · 압륜 부품만 따로 있음)"), ( r"^(PE직관\(수도용\)|PE이중벽)", "짝 못 지음 — 조달청은 압력·강성 등급별 값인데 다산 규격에 등급 없음", ), - (r"^VR관", "조달청 종합쇼핑몰에 없음 — VR관 품명 없음"), ( r"^(순환)?아스콘.*제외", "짝 못 지음 — 다산 묶음이 「~구 제외」 꼴이라 조달청 공급 지역과 안 맞음", ), (r"^측구수로관", "짝 못 지음 — 조달청은 길이·형별 표기(가로×세로×길이)"), ) -GONE = ("후보 없음", "강도 없음(원문 규격이 조달청에 없음)", "규격 없음(원문 규격이 조달청에 없음)") +GONE = { + "후보 없음": "조달청에 이 품목 줄이 없음(후보 없음)", + "강도 없음(원문 규격이 조달청에 없음)": "조달청에 이 강도 줄이 없음(강도 없음)", + "규격 없음(원문 규격이 조달청에 없음)": "조달청에 같은 품명은 있으나 이 규격 줄이 없음", +} def absent(name: str, why: str) -> str: @@ -67,8 +79,8 @@ def absent(name: str, why: str) -> str: if re.search(pat, name): return text if why == "지역 못 찾음": - return "조달청 종합쇼핑몰에 그 지역 묶음의 줄 없음 — 지역 못 찾음" - return f"조달청 종합쇼핑몰에 없음 — {why}" if why in GONE else why + return "조달청에 그 지역 묶음의 줄이 없음(지역 못 찾음)" + return GONE.get(why, why) SKIP_WORDS = {"관급", "포장품", "", "각종"} diff --git a/resources/master_data/scripts/update_관급단가_느슨.py b/resources/master_data/scripts/update_관급단가_느슨.py new file mode 100644 index 00000000..25f072a3 --- /dev/null +++ b/resources/master_data/scripts/update_관급단가_느슨.py @@ -0,0 +1,366 @@ +"""관급 단가 — 느슨한 두 번째 짝짓기(`update_관급단가.py` 가 엄격한 짝이 안 서면 부름). + +엄격한 짝짓기가 못 세운 줄만 다시 봄 — 같은 갈래 안에서 규격 글자를 느슨하게 맞춰 후보를 찾고, +**값이 잡히면 다산소프트 옛 값과 ±5% 안일 때만** 채움(밖이면 안 채움 · 까닭에 조달청 값). + · 후보가 같은 규격 여럿이면 최저가(제조사 비고) — 최저가가 ±5% 밖이고 후보가 등급·형이 달라 + 글자로 못 가릴 때만 다산 값에 가장 가까운 줄(비고에 「느슨」 으로 밝힘) + · 다산소프트 옛 값 = 원문(estx) Cost1 · 키 → 원문번호는 `_키대장.json` — 자재품목 정본엔 이미 없음 +갈래: H형강 · 아스콘 · 돌망태·개비온 · 관(HI-VP · PE 이중벽 · PE 수도용 · VR관 · 유공관) + · 무게수로관 뚜껑 · 벤치플룸 · 파형강관 이음관. +""" + +import re +from functools import lru_cache + +PCT = 0.05 # 다산 옛 값과 이 안이면 「맞음」 +SPREAD = 0.01 # 후보 묶음마다 값이 이 안이면 같은 값으로 봄 + + +def within(new, old) -> bool: + return bool(old) and abs(new / old - 1) <= PCT + + +@lru_cache(maxsize=1) +def _dasan() -> dict[str, int]: + """{자재품목 키: 다산소프트 관급 값} — 원문 estx 에서 읽음(못 채운 줄을 다시 볼 때만 부름).""" + import compare_estx as ce # noqa: PLC0415 — 무거운 불러오기는 필요할 때만 + import master_keys as mk # noqa: PLC0415 + + book = mk.load_book()["키"] + cost = {r["Code"]: r.get("Cost1") for r in ce.Estx().table("StuffcDat") if r.get("Code")} + return { + k: int(cost[v["원문번호"]]) + for k, v in book.items() + if v["파일"] == "재료_자재품목.json" and cost.get(v["원문번호"]) + } + + +def old_of(key: str) -> int | None: + return _dasan().get(key) + + +def _price(r: dict) -> int: + return int(r["cntrctPrceAmt"]) + + +def choose(cands: list[dict], old, pick, why: str = ""): + """(줄, 까닭) — 최저가가 ±5% 안이면 그 줄 · 밖이면 가장 가까운 줄(±5% 안) · 아니면 못 채움.""" + if not cands: + return None, "후보 없음" + prices = sorted({_price(r) for r in cands}) + low, high = prices[0], prices[-1] + if within(low, old): + r, how = pick(cands) + return r, f"느슨 · {why}{how}" + near = min(cands, key=lambda r: (abs(_price(r) - old), _price(r))) + if within(_price(near), old): + return near, ( + f"느슨 · {why}값이 {low:,}~{high:,} 로 갈려 최저가는 다산과 5% 넘게 다름" + " — 다산 값에 가장 가까운 줄" + ) + return None, f"규격은 맞으나 조달청 값({low:,}~{high:,})이 다산 값과 5% 넘게 다름" + + +# ── H형강 — 치수가 조달청에 없어도 강종별 톤당 단가가 모든 치수에서 같음 ──────────────── +def hbeam(row: dict, m, old, pick): + g = re.match(r"(SS\d+|SM\d+|SHN\d+)", row["규격"]) + rows = [ + r + for r in m.hbeam + if g and g.group(1) in r["prdctSpecNm"] and r["prdctSplyRgnNm"].startswith("전지역") + ] + if not rows: + return None, "조달청에 이 강종의 H형강 줄이 없음" + prices = {_price(r) for r in rows} + makers = {r["cntrctCorpNm"] for r in rows} + if len(prices) > len(makers): # 제조사마다 값이 하나뿐이어야 치수와 무관한 값 + return None, "조달청은 치수마다 톤당 단가가 달라 치수 없이는 못 정함" + got = [r for r in rows if _price(r) == min(prices)] + return choose( + got, old, pick, "치수는 조달청에 없으나 이 강종은 모든 치수의 톤당 단가가 같음 · " + ) + + +def _out(text: str) -> set[str]: + """「(강북,노원,도봉구 제외)」 — 묶음에서 뺀 시·군·구 낱말(끝 글자 구·시·군은 뗌).""" + return { + w[:-1] if len(w) > 2 and w[-1] in "구시군" else w + for grp in re.findall(r"\(([^()]*?)제외\)", text or "") + for w in re.findall(r"[가-힣]+", grp) + } + + +# ── 아스콘 — 묶음 이름의 「~구 제외」 · 줄임 글을 도 이름으로 맞춤 ──────────────────── +def asphalt(row: dict, m, old, pick, places, province): + name = row["이름"] + key = ( + name.startswith("순환"), + re.sub( + r"\s+", + "", + re.sub( + r"^\s*[A-Z]\d-[HW]-[^,]*,", + "", + re.split(r"조합공통품목(?:\([^)]*\))?,", row["규격"])[-1], + ), + ), + ) + table = m.asc.get(key) + if not table: + return None, "이 배합(가열 · 중온 · 등급)의 아스콘 줄이 조달청에 없음" + prov = places(name.split(")-", 1)[1] if ")-" in name else name) & province + got = [r for reg, rows in table.items() if prov and prov <= reg for r in rows] + if not got: + return None, f"조달청에 그 지역의 {key[1].split(',')[1]} 아스콘 줄이 없음(일부 지역만 있음)" + out = _out(name) + if out: # 「~구 제외」 묶음 — 조달청 묶음의 제외 목록이 다산이 뺀 곳을 포함하는 줄 + near = [r for r in got if out <= _out(r["prdctSplyRgnNm"])] + if near: + return choose(near, old, pick, "다산이 뺀 구가 조달청 묶음의 제외 목록에 듦 · ") + prices = sorted({_price(r) for r in got}) + if prices[-1] / prices[0] - 1 > SPREAD: + return ( + None, + f"그 도의 조달청 묶음마다 값이 갈리고({prices[0]:,}~{prices[-1]:,})" + " 제외 목록도 안 맞음", + ) + return choose(got, old, pick, "묶음의 「제외」 · 줄임 글을 도 이름으로 맞춤 · ") + + +# ── 돌망태 · 개비온 — 치수 표기(m · cm) 달라도 mm 로 맞춤 ───────────────────────────── +def _dims(spec: str): + """규격의 치수 셋(또는 둘) → mm 후보 글 목록.""" + m = re.match(r"\s*([\d.]+)L×([\d.]+)W×([\d.]+)H", spec) + if m: # 1.5L×1.0W×1.0H (m) + return [[str(round(float(x) * 1000)) for x in m.groups()]] + m = re.search(r"\((\d+)×(\d+)\)", spec) # #8(45×45) (cm) + if m: + return [[str(int(x) * 10) for x in m.groups()]] + return [] + + +def gabion(row: dict, raw: dict, old, pick): + n, spec = row["이름"], row["규격"] + dims = _dims(spec) + if not dims: + return None, "규격 치수를 못 읽음" + d = dims[0] + form = re.match(r"(사각|육각|원형|타원형|이불형)", n) + part = re.search(r"\((뚜껑|몸통)\)", n) + wire = re.search(r"\(([\d.]+)mm", n) + rows = [r for f in ("MASCntrct_돌망태", "ThptyUcntrct_돌망태") for r in raw.get(f, [])] + pat = "×".join(d) + got = [] + for r in rows: + s = re.sub(r"\s+", "", r["prdctSpecNm"]) + if not re.search(rf"(? dict: + """본(길이 m) 값을 다산 단위(m)로 — 줄 사본에 값을 바꿔 넣음.""" + return dict(r, cntrctPrceAmt=str(round(_price(r) / meters)), prdctUnit="m") + + +def pipe(row: dict, raw: dict, old, pick): + n, spec = row["이름"], row["규격"] + pvc = raw.get("MASCntrct_경질폴리염화비닐관", []) + raw.get( + "ThptyUcntrct_경질폴리염화비닐관", [] + ) + pe = raw.get("MASCntrct_폴리에틸렌", []) + raw.get("ThptyUcntrct_폴리에틸렌", []) + if n.startswith("HI-VP"): + m = re.match(r"D(\d+)mm×(\d)m", spec) + kind = "편수칼라" if "편수칼라" in n else "직관" + if not m: + return None, "규격 지름을 못 읽음" + got = [ + r + for r in pvc + if "HIVP" in r["prdctSpecNm"] + and "삼중" in r["prdctSpecNm"] + and kind in r["prdctSpecNm"] + and f"Φ{m.group(1)}mm×{m.group(2)}m" in r["prdctSpecNm"] + ] + return choose(got, old, pick, "압력등급 · 제조사가 달라 같은 지름 후보를 함께 봄 · ") + if n.startswith("PE이중벽"): + m = re.match(r"Φ(\d+)m/m", spec) + if not m: + return None, "규격 지름을 못 읽음" + got = [] + for r in pe: + s = r["prdctSpecNm"] + k = re.search(rf"Φ{m.group(1)}mm×(\d+)m", s) + if s.startswith("일반용폴리에틸렌관") and "이중벽관" in s and k: + got.append(_per(r, int(k.group(1)))) + return choose( + got, old, pick, "본 값을 m 당으로 나눔 · 강성등급이 달라 같은 지름 후보를 함께 봄 · " + ) + if n.startswith("PE직관(수도용)"): + kp = re.search(r"신관(\d+)×(\d)m", spec) # 「75×6m (신관90×6m) KP식」 — 6m 한 본 값 + if kp: + got = [ + r + for r in pe + if r["prdctSpecNm"].startswith("수도용폴리에틸렌관") + and f"Φ{kp.group(1)}mm×{kp.group(2)}m" in r["prdctSpecNm"] + ] + return choose(got, old, pick, "신관 지름 · 본 길이로 맞춤 · ") + m = re.search(r"신관(\d+)mm\)$", spec) # 「D16mm (신관20mm)」 — 조달청은 신관 지름 + if not m: + return None, "규격에 신관 지름이 없음" + got = [ + r + for r in pe + if r["prdctSpecNm"].startswith("수도용폴리에틸렌관") + and re.search(rf"Φ{m.group(1)}mm", r["prdctSpecNm"]) + and r["prdctUnit"].lower() == "m" + ] + return choose( + got, old, pick, "신관 지름으로 맞춤 · 등급이 달라 같은 지름 후보를 함께 봄 · " + ) + if n.startswith("VR관"): # VR = 진동및전압철근콘크리트관 — 조달청 「콘크리트관」 분류 안 + m = re.match(r"D=(\d+)mm", spec) + if not m: + return None, "규격 지름을 못 읽음" + hume = raw.get("MASCntrct_콘크리트관", []) + raw.get("Ucntrct_콘크리트관", []) + got = [ + r + for r in hume + if "진동및전압" in r["prdctSpecNm"] + and "보통관" in r["prdctSpecNm"] + and f"Φ{m.group(1)}×" in r["prdctSpecNm"] + ] + return choose(got, old, pick, "VR관 = 진동및전압철근콘크리트관 · ") + if n.startswith("파형강관(관급)"): # 유공관 — 다산 규격에 RS 가 없어 엄격한 짝이 안 섬 + dia = re.search(r"Φ(\d+)", spec) + th = re.search(r"t(\d+(?:\.\d+)?)mm", spec) + if not (dia and th and "유공관" in spec): + return None, "" + kinds = [w for w in ("아연도금", "세라믹", "PE") if w in spec] + got = [ + r + for r in raw.get("MASCntrct_파형강관", []) + raw.get("ThptyUcntrct_파형강관", []) + if r["prdctSpecNm"].startswith("파형강관,") + and f"Φ{dia.group(1)}×" in r["prdctSpecNm"] + and re.search(rf"t{re.escape(th.group(1))}(\.0)?mm", r["prdctSpecNm"]) + and "유공관" in r["prdctSpecNm"] + and all(w in r["prdctSpecNm"] for w in kinds) + ] + return choose(got, old, pick, "다산 규격에 없는 RS 표기를 뺌 · ") + return None, "" + + +# ── 무게수로관 뚜껑 · 벤치플룸 · 파형강관 이음관 — 조달청 표기(모델 · 치수 순서 · 피복)를 맞춤 ── +def lid(row: dict, raw: dict, old, pick): + m = re.match(r"(M-\w+)\((\d+)\*(\d+)\*(\d+)\)", row["규격"]) + if not m: + return None, "규격 모델 · 치수를 못 읽음" + want = sorted( + int(x) for x in m.groups()[1:] + ) # 다산 가로*길이*높이 — 조달청은 가로×높이×길이 순 + got = [] + for f in ("MASCntrct_배수로", "ThptyUcntrct_배수로"): + for r in raw.get(f, []): + s = r["prdctSpecNm"] + if "수로관뚜껑" not in s or not re.search(rf"(?