Files
Aislo/M02_MasterTemplete/M02_Hopyo_Material.py
eomsangdonandClaude Opus 5.5 215cc1531d feat(778-5): 줄 잇기 한 벌 — 단위 줄 → 우리 계산 단위 + 상태(이어짐 · 고를 것 · 못 이음) · 일위대가 자재 맞춤 같은 함수(D-2 · 요구 622 · 623 · 624)
- 새 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>
2026-11-04 13:43:51 +09:00

310 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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