Files
Aislo/M02_MasterTemplete/M02_Hopyo_Basis.py
T
eomsangdonandClaude Opus 5.5 193e03927b feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글)
- 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0
- Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21
- Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안)
- Grid STmate 시트 · 숨은 칸 글 뺌
- 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:28:57 +09:00

296 lines
13 KiB
Python

"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
우리 돈 로직과 견준 근거 후보.
읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터
다음 머리 앞까지 한 블록 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은
그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열).
견줌(`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
SHEET = "단가산출"
COMMON = "보통인부"
BONUS_PARTS = ("공통", "토목")
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
_BOOK = re.compile(r"품[셈줌]")
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
GLINES, GCHARS, WIDE = 200, 300, 140
_TYPOS = (("깍기", "깎기"),)
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
def _cell_text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value).strip() if value is not None else ""
@lru_cache(maxsize=2)
def _trade_names(whole_id: int) -> dict[str, str]:
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
out: dict[str, str] = {}
for row in m01.loaded()[1].numbers.get("LB", {}).values():
name = str(row.get("이름") or "")
out[_flat(name)] = name
for old in row.get("옛이름") or []:
out.setdefault(_flat(old), name)
return out
def trade_names() -> dict[str, str]:
return _trade_names(id(m01.loaded()[1]))
def sources(text: str) -> list[list[str]]:
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
out = []
for hit in _BOOK.finditer(text):
num = labor_inputs._NUMBER.search(text, hit.end())
if not num or num.start() - hit.end() > 20:
continue
before = text[max(0, hit.start() - 12) : hit.start()]
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
typo = _TYPO_CLAUSE.match(num.group())
number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group()
if [book, number] not in out:
out.append([book, number])
return out
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "산출근거" in flat:
last = len(flat) - 1
money = {
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
}
return at + 1, flat.index("산출근거"), money
return 0, 1, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}`
(행 = 엑셀 1부터)."""
if not rows:
return {}
start, col, money = _columns(rows)
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
names = trade_names()
blocks: dict[int, dict[str, Any]] = {}
block: dict[str, Any] | None = None
for at, row in enumerate(rows[start:], start + 1):
text = row[col] if col < len(row) else None
head = None
if isinstance(text, str) and text.strip():
head = None if text[:1].isspace() else _HEAD.match(text)
else:
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
if not text:
continue
if head:
block = blocks.setdefault(
int(head.group(1)),
{
"번": int(head.group(1)),
"이름": head.group(2),
"단위": head.group(3).strip(),
"행": [at, at],
"출처": [],
"직종": [],
"글": [],
"금액": {
key: v
for key, c in money.items()
for v in [row[c] if c < len(row) else None]
if isinstance(v, (int, float)) and not isinstance(v, bool)
},
},
)
continue
if block is None:
continue
block["행"][1] = at
if len(block["글"]) < GLINES:
block["글"].append(text.rstrip()[:GCHARS])
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
trade = _TRADE.match(text)
name = names.get(_flat(trade.group(1))) if trade else None
if name and name not in block["직종"]:
block["직종"].append(name)
return blocks
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
@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)]