Files
Aislo/M02_MasterTemplete/M02_Hopyo_Material.py
T
eomsangdonandClaude Opus 5.5 dd9ebad8d1 feat(830-10): 불러오기 재료 찾기를 지역 · 상하차 칸에 맞춤 — 가져온 줄 글은 빌더 규칙으로 판독
- 새 M02_Mat_Place: 가져온 이름 · 규격 → (뺀 이름, 뺀 규격, 지역, 상하차)(material_merge 판독 한 벌) · 고르는 차례 rank
- 고르는 차례 = 상하차 맞음(글 없으면 -) › 값 있는 줄 › 줄 지역 › 전국 · 지역 모름 › 서울 › 경기 › 그 밖(값 가운데에 가까운 줄)
- Elem_Alias(_same · one · region_of) · Hopyo_Match(목록에 지역 · 상하차 · 값 · 이름 같음 = 뺀 이름 + 지역 · 상하차 같음) · Hopyo_Material(_places · 변형 지역 = 지역 칸 · _prefer) · MatSame._pick · MatFind 후보 글 = 지역 칸
- 다시 고르기 · 찾기 창 · 줄 풍선말 후보 글에 지역 · 상하차(M01 placeNote 한 벌)
- 시험: 814_1 · m02_hopyo_match 기대를 지역 칸 꼴로 · 새 지킴 test_830_10_material_region 21
- 불러오기 지킴 812_1 · 814_1 · 815_1 · 817_1 · 827_2 · 827_3 · 828_1 · 828_2 · 778_5 ×2 · 793_2 ×2 · 797_1 ×2 · m02_hopyo_match 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:57:23 +09:00

316 lines
14 KiB
Python
Raw 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 · 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:
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["규격"])
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, [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) 차례(`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