- 행정구역 표 = 시도 17(짧은 이름) + 전국 · 시도별 시군구 229(자치 227 + 제주 행정시 2 · 2026-07-01 인천 제물포 · 영종 · 검단 반영) · 옛 이름(강원도 · 전라북도 · 청원 · 마산 · 진해 · 연기 · 인천 남구 · 중구 · 동구 · 경북 군위) · 별칭(영종도 · 용유도 · 세종시 · 제주도) - 풀이 master_region.split — 시도만 = 시군구 「전체」 · 시군구만 = 시도 찾음 · 읍면 = 시군구(읍면) · 여러 시도 = 쉼표 · 제외 조건 = 끝에 「 제외」 · 조합 이름 = 비고 · 잘린 끝 낱말 = 하나뿐인 시군구(동두 → 동두천 · 금 → 금천) - 지역 글 242 종 모두 풀림(못 푼 것 0) · 옛 이름 바꾼 줄 648 · 줄 · 키 · 값 그대로(24,027 · 비고만 조합 121 줄) - 쓰는 곳: 지역 글 = material_merge.where(시도 + 시군구) — M01 찾기(label · 검색어 · 자재지역) · 표 찾기 · 후보 글 · 불러오기(M02_Mat_Place · Hopyo_Match · Hopyo_Material — 가져온 지역 낱말도 짧은 이름으로 · 시군구가 있으면 그것으로 맞댐) - M01 화면: 시도 = 고르기(목록 밖 값 「대전,충남」 은 끝에) · 시군구 = 글 · 새 줄 - · - - 로직 1,520 시험 계산 전후 다름 0 · check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음) - 빌더 adopt 가 로직 장 파일 「차례」 를 하나씩 미는 것은 이번 일 밖 — 되돌림(로직 파일 손대지 않음) - 새 지킴 test_830_11_material_sido · 낡은 시험 고침: test_830_10 · test_830_2(열 · 지역 기대를 두 칸으로 · 830-11 새 겹침 38 묶음 = 청원 → 청주 4 · 조합 비고 34) · test_m02_hopyo_match(강원도(강릉시) → 강원 강릉) - 틀 문서 _틀.md 11 절 행정구역 표 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
14 KiB
Python
324 lines
14 KiB
Python
"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름.
|
||
|
||
줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm.
|
||
바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘).
|
||
규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가
|
||
자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) ·
|
||
줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함.
|
||
맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐
|
||
(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 먼저 · 없으면
|
||
별도계상 줄 = 관급 · 재료비 줄 = 사급).
|
||
지역(361-1 · 830-10) = 우리 줄은 지역 칸 · 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는
|
||
변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`national` ·
|
||
바탕 이름(전국)-관급/사급 · 규격 · 단위 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 자동 고름 없음 ·
|
||
지역 변형은 고르기 창에서 사용자가.
|
||
그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료).
|
||
가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른
|
||
자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import unicodedata
|
||
from functools import lru_cache
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||
from M02_MasterTemplete import M02_Mat_Place as place
|
||
|
||
mm = hm.mm
|
||
APART = "별도계상"
|
||
SUPPLY = ("관급", "사급")
|
||
NO_REGION = ("전국", "전지역")
|
||
REGIONS = (
|
||
*"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(),
|
||
*"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(),
|
||
*"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(),
|
||
*"여주 이천 가평 연천".split(),
|
||
)
|
||
SKIP_WORDS = frozenset({"조합공통품목"})
|
||
NATIONAL = "전국"
|
||
NATIONAL_TAG = "전국 — 지역은 프로젝트에서"
|
||
REGION_TAG = "지역 — 줄 글"
|
||
_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?")
|
||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||
_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)")
|
||
_TAIL = re.compile(r"[\s,\-]*([가-힣]+)$")
|
||
SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001)
|
||
_N = r"\d+(?:\.\d+)?"
|
||
_DIMS = re.compile(rf"(?i)((?:{_N})(?:\s*[×xX*]\s*{_N})*)\s*(?:mm|cm|m|㎜|㎝|㎡|㎥|kg|t|ton)?")
|
||
|
||
|
||
def _nfkc(text: Any) -> str:
|
||
return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm")
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _places() -> frozenset[str]:
|
||
"""곳 낱말 — M01 레미콘 줄 시도 · 시군구 칸의 지역 · 시군 낱말(자료에서 · 「홍천」 · 「미원면」)."""
|
||
names = (one["지역"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘"))
|
||
words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))}
|
||
return frozenset(words - {"레미콘", "조합공통품목", "제외", "전체", *SUPPLY})
|
||
|
||
|
||
def _region(word: str) -> bool:
|
||
places = _places()
|
||
return (
|
||
word.startswith(REGIONS)
|
||
or word in NO_REGION
|
||
or word in places
|
||
or place.mmerge.mr.short(word) in places
|
||
)
|
||
|
||
|
||
def _paren_words(name: str) -> set[str]:
|
||
return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)}
|
||
|
||
|
||
def _bare(name: Any) -> str:
|
||
"""괄호 글 · 끝 지역 · 관급/사급 꼬리 뗀 글(정규화 전 · 「레미콘(서울)-사급」 → 레미콘)."""
|
||
text = _nfkc(name)
|
||
while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text:
|
||
text = cut
|
||
while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))):
|
||
if m.start() == 0:
|
||
break
|
||
text = text[: m.start()]
|
||
for w in SUPPLY:
|
||
text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text
|
||
return text.strip()
|
||
|
||
|
||
def base(name: Any) -> str:
|
||
"""바탕 이름 — `_bare` 정규화."""
|
||
return mm.plain(_bare(name))
|
||
|
||
|
||
def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||
"""(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시."""
|
||
out, apart = dict(line), line.get("갈래") == APART
|
||
for k in ("이름", "규격"):
|
||
text = str(line.get(k) or "")
|
||
if _APART.search(text):
|
||
apart, text = True, _APART.sub("", text).strip(" ,")
|
||
out[k] = text
|
||
return out, apart
|
||
|
||
|
||
def _nums(text: str) -> list[float]:
|
||
return [float(x) for x in _NUM.findall(text)]
|
||
|
||
|
||
def _ranges(text: str) -> list[tuple[float, bool, float, bool]]:
|
||
return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)]
|
||
|
||
|
||
def _words(text: str) -> frozenset[str]:
|
||
return (
|
||
frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY)
|
||
- SKIP_WORDS
|
||
)
|
||
|
||
|
||
@lru_cache(maxsize=2)
|
||
def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
|
||
"""자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]})."""
|
||
rows, index = [], {}
|
||
for at, one in enumerate(hm.targets()["자재"]):
|
||
name, spec = _nfkc(one["이름"]), _nfkc(one["규격"])
|
||
row = {
|
||
"one": one,
|
||
"at": at,
|
||
"base": base(name),
|
||
"nums": _nums(spec),
|
||
"ranges": _ranges(spec),
|
||
"spec": mm.plain_spec(spec),
|
||
"region": sorted(place.words(one["지역"])), # 830-10 지역 칸
|
||
"supply": next((w for w in SUPPLY if w in name), None),
|
||
"key": (
|
||
tuple(sorted(_nums(spec))),
|
||
mm.plain_unit(one["단위"]),
|
||
_words(spec) | _words(" ".join(_paren_words(name))),
|
||
),
|
||
}
|
||
row["grams"] = hm._grams(row["base"])
|
||
rows.append(row)
|
||
index.setdefault(row["base"], []).append(row)
|
||
return rows, index
|
||
|
||
|
||
def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
|
||
return _pool(id(hm.targets()))
|
||
|
||
|
||
def _near(x: float, y: float) -> bool:
|
||
return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES)
|
||
|
||
|
||
def dims(spec: Any) -> tuple[float, ...] | None:
|
||
"""숫자 규격(778-5 · 요구 624-2) → 치수 차례 무관 묶음 — 「600×1200」 = 「1200x600 mm」 · 구분자
|
||
× x X * · 끝 단위 하나 · 글 섞이면 None."""
|
||
hit = _DIMS.fullmatch(_nfkc(spec).strip())
|
||
return tuple(sorted(_nums(hit.group(1)))) if hit else None
|
||
|
||
|
||
def lettered(spec: Any) -> bool:
|
||
"""글자 든 규격(한글 낱말 · 지역 · 관급/사급 · 별도계상 뺌) — 같은 규격 판정 대신 사용자 확인."""
|
||
return bool(_words(_APART.sub("", _nfkc(spec))))
|
||
|
||
|
||
def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float:
|
||
"""규격 맞음 0~1 — 같음 · 숫자 규격 치수 같음(차례 · 구분자 무관) · 범위에 듦 · 줄 규격 수가 자재
|
||
규격 수에 있는 몫."""
|
||
text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}")
|
||
if mm.plain_spec(line.get("규격")) == row["spec"]:
|
||
return 1.0
|
||
a = dims(line.get("규격"))
|
||
if a and a == dims((row.get("one") or {}).get("규격")):
|
||
return 1.0
|
||
mine = _nums(text)
|
||
if row["ranges"]:
|
||
inside = any(
|
||
(a < x or (lo and a == x)) and (x < b or (hi and x == b))
|
||
for x in mine
|
||
for a, lo, b, hi in row["ranges"]
|
||
)
|
||
return 1.0 if inside else 0.0
|
||
if not mine or not row["nums"]:
|
||
return 0.0
|
||
# 827-2 · 827-6 줄 수와 그대로 같은 자재 수는 그 줄 수 몫(「d=320~400 t=3.2」 의 320 이
|
||
# 3.2 를 다시 못 씀) · 배율로만 맞는 수는 여럿이 같이 씀(글 붙은 「#200.9mm200.4m/kg」
|
||
# = #20 0.9mm 200.4m/kg)
|
||
mine, rest = list(dict.fromkeys(mine)), list(row["nums"])
|
||
same = []
|
||
for x in mine:
|
||
if x in rest:
|
||
rest.remove(x)
|
||
same.append(x)
|
||
return sum(x in same or any(_near(x, y) for y in rest) for x in mine) / len(mine)
|
||
|
||
|
||
def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
|
||
"""(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급."""
|
||
text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}")
|
||
words = set(re.findall(r"[가-힣]+", text))
|
||
supply = next((w for w in SUPPLY if w in text), None)
|
||
if supply is None:
|
||
supply = (
|
||
"관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None
|
||
)
|
||
return supply, [
|
||
place.norm(w) for w in words if _region(w) and w not in NO_REGION
|
||
] # 830-11 짧은 이름
|
||
|
||
|
||
def _hit(r: dict[str, Any], regions: list[str]) -> bool:
|
||
return any(w[:2] == x[:2] for w in regions for x in r["region"])
|
||
|
||
|
||
def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""변형 차례 — 줄 글의 관급/사급 › 상하차 › 지역(줄 지역 › 전국 · 지역 없음 › 서울 › 경기 › 값 가운데) ›
|
||
엑셀(M01) 차례(`M02_Mat_Place.rank` · 830-10)."""
|
||
supply, regions = _wants(line)
|
||
load = place.line(line.get("이름"), line.get("규격"))["상하차"]
|
||
mid = place.middle([r["one"] for r in rows])
|
||
|
||
def rank(r: dict[str, Any]) -> tuple:
|
||
*head, gap = place.rank(r["one"], regions, load, mid)
|
||
return (supply is not None and r["supply"] not in (None, supply), *head, gap, r["at"])
|
||
|
||
return sorted(rows, key=rank)
|
||
|
||
|
||
def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool:
|
||
return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"]))
|
||
|
||
|
||
def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
|
||
"""맞음 ② — 바탕 이름 같음 · 단위(줄에 없으면 안 봄 · EST 블록 줄) · 괄호 낱말 · 규격 맞음 1 이 한
|
||
품목 → (변형 차례, 꼬리표) · 아니면 ([], [])."""
|
||
_, index = pool()
|
||
rows = [
|
||
r
|
||
for r in index.get(base(line.get("이름")), [])
|
||
if (not line.get("단위") or hm.fits(line.get("단위"), r["one"]["단위"]))
|
||
and _paren_ok(line, r)
|
||
]
|
||
full = [r for r in rows if spec_fit(line, r) == 1.0]
|
||
unit = mm.plain_unit(line.get("단위"))
|
||
full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면)
|
||
if not full or len({r["key"] for r in full}) > 1:
|
||
return [], []
|
||
full = _prefer(full, line)
|
||
supply, regions = _wants(line)
|
||
if any(_hit(r, regions) for r in full):
|
||
return full, [REGION_TAG]
|
||
if len(full) == 1 or any(not r["region"] for r in full):
|
||
return full, []
|
||
return [_national(full[0], supply), *full], [NATIONAL_TAG]
|
||
|
||
|
||
def _national(row: dict[str, Any], supply: str | None) -> dict[str, Any]:
|
||
"""「전국」 자리표(361-1) — 지역 변형만 있는 품목 · 키 · 단가 없음 · 이름(전국)-관급/사급."""
|
||
one = row["one"]
|
||
name = f"{_bare(one['이름'])}({NATIONAL})" + (f"-{supply}" if supply else "")
|
||
spec = _nfkc(one["규격"])
|
||
for w in SKIP_WORDS: # 「조합공통품목, 25-21-120」(관급) → 25-21-120
|
||
spec = re.sub(rf"{w}[\s,]*", "", spec)
|
||
return {"one": {"전국": True, "이름": name, "규격": spec.strip(" ,"), "단위": one["단위"]}}
|
||
|
||
|
||
def national(line: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""자재 · 별도계상 줄(다듬기 전)의 「전국」 자리표 — 없으면 None(만들기 · 미리 셈 재검사)."""
|
||
if line.get("갈래") not in hm.MATERIAL_KINDS:
|
||
return None
|
||
rows, _ = sure(clean(line)[0])
|
||
return next((r["one"] for r in rows if r["one"].get("전국")), None)
|
||
|
||
|
||
def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
|
||
"""자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 ·
|
||
뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수)."""
|
||
rows, _ = pool()
|
||
me = base(line.get("이름"))
|
||
grams, mine = hm._grams(me), hm.words(line.get("이름"))
|
||
parens = _paren_words(_nfkc(line.get("이름")))
|
||
scored: dict[tuple, list] = {}
|
||
for r in rows:
|
||
if not grams & r["grams"]:
|
||
continue
|
||
fit = spec_fit(line, r)
|
||
if not hm.near(mine, r["one"]["words"]) and fit < 0.5:
|
||
continue
|
||
score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"]))
|
||
score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"])
|
||
score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0)
|
||
fold = (r["base"], *r["key"])
|
||
have = scored.setdefault(fold, [score, []])
|
||
have[0] = max(have[0], score)
|
||
have[1].append(r)
|
||
best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit]
|
||
out = []
|
||
for score, group in best:
|
||
first = _prefer(group, line)[0]
|
||
one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"}
|
||
out.append(
|
||
{**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one
|
||
)
|
||
return out
|
||
|
||
|
||
def marks(line: dict[str, Any], name: str = "") -> dict[str, str]:
|
||
"""가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` →
|
||
줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본)."""
|
||
_, apart = clean(line)
|
||
out = {"별도계상": APART if apart else "직접계산"}
|
||
supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0]
|
||
if supply:
|
||
out["자재구분"] = supply
|
||
text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}"))
|
||
if "운반비미포함" in text or "운반비별도" in text:
|
||
out["운반비"] = "미포함"
|
||
return out
|