- 재료_자재품목 24,092 → 17,744 줄(지운 키 6,348 · 이력 안 남김 · 대장에서 뺌 · 「다음」 30719 그대로) - 통합 규칙 한 모듈 scripts/material_merge.py — 지역 · 관급/사급 꼬리표 · 제조사(-거양) · 조합 · 상하차 글을 뗀 이름 · 규격 · 단위 · 상하차가 같은 줄 한 줄 - 남는 줄 = 사급 줄 가운데 전국 › 지역 글 없음(전국으로 봄) › 서울 › 값 중간 지역 · 관급 줄만 묶음 = 앞(작은 키) 줄 · 원래 겹친 줄(덧정보 없음)은 안 합침 - 관급 칸(EST 가격정보) 값 4,552 → 0 · 칸은 남김 · 비고 「조달 …」 · 「가격정보 …」 글 뺌 - 상하차 칸(요구 828) = 상차 · 하차 · -(기본) · 관급 칸 바로 앞(자리 = material_merge.LOAD_AT 한 곳) · 도착도 = 하차 · 채취장상차 · 하치장/생산공장 상차도 · (상차도) = 상차 · 철근 HD 규격 정규화 안 함 - 남는 줄 물가지 넷 · 면수 · 출처 · 단위 · 구분 바뀐 것 0 · 빌더 두 번 돌림 = 같은 파일 - check_master 열 목록에 상하차 · 값 검사(셋 가운데 하나) · 새 오류 0(단위 27 · 배 7 전과 같음) - _틀.md · _갱신절차.md 열 한 벌 · 관급 칸 · 상하차 · 통합 한 줄씩 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · PLAN 830-2) — build_재료_자재품목 이 부름.
|
|
시험 = test_830_2_material_merge.
|
|
|
|
묶음 = 이름에서 지역 · 관급/사급 꼬리표 · 제조사 꼬리(「-거양」) · 조합 이름 · 상하차 글을 뺀
|
|
이름 · 규격 · 단위 · 상하차가 같은 줄.
|
|
규격 = 앞말 「조합공통품목,」 · 규격 속 지역 괄호 뗌
|
|
단위 = M=m · m²=㎡ · Ton=톤 · ㎏=kg 표기 하나로 · 비교는 공백을 뺀 글
|
|
상하차 칸(요구 828) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
|
|
「도착도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
|
|
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄 · 브) › 서울 › 서울 권역 글
|
|
› 값이 중간인 지역 줄(브 · 짝수면 아래 중간)
|
|
· 남길 줄의 값 칸이 전부 비고 다른 줄에 값이 있으면 다음 차례 줄(브)
|
|
· 관급 줄만 있는 묶음 = 가장 작은 키 한 줄(브) · 나머지 줄은 지움(이력 안 남김)
|
|
덧정보가 하나도 없는 묶음(원래부터 겹친 줄 — 식생 · 종자 등)은 합치지 않음.
|
|
관급 칸 값은 모두 비움(EST 「가격정보」 — 요구 827) · 이름에서 뗀 말은 비고 「통합: …」 로.
|
|
"""
|
|
|
|
import re
|
|
|
|
MARKET = ("물가자료", "유통물가", "물가정보", "거래가격")
|
|
LOAD = "상하차" # 표지 칸(값 칸 아님)
|
|
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
|
|
LOAD_AT = "관급" # 상하차 칸 자리 = 이 칸 바로 앞 — 자리는 여기 한 곳
|
|
CITY = set(
|
|
"""서울 부산 대구 인천 광주 대전 울산 세종 제주 청주 전주 포항 창원 춘천 충주 강릉
|
|
천안 원주 여수 구미 순천 수원 목포 금산 영천 논산 삼척 홍성 익산 청도 양양 남원
|
|
공주 군산 김천 가평 용인 포천 경주 아산 제천 여주 보령 부여 당진 청원 음성 서산
|
|
영동 진천 예산 장성 고흥 고창 광양 고령 성주 영주 안동 칠곡 상주 문경 합천 화성
|
|
경산 전국""".split()
|
|
)
|
|
SIDO = "서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남|충청|전라|경상"
|
|
_CITY = "|".join(sorted(CITY, key=len, reverse=True))
|
|
UNIT = {"M": "m", "m²": "㎡", "Ton": "톤", "ton": "톤", "㎏": "kg"}
|
|
SUPPLY = ( # (꼬리표 글, 공급) — 이름 끝 · 괄호 꼴
|
|
(r"\(관급\)", "관급"),
|
|
(r"[-/ ]관급$", "관급"),
|
|
(r"(?<=\))관급$", "관급"),
|
|
(r",관급(?=\))", "관급"),
|
|
(r"\(사급\)", "사급"),
|
|
(r"-사급$", "사급"),
|
|
(r",사급$", "사급"),
|
|
)
|
|
NAME_LOAD = re.compile(r",\s*(도착도|채취장상차도|채취장상차)$") # 모 래, 서울, 도착도
|
|
SPEC_LOAD = re.compile(
|
|
r"\s*(?:\((상차도)\)|((?:하치장|생산공장)\s*상차도))$"
|
|
) # HD=10mm 하치장 상차도
|
|
COOP = re.compile(r"^조합공통품목(?:\(([^)]*)\))?,?\s*")
|
|
|
|
|
|
def load_of(text: str) -> str:
|
|
"""인도 조건 글 → 상하차 값 — 도착 = 하차 · 상차 = 상차."""
|
|
return "하차" if text.startswith("도착") else "상차"
|
|
|
|
|
|
def place(row: dict, load: str) -> dict:
|
|
"""상하차 칸을 LOAD_AT 바로 앞에 둔 줄."""
|
|
out = {}
|
|
for k, v in row.items():
|
|
if k == LOAD:
|
|
continue
|
|
if k == LOAD_AT:
|
|
out[LOAD] = load
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def ws(s: str | None) -> str:
|
|
return re.sub(r"\s+", " ", s or "").strip()
|
|
|
|
|
|
def nows(s: str) -> str:
|
|
return re.sub(r"\s+", "", s)
|
|
|
|
|
|
def _region(name: str, spec: str):
|
|
"""(지역 글, 지역을 뗀 이름, 지역을 뗀 규격) — 지역 꼴 일곱(조사 818)."""
|
|
m = re.match(r"^(.*?)\(([^()]+)\)-사급$", name) # 레미콘(서울)-사급
|
|
if m and m[2] in CITY:
|
|
return m[2], m[1] + "-사급", spec
|
|
m = re.match(r"^(.*?)\((SD\d+),([^()]+)\)-관급$", name) # 이형철근(SD400,제주)-관급
|
|
if m and m[3] in CITY:
|
|
return m[3], f"{m[1]}({m[2]})-관급", spec
|
|
m = re.match(r"^(.*?\(관급\))-(.+)$", name) # 레미콘(관급)-강원도(태백시)
|
|
if m:
|
|
tail = m[2]
|
|
head = re.sub(r"[(\s,].*", "", tail)
|
|
if re.match(rf"^({SIDO})", head) or head in CITY or "광역시" in head or head.endswith("도"):
|
|
return tail, m[1], spec
|
|
return None, name, spec
|
|
m = re.match(r"^(.*?),\s*([가-힣]+)\s*\(관급\)$", name)
|
|
if m and m[2] in CITY:
|
|
return m[2], m[1] + "(관급)", spec
|
|
if "(관급)" in name or name.endswith("-관급") or "-사급" in name:
|
|
return None, name, spec
|
|
parts = [p.strip() for p in name.split(",")] # 합판(내수), 서울 · 모 래, 여수, 도착도
|
|
hit = [i for i, p in enumerate(parts) if i > 0 and p in CITY]
|
|
if hit:
|
|
return parts[hit[0]], ", ".join(p for j, p in enumerate(parts) if j != hit[0]), spec
|
|
m = re.search(
|
|
rf"\((?:([가-힣]+)·)?({_CITY}|충남)(지역)?(,관급)?\)", name
|
|
) # H 형강(전국) · 모래(건조상태·전국)
|
|
if m:
|
|
keep = f"({m[1]})" if m[1] else ""
|
|
rest = "(관급)" if m[4] else ""
|
|
return m[2], name[: m.start()] + keep + rest + name[m.end() :], spec
|
|
m = re.search(rf"\s?\(({_CITY})(지역)?\)", spec) # 콘크리트근가 0.7M(제주지역)
|
|
if m:
|
|
return m[1], name, spec[: m.start()] + spec[m.end() :]
|
|
if spec in CITY: # 품질시험 현장출장비 · 포항
|
|
return spec, name, ""
|
|
return None, name, spec
|
|
|
|
|
|
def parse(row: dict) -> dict:
|
|
"""줄 → 덧정보를 뗀 이름 · 규격 · 상하차 · 묶음 열쇠 · 뗀 말."""
|
|
name, spec = row.get("이름") or "", row.get("규격") or ""
|
|
words = []
|
|
region, base, spec2 = _region(name, spec)
|
|
m = re.search(r"-거양$", base)
|
|
if m:
|
|
base = base[: m.start()]
|
|
words.append("제조사 거양")
|
|
supply = ""
|
|
for pat, kind in SUPPLY:
|
|
if re.search(pat, base):
|
|
supply = supply or kind
|
|
base = re.sub(pat, "", base)
|
|
if supply:
|
|
words.append(supply)
|
|
load = row.get(LOAD) or LOADS[0]
|
|
m = NAME_LOAD.search(base)
|
|
if m:
|
|
load = load_of(m[1])
|
|
words.append(m[1])
|
|
base = base[: m.start()]
|
|
base = re.sub(r"[\s,]+$", "", base)
|
|
m = COOP.match(spec2)
|
|
if m:
|
|
spec2 = spec2[m.end() :]
|
|
words.append(f"조합 {m[1]}" if m[1] else "조합공통품목")
|
|
m = SPEC_LOAD.search(spec2)
|
|
if m:
|
|
load = load_of(m[1] or m[2])
|
|
words.append(m[1] or m[2])
|
|
spec2 = spec2[: m.start()]
|
|
spec2 = spec2.strip()
|
|
unit = ws(row.get("단위"))
|
|
tagged = bool(region or words)
|
|
return {
|
|
"이름": base if tagged else name,
|
|
"규격": spec2 if tagged else spec,
|
|
LOAD: load,
|
|
"지역": region or "",
|
|
"공급": supply,
|
|
"뗀 말": words,
|
|
"덧정보": tagged,
|
|
"열쇠": (nows(base), nows(spec2), UNIT.get(unit, unit), load),
|
|
}
|
|
|
|
|
|
def price(row: dict):
|
|
v = [row[s] for s in MARKET if row.get(s) is not None]
|
|
return min(v) if v else None
|
|
|
|
|
|
def _rank(region: str) -> int:
|
|
if region == "전국":
|
|
return 0
|
|
if not region:
|
|
return 1
|
|
if region == "서울":
|
|
return 2
|
|
if region.startswith("서울"):
|
|
return 3
|
|
return 4
|
|
|
|
|
|
def _order(pool: list) -> list:
|
|
"""남길 차례 — 전국 › 지역 없음 › 서울 › 서울 권역 › 값이 중간인 지역 줄부터 가까운 차례 · 같으면 앞 줄(작은 키)."""
|
|
top = sorted(
|
|
(p for p in pool if p[1]["지역"] and _rank(p[1]["지역"]) == 4),
|
|
key=lambda p: (price(p[0]) is None, price(p[0]) or 0),
|
|
)
|
|
if top:
|
|
mid = (sum(1 for p in top if price(p[0]) is not None) - 1) // 2
|
|
mid = max(mid, 0)
|
|
top = sorted(top, key=lambda p: (abs(top.index(p) - mid), top.index(p)))
|
|
rest = sorted((p for p in pool if _rank(p[1]["지역"]) < 4), key=lambda p: _rank(p[1]["지역"]))
|
|
return rest + top
|
|
|
|
|
|
def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
"""통합한 줄(관급 칸 비움) · 기록(덧정보를 뗀 묶음마다 남은 줄 · 지운 줄 · 기준 지역 · 「브」 규칙).
|
|
줄 차례 = 키 차례(빌더가 원문 차례로 짓고 대장 키도 그 차례) — 같은 순위 · 관급만 묶음은 앞 줄을 남김."""
|
|
groups: dict = {}
|
|
for r in rows:
|
|
p = parse(r)
|
|
groups.setdefault(p["열쇠"], []).append((r, p))
|
|
new, log = {}, []
|
|
for g in groups.values():
|
|
if not any(p["덧정보"] for _, p in g):
|
|
continue
|
|
sup = [x for x in g if x[1]["공급"] != "관급"]
|
|
rules = []
|
|
if sup:
|
|
order = _order(sup)
|
|
best = next((x for x in order if price(x[0]) is not None), order[0])
|
|
region = best[1]["지역"]
|
|
if _rank(region) == 1 and len(g) > 1:
|
|
rules.append("지역 없음 = 전국")
|
|
if _rank(region) == 4 and len({x[1]["지역"] for x in sup}) > 1:
|
|
rules.append("값 중간 지역")
|
|
if best is not order[0]:
|
|
rules.append("값 빈 줄 건너뜀")
|
|
else:
|
|
best = g[0]
|
|
region = ""
|
|
if len(g) > 1:
|
|
rules.append("관급만 = 작은 키")
|
|
words = list(dict.fromkeys(w for _, p in g for w in p["뗀 말"]))
|
|
regions = sorted({p["지역"] for _, p in g if p["지역"]})
|
|
bits = [f"기준 지역 {region or '없음'}" if sup else "관급 줄만"]
|
|
if len(g) > 1:
|
|
bits.append(f"{len(g)}줄 합침")
|
|
if len(regions) > 1 or (regions and region != regions[0]):
|
|
bits.append(f"지역 {len(regions)}곳")
|
|
if words:
|
|
bits.append("뗀 말 " + ", ".join(words))
|
|
r, info = best
|
|
note = "통합: " + " · ".join(bits)
|
|
new[id(r)] = place(
|
|
{
|
|
**r,
|
|
"이름": info["이름"],
|
|
"규격": info["규격"],
|
|
"비고": " · ".join(x for x in (r.get("비고"), note) if x),
|
|
},
|
|
info[LOAD],
|
|
)
|
|
gone = [o for o, _ in g if o is not r]
|
|
for o in gone:
|
|
new[id(o)] = None
|
|
log.append(
|
|
{"남은": new[id(r)], "원래": r, "지운": gone, "기준 지역": region, "규칙": rules}
|
|
)
|
|
out = []
|
|
for r in rows:
|
|
r = new.get(id(r), place(r, r.get(LOAD) or LOADS[0]))
|
|
if r is not None:
|
|
out.append({**r, "관급": None})
|
|
return out, log
|