- 새 M02_Hopyo_ReadEst.py — Basis.read(엑셀 「단가산출」 블록) · trade_names · sources 옮김 + 글 → 항목 풀이(L 직종 · X 장비 · M 자재 · 줄 율에 기대지 않는 것만) - Basis — 근거 후보만 남김 · 옛 이름(read · SHEET · GLINES · GCHARS · sources · trade_names)은 그대로 다시 냄 - Stmate — 블록 금액(머리 줄 C~F) · 항목(자원 + 단가 I~L · 금액 C~F · 기계 셋 줄 더함 · 행) · 항목 단가 639 중 635 - 항목만으로 지문 인력 · 장비 · 자재 다시 세움 = 지금 지문과 544 쌍 모두 같음(tmp 대조 · 776-3 길) - S1 · S2 해시 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
8.4 KiB
Python
187 lines
8.4 KiB
Python
"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
|
|
우리 돈 로직과 견준 근거 후보.
|
|
|
|
읽기(`read` · `sources` · `trade_names` · `SHEET` · `GLINES` · `GCHARS`) = EST 어댑터
|
|
`M02_Hopyo_ReadEst`(776-2 · 이름은 그대로 다시 냄).
|
|
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) ·
|
|
앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 ·
|
|
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
|
|
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이
|
|
하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 ·
|
|
다섯 안에 이름 1순위 하나는 늘 남김(`pick`).
|
|
자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`.
|
|
M01 · 원본 로직은 읽기만.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀
|
|
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
|
from M02_MasterTemplete.M02_Hopyo_ReadEst import ( # noqa: F401 — 읽기는 EST 어댑터(776-2) · 옛 이름 그대로
|
|
GCHARS,
|
|
GLINES,
|
|
SHEET,
|
|
read,
|
|
sources,
|
|
trade_names,
|
|
)
|
|
|
|
COMMON = "보통인부"
|
|
BONUS_PARTS = ("공통", "토목")
|
|
_TYPOS = (("깍기", "깎기"),)
|
|
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
|
|
|
|
|
|
def _flat(value: Any) -> str:
|
|
return re.sub(r"\s+", "", str(value or ""))
|
|
|
|
|
|
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
|
|
@lru_cache(maxsize=2)
|
|
def _ours(whole_id: int) -> dict[str, dict[str, Any]]:
|
|
"""{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 ·
|
|
직종 = 호표 인력(LB) 요소."""
|
|
files, whole = m01.loaded()
|
|
lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()}
|
|
out = {}
|
|
for data in files.values():
|
|
if data.get("그룹") != "로직":
|
|
continue
|
|
for row in data.get("줄") or []:
|
|
kind = str(row.get("구분") or "")
|
|
book, _, part = kind.partition(" ")
|
|
trades = {
|
|
lb[str(h.get("요소"))]
|
|
for h in row.get("호표") or []
|
|
if isinstance(h, dict) and str(h.get("요소")) in lb
|
|
}
|
|
clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or "")))
|
|
out[str(row.get("키"))] = {
|
|
"책": book,
|
|
"부문": part,
|
|
"구분": kind,
|
|
"조항": clauses,
|
|
"직종": trades,
|
|
}
|
|
return out
|
|
|
|
|
|
def ours() -> dict[str, dict[str, Any]]:
|
|
return _ours(id(m01.loaded()[1]))
|
|
|
|
|
|
def _head(number: str) -> str:
|
|
return number.rsplit("-", 1)[0]
|
|
|
|
|
|
def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
|
|
"""엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글,
|
|
꼬리표) — 같은 책만 · 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5."""
|
|
nums = [n for book, n in theirs if book == our["책"]]
|
|
same = sorted(n for n in nums if n in our["조항"])
|
|
if same:
|
|
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
|
kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")})
|
|
if kids:
|
|
return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음"
|
|
heads = {_head(n) for n in nums}
|
|
near = sorted(n for n in our["조항"] if _head(n) in heads)
|
|
if near:
|
|
return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음"
|
|
return 0.0, [], ""
|
|
|
|
|
|
def _text(line: dict[str, Any]) -> str:
|
|
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
|
|
for old, new in _TYPOS:
|
|
text = text.replace(old, new)
|
|
return text
|
|
|
|
|
|
def _loose_words(text: str) -> frozenset[str]:
|
|
"""괄호 글까지 뜻 낱말 — 방법 낱말 뺌."""
|
|
return hopyo_match.words(text.replace("(", " ").replace(")", " ")) - _PLAIN_WORDS
|
|
|
|
|
|
def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 근거, _겹침, _낱말}`
|
|
점수 차례(`_` 칸은 `merge` 가 쓰고 뺌)."""
|
|
basis = line.get("근거") or {}
|
|
theirs = [list(one) for one in basis.get("출처") or []]
|
|
if not theirs:
|
|
return []
|
|
trades = set(basis.get("직종") or []) - {COMMON}
|
|
text = _text(line)
|
|
mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」
|
|
grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름
|
|
loose = _loose_words(text)
|
|
table = ours()
|
|
scored = []
|
|
for one in got["로직"]:
|
|
our = table.get(one["로직"])
|
|
if not our:
|
|
continue
|
|
score, clauses, tag = clause(theirs, our)
|
|
if not score:
|
|
continue
|
|
shared = sorted(trades & our["직종"])
|
|
if not shared and not any(a in b or b in a for a in mine for b in one["words"]):
|
|
continue
|
|
tags = [tag]
|
|
if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS:
|
|
score += 0.1
|
|
tags.append(f"건설 {our['부문']}")
|
|
if shared:
|
|
score += 0.5
|
|
tags.append("직종 " + " · ".join(shared))
|
|
unit = hopyo_match.fits(line.get("단위"), one["단위"])
|
|
if unit:
|
|
score += 0.15
|
|
tags.append("단위 맞음")
|
|
dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
|
|
why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)}
|
|
theirs_loose = _loose_words(one["이름"])
|
|
overlap = any(a in b or b in a for a in loose for b in theirs_loose)
|
|
full = tag != "앞 마디 같음" and unit and (bool(shared) or not trades) # 근거 맞음
|
|
why["까닭"] = " · ".join(tags)
|
|
extra = {"_겹침": dice, "_낱말": overlap or full}
|
|
scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra}))
|
|
scored.sort(key=lambda x: (-x[0], -x[1]))
|
|
return [one for _, _, one in scored]
|
|
|
|
|
|
def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례 ·
|
|
같으면 이름 겹침 큰 쪽 · 이름 · 낱말 안 겹치는 근거 후보는 겹치는 근거 후보 뒤로."""
|
|
if not basis:
|
|
return named
|
|
by_name = {c.get("로직"): c["점수"] for c in named}
|
|
out = []
|
|
for c in basis:
|
|
named_too = c["로직"] in by_name
|
|
score = round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)
|
|
out.append({**c, "점수": score, "_낱말": c["_낱말"] or named_too})
|
|
out += [c for c in named if c.get("로직") in by_name]
|
|
out.sort(key=lambda c: (-c["점수"], -c.get("_겹침", 0)))
|
|
loose = [c for c in out if c.get("_낱말") is False]
|
|
if loose and any(c.get("_낱말") for c in out):
|
|
out = [c for c in out if c.get("_낱말") is not False]
|
|
last = max(at for at, c in enumerate(out) if c.get("_낱말"))
|
|
out[last + 1 : last + 1] = loose
|
|
return [{k: v for k, v in c.items() if not k.startswith("_")} for c in out]
|
|
|
|
|
|
def pick(ordered: list[dict[str, Any]], first: Any, room: int) -> list[dict[str, Any]]:
|
|
"""후보 `room` 개 — 이름 1순위(`first` 로직 키) 하나는 늘 남기고 나머지는 차례대로."""
|
|
if room <= 0:
|
|
return []
|
|
if first is None or all(c.get("로직") != first for c in ordered):
|
|
return ordered[:room]
|
|
others = [c for c in ordered if c.get("로직") != first][: room - 1]
|
|
return [c for c in ordered if c.get("로직") == first or any(c is o for o in others)]
|