- 새 M02_Hopyo_Units: 노무 LB(같은 이름 여럿 = 건설업 먼저 · 작업반장) · 기계 EQ(Fixed.Linker) → 기계경비 기초 로직 줄(778-2) · 없는 규격 = 가까운 후보만 · 자재 MT(별칭 장부 › 이름 · 규격 › 바탕 이름 · 숫자 규격 · 이름에 붙은 규격 · 글자 규격 = 고를 것) · 손료율 방법 · 율 778-4 꼴(기준꼴 → 묶음 · 비목 · 줄 목록) · 하위 · 값 - Material: 숫자 규격 치수 차례 · 구분자 무관 견줌(dims) · 글자 규격 판별(lettered) · 단위 없는 줄(EST 블록) = 단위 안 봄 - Line._material = Units.material — 일위대가 자재 체크 제안 147 → 146(봉화 광명단조합페인트 「1종2류(철재)」 = 글자 규격 → 후보 · 요구 624-2) - 여덟 파일 불리는 블록 줄 842: 노무 366/366 · 기계 180/190 · 자재 71/130 · 율 150/152 · 하위 3/3 · 값 1/1 · 줄 다 이어진 블록 156/206 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
14 KiB
Python
310 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) = 줄 글에 지역 낱말 → 그 지역 변형(꼬리표 「지역 — 줄 글」) · 없으면 지역 없는
|
||
변형((전국) 품목) · 그것도 없으면(레미콘) 「전국」 = 키 · 단가 없는 자리표(`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
|
||
|
||
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:
|
||
return word.startswith(REGIONS) or word in NO_REGION or 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["규격"])
|
||
words = set(re.findall(r"[가-힣]+", name))
|
||
row = {
|
||
"one": one,
|
||
"at": at,
|
||
"base": base(name),
|
||
"nums": _nums(spec),
|
||
"ranges": _ranges(spec),
|
||
"spec": mm.plain_spec(spec),
|
||
"region": sorted(w for w in words if _region(w) and w not in NO_REGION),
|
||
"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
|
||
return sum(any(_near(x, y) for y in row["nums"]) 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, [w for w in words if _region(w) and w not in NO_REGION]
|
||
|
||
|
||
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) 차례."""
|
||
supply, regions = _wants(line)
|
||
|
||
def rank(r: dict[str, Any]) -> tuple:
|
||
hit = _hit(r, regions)
|
||
return (
|
||
supply is not None and r["supply"] not in (None, supply),
|
||
bool(regions) and not hit,
|
||
bool(r["region"]),
|
||
bool(r["region"]) or not any(w in r["one"]["이름"] for w in NO_REGION), # (전국) 먼저
|
||
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
|