Files
Aislo/M02_MasterTemplete/M02_Hopyo_Material.py
T
eomsangdonandClaude Opus 5.5 3abb10cc13 fix(M02 실무자료): 361-1 전국 자재 = 확인 창 자재 표 · 지역 변형 차례 = (전국) 낱말은 지역 없는 품목에만
- 확인 창 표 가름(`tableOf`): 전국 대상도 자재 표(코드 칸 「전국」) · 미리 셈 문서엔 안 넣음(키 · 단가 없음)
- 변형 차례: 「전지역」 낱말 든 지역 품목이 접힌 목록 앞에 오던 것 → (전국) 먼저는 지역 없는 품목에만
- 시험: 화면 도우미 전국 표 · 미리 셈 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 23:23:11 +09:00

291 lines
13 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)
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 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
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]]:
"""맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) ·
아니면 ([], [])."""
_, index = pool()
rows = [
r
for r in index.get(base(line.get("이름")), [])
if 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