- 확인 창 표 가름(`tableOf`): 전국 대상도 자재 표(코드 칸 「전국」) · 미리 셈 문서엔 안 넣음(키 · 단가 없음) - 변형 차례: 「전지역」 낱말 든 지역 품목이 접힌 목록 앞에 오던 것 → (전국) 먼저는 지역 없는 품목에만 - 시험: 화면 도우미 전국 표 · 미리 셈 없음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
291 lines
13 KiB
Python
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
|