Files
Aislo/M02_MasterTemplete/M02_Hopyo_Basis.py
eomsangdonandClaude Opus 5.5 0c2f12c23f refactor(776-2): EST 단가산출 읽기 어댑터 떼어 냄 · 두 형식 블록 항목 · STmate 블록 단가 · 금액
- 새 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>
2026-11-04 08:48:10 +09:00

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)]