- 책 낱말 표(ReadEst.book_of) — 임도 · 사방 = 옛 책 「(옛)」 · 공통 · 토목 · 건축 · 기계설비 · 유지관리 = 「건설품셈 <부문>」 · 그 밖 건설품셈(부문 모름) 그대로 - 출처 풀이(Basis.settle · Excel.read_book 끝 한 곳 · 두 형식) — 건설품셈(부문 모름) 번호 → 블록 이름(안 맞으면 글 두 묶음)이 원문 제목 · 같은 번호 로직 이름과 뜻 낱말을 나누는 부문 하나 = 그 부문 · 둘 이상 = 모름 · 없음 = (옛) · 임도 옛 번호 → 지식DB 대응표 확정 한 대응(가 · 나 항 = 현행 윗마디 · 일대다 안 옮김) - 조항 견줌(Basis.clause) 한 줄 — 책 또는 구분 같음 · (옛) = 없음 - 기초 없음 표지(Basis.no_basis) — 출처 조항(윗 · 아랫마디 포함) 로직 0 = 「근거 조항 우리 기초에 없음 — 건설품셈 공통 1-2-7 운반」 · 옛 번호 = 「옛 번호 — 대응 미확인」 · 맞춤셈 꼬리표 · 맞음 아닌 후보 글 「참고 후보 — 근거 조항 아님」 · 단가산출 근거 꼬리표 1위 대신 표지(1위 맞음이면 1위) - 시험: 새 test_628 · test_621 단계 해시 다시 굳힘(달라진 칸 = 출처뿐 · S1 수 · 그래프 그대로) · test_m02_hopyo_basis · finger · stmate · excel 출처 기대값 새 책 꼴로(낡은 기대 「임도 = 산림품셈」 · 「건설품셈」 부문 없음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
15 KiB
Python
336 lines
15 KiB
Python
"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
|
|
우리 돈 로직과 견준 근거 후보.
|
|
|
|
읽기(`read` · `sources` · `trade_names` · `SHEET` · `GLINES` · `GCHARS`) = EST 어댑터
|
|
`M02_Hopyo_ReadEst`(776-2 · 이름은 그대로 다시 냄).
|
|
출처 풀이(`settle` · 781-1) = 건설품셈(부문 모름) 번호 → 뜻 낱말로 부문 ·
|
|
임도품셈(옛) → 대응표 현행 산림 번호 ·
|
|
기초 없음 표지(`no_basis` · 781-2) = 출처 조항에 우리 로직 0 · 옛 번호.
|
|
견줌(`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 csv
|
|
import re
|
|
from functools import lru_cache
|
|
from typing import Any
|
|
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
from M01_MasterData import M01_MasterData_Store_Make as make
|
|
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 in (our["책"], 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, [], ""
|
|
|
|
|
|
# ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ───────────
|
|
OLD = "(옛)"
|
|
_ROAD = "임도품셈" + OLD
|
|
_ROAD_MAP = make.BODY.parent / "경쟁사 프로그램/STmate 분석/30_원자료/임도품셈_판_대응표.csv"
|
|
_OLD_NO = re.compile(r"^(\d+(?:-\d+)+)(\s+[가-하])?$")
|
|
_STOP_GRAMS = frozenset("설치 해체 작업 인력 기계 장비 보완 신설 공사".split())
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def road_map() -> dict[str, str]:
|
|
"""임도품셈(옛) 번호 → 현행 산림 번호 — 지식DB 대응표 「확정」 한 대응만(일대다 · 미확인 뺌) ·
|
|
옛 번호가 가 · 나 항이면 현행 윗마디(9-13-7 → 9-13 · 그 항만 대응하므로)."""
|
|
with open(_ROAD_MAP, encoding="utf-8-sig", newline="") as f:
|
|
rows = list(csv.DictReader(f))
|
|
out = {}
|
|
for row in rows:
|
|
old, new = _OLD_NO.match(row["과거_절"].strip()), row["현행_절"].strip()
|
|
if row["판정"] != "확정" or not old or not _OLD_NO.fullmatch(new) or " " in new:
|
|
continue
|
|
out[old.group(1)] = new.rsplit("-", 1)[0] if old.group(2) and new.count("-") > 1 else new
|
|
return out
|
|
|
|
|
|
def _grams(text: Any) -> set[str]:
|
|
"""뜻 낱말 — 낱말 안 한글 두 글자 묶음(괄호 글 포함 · 굴삭 = 굴착)."""
|
|
words = re.findall(r"[가-힣]+", str(text or "").replace("굴삭", "굴착"))
|
|
return {w[i : i + 2] for w in words for i in range(len(w) - 1)} - _STOP_GRAMS
|
|
|
|
|
|
@lru_cache(maxsize=2)
|
|
def _sections(book: str) -> dict[tuple[str, str], str]:
|
|
"""{(부문, 절 번호): 제목(괄호 글 · 「:」 뒤 뺌)} — 원문 본문."""
|
|
return {
|
|
(s["division"], s["section"]): re.sub(r"\(.*?\)", "", s["title"]).split(":")[0].strip()
|
|
for s in make._all_sections(book)
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=2)
|
|
def _named(whole_id: int) -> dict[tuple[str, str], list[str]]:
|
|
"""{(구분, 원문번호): [로직 이름]} — M01 로직."""
|
|
out: dict[tuple[str, str], list[str]] = {}
|
|
for data in m01.loaded()[0].values():
|
|
if data.get("그룹") == "로직":
|
|
for row in data.get("줄") or []:
|
|
key = (str(row.get("구분") or ""), str(row.get("원문번호") or "").split(" ")[0])
|
|
out.setdefault(key, []).append(str(row.get("이름") or ""))
|
|
return out
|
|
|
|
|
|
def _part(number: str, name: Any, text: Any = "") -> str:
|
|
"""건설품셈(부문 모름) 번호 → 원문에 그 번호가 있는 부문 중 블록 이름(안 맞으면 블록 글 ·
|
|
두 묶음)이 원문 제목 · 같은 번호 로직 이름과 뜻 낱말을 나누는 부문이 하나 = 그 부문 ·
|
|
둘 이상 = 모름 · 없음 = 「(옛)」(번호 뜻 안 맞음)."""
|
|
if not _grams(name):
|
|
return "건설품셈"
|
|
named = _named(id(m01.loaded()[1]))
|
|
texts = {
|
|
part: [title, *named.get((f"건설품셈 {part}", no), [])]
|
|
for (part, no), title in _sections("건설품셈").items()
|
|
if no == number
|
|
}
|
|
hits = []
|
|
for mine, least in ((_grams(name), 1), (_grams(text), 2)): # 글은 두 묶음 이상
|
|
hits = [p for p, ts in texts.items() if any(len(mine & _grams(t)) >= least for t in ts)]
|
|
if hits:
|
|
break
|
|
if len(hits) == 1:
|
|
return f"건설품셈 {hits[0]}"
|
|
return "건설품셈" if hits else "건설품셈" + OLD
|
|
|
|
|
|
def resolve(theirs: list[list[str]], name: Any, text: Any = "") -> list[list[str]]:
|
|
"""블록 출처 → 부문 풀이 · 임도 옛 번호 대응(`road_map`) 뒤 출처."""
|
|
out: list[list[str]] = []
|
|
for book, number in theirs:
|
|
one = [book, number]
|
|
if book == _ROAD and number in road_map():
|
|
one = ["산림품셈", road_map()[number]]
|
|
elif book == "건설품셈":
|
|
one = [_part(number, name, text), number]
|
|
if one not in out:
|
|
out.append(one)
|
|
return out
|
|
|
|
|
|
def settle(book: dict[str, Any]) -> dict[str, Any]:
|
|
"""Book 단산 블록 출처를 제자리에서 풀이(줄 `근거.출처` 가 같은 리스트를 가리킴)."""
|
|
for block in book.get("단산") or []:
|
|
if block.get("출처"):
|
|
text = " ".join(str(t) for t in block.get("글") or [])
|
|
block["출처"][:] = resolve(block["출처"], block.get("이름"), text)
|
|
return book
|
|
|
|
|
|
def has_logic(kind: str, number: str) -> bool:
|
|
"""그 구분 · 조항(윗마디 · 아랫마디 포함)에 M01 로직이 있나."""
|
|
return _has(id(m01.loaded()[1]), kind, number)
|
|
|
|
|
|
@lru_cache(maxsize=512)
|
|
def _has(whole_id: int, kind: str, number: str) -> bool:
|
|
named = _named(whole_id)
|
|
parts = number.split("-")
|
|
ups = {"-".join(parts[:i]) for i in range(2, len(parts) + 1)}
|
|
return any(k == kind and (no in ups or no.startswith(number + "-")) for k, no in named)
|
|
|
|
|
|
def no_basis(theirs: list | None) -> dict[str, str] | None:
|
|
"""출처 조항이 모두 우리 기초 밖이면 표지 `{판정, 글, 이름}` — 기초 없음(로직 0) › 옛 번호
|
|
(「(옛)」 · 지금 원문에 없는 번호) · 로직 있는 조항 · 건설품셈(부문 모름)이 하나라도
|
|
있으면 None."""
|
|
none, old = [], []
|
|
for book, number in theirs or []:
|
|
base, _, part = book.partition(" ")
|
|
if book == "건설품셈":
|
|
return None
|
|
if book.endswith(OLD) or base not in make.BOOKS:
|
|
old.append((book.removesuffix(OLD), number))
|
|
elif has_logic(book, number):
|
|
return None
|
|
elif (title := _sections(base).get((part, number))) is None:
|
|
old.append((book, number))
|
|
else:
|
|
none.append((book, number, title))
|
|
if none:
|
|
book, number, title = none[0]
|
|
where = f"{book.replace('품셈', '')} {number} {title}"
|
|
return {
|
|
"판정": "기초 없음",
|
|
"글": f"근거 조항 우리 기초에 없음 — {book} {number} {title}",
|
|
"이름": f"기초 없음 — {where}",
|
|
}
|
|
if old:
|
|
return {
|
|
"판정": "옛 번호",
|
|
"글": f"옛 번호 — 대응 미확인 — {old[0][0]} {old[0][1]}",
|
|
"이름": "옛 번호 — 근거 못 씀",
|
|
}
|
|
return None
|
|
|
|
|
|
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)]
|