refactor(776-3): 지문 형식 모름 — 단위 줄 투영 한 벌 · 몫 한 벌 · 블록 단가 = 항목 · clause 대체 몸 · 오타 정규식 걷음 · 맞춤 캐시 = HP 지문 칸

- Finger — 풀이 두 벌(_text · _stmate) 걷고 블록 단위 줄 투영 하나(_units) · 글은 조건 · 옛 판 · 계수 · 「…의 N %」 만 · 셈 밖 줄 안 씀
- Shares — 몫 = 단위 줄을 절(제목 · 윗절)로 묶음 한 벌 · 식 다시 풀기(qty_of · sections_stm) 걷음 · trade = ReadEst.trade
- Elem.prices = 단위 줄 단가(글 다시 안 풂 · machines 갈래 안 건드림)
- Match.clause = Basis.clause 만 · 대체 몸 · _TYPO_CLAUSE 걷음(오타는 읽기 ReadEst.sources) · fix_typo = 사본(Line 이 부름)
- Fit.cache_path — HP 지문 칸(올린 엑셀 sha256 + 판) · 파일 그대로면 다시 안 읽음(lru) · 붙여넣기 HP 만 파일 해시
- 지문 전후 = 셈 단위 275 중 150 같음 · 다름 = 장비 절 · 글(쓰는 곳 없음) 100 · 고침(STmate 기계 두 묶음 · 거창 자재 · 끝 점 직종 · 옛 몫 금액 ÷ 단가 엉터리 · 제외금액 줄) — 골든 1위 줄마다 · 불리는 블록 206 갈래 · 1위 · 상태 전후 같음(162/177 · 기초 80 · SK 37 · 대기 89)
- S1 다시 굳힘 = 봉화 · 울진대흥 · 울진소광 자원(같은 코드 두 묶음 따로) · S2 다시 굳힘 125/275(위 까닭)
- 시험 고침 — finger(블록 = ReadEst.items · STmate 단위 줄 · 셈 밖) · fit 옛 HP(항목 · 글 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 13:20:17 +09:00
co-authored by Claude Opus 5.5
parent 660e3318a9
commit 022d6375e1
8 changed files with 256 additions and 522 deletions
+62 -186
View File
@@ -1,102 +1,45 @@
"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2) — 호표 줄 + 이어진 단가산출 블록 →
직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀(식 맨 앞 단가는 뗌).
"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2 · 776-3) — 호표 줄 + 이어진 단가산출 블록 →
직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀.
엑셀꼴 = 블록 `글`(머리 뺀 몸 줄)을 줄마다 풂 — 절 `^\\d+\\.` · 작은 절 `^[가-하]\\.` ·
직종 줄 「직종 : 단가 × 식 = 금액」(우리 인력 이름 · 옛이름) · 장비 = 「노무비 · 경비 · 재료비 :
단가 ÷ Q」
+ 지금 장비(노무비 > 경비 > 재료비) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」 ·
못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만).
STmate = 블록 `자원`(L · X · M · S) 품이 곧 품(노무비 · 재료비 · 경비 세 줄 같은 품은 한 번) ·
섞음 = 이름 · 규격 몫 둘(작은 쪽 인력 · 큰 쪽 장비).
몫(조합용 · 설계 2-7) = 맨 윗 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares`).
형식 모름 — 블록 단위 줄(`항목` · `M02_Hopyo_UnitLine`)의 투영:
L 노무 = 직종 품 · 식이 있으면 맨 인자가 섞음 비율 후보(호표 이름 · 규격 · 절 제목의 (N%) ·
8:2)와 같을 때 율로 가름 · 식 없는 줄(QTY)은 호표 이름 비율 둘 이상이면 작은 쪽(인력)
X 기계 = 장비 {갈래, 규격, 시간, 밑시간, 율, 절, 글} · 식 없는 줄은 큰 비율(장비)로 가름
M 자재 = 식(수량) · 별도계상(품 없음) · E 값 줄에 수량이 있으면 장비 글이면 장비 · 아니면 자재
셈 밖 · 율 · 하위 줄 = 안 씀(율 % 는 글에서).
글(블록 머리 「N.이름 [단위]」 + 몸 글) = 조건 · 옛 판 · 계수(q · K · f …) · 글 속 「…의 N %」.
몫(조합용 · 설계 2-7) = 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares.sections`).
블록 없는 기계(hr) 줄 = `machine` · 노임 · 장비를 바로 적은 호표 = `direct`.
값 · 규칙은 336 실험(C4 `s1_excel_fp` · K5 `stmate`) 그대로.
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete.M02_Hopyo_Shares import trade
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs, specnums
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
COST = {"노무비", "재료비", "경비"}
SKIPW = {
"소계",
"계",
"합계",
"인부소계",
"자재소계",
"주자재소계",
"운반",
"상차",
"기계",
"다짐",
"적사",
"살수",
"집토",
"암절취",
"채집",
"인력",
"장비",
"작업조건",
"1일시공량",
"작업면적",
"1시간작업량",
"기계채집",
"구조물터파기",
}
_COLON = re.compile(
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$"
)
_MAT = re.compile(
r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$"
)
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
_COEF = re.compile(
r"(?<![A-Za-z가-힣])(q|qs|q1|K|k|f|f1|f2|E|Es|Cm|L|V1|V2|T|rt|n|t1|t2|t3|t4)\s*=\s*"
r"(\d+(?:\.\d+)?)(?![\d.]*\s*[×x*÷/])"
)
_HEAD = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
_SECTION = re.compile(r"^\s*(\d+\.|[가-하]\.)")
OLD = re.compile(
r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)"
)
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
_HOURS = re.compile(r"(hr|시간)", re.I)
TRADE_UNITS = ("인",)
rates_in = hopyo_est.rates_in
def _flat(s: Any) -> str:
return re.sub(r"\s+", "", str(s or ""))
def _ratio(rhs: str) -> float | None:
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
n = _LASTNUM.search(rhs)
if not m or not n:
return None
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
return a / p if p else None
def rates_in(text: Any) -> set[float]:
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
t = str(text or "")
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)}
out = {v for v in out if 0 < v < 1}
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
a, b = float(a), float(b)
if a and b and a + b <= 20:
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
return out
def empty(line: dict[str, Any], block: dict[str, Any] | None = None) -> dict[str, Any]:
block = block or {}
basis = line.get("근거") or {}
@@ -133,12 +76,10 @@ def _round(fp: dict[str, Any]) -> dict[str, Any]:
def excel(block: dict[str, Any] | None, line: dict[str, Any]) -> dict[str, Any]:
"""HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 글 · 자원이 없으면
"""HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 단위 줄 · 글이 없으면
기계(hr) 줄 지문(장비 1 시간) 또는 빈 지문."""
if block and block.get("자원"):
return _round(_stmate(block, line))
if block and block.get("글"):
return _round(_text(block, line))
if block and (block.get("항목") or block.get("글")):
return _round(_units(block, line))
return machine(line)
@@ -155,124 +96,59 @@ def machine(line: dict[str, Any]) -> dict[str, Any]:
return fp
def _text(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
"""엑셀꼴 — 블록 글 줄마다 풂(C4 `north`)."""
fp = empty(line, block)
def _text(fp: dict[str, Any], block: dict[str, Any]) -> None:
"""블록 글 → 조건(머리 + 앞 넉 줄 + ※ · 작업조건 줄) · 옛 판 · 계수 · 「…의 N %」."""
head = f"{block.get('번', '')}.{block.get('이름', '')} [{block.get('단위', '')}]"
text = [head, *[str(t) for t in block.get("글") or []]]
front = [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t]
head_text = " ".join([fp["이름"], fp["규격"], *front])
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
front = text[:5] + [t for t in text if "※" in t or "작업조건" in t]
got = conds(" ".join([fp["이름"], fp["규격"], *front]))
fp["조건"] = {k: sorted(v) for k, v in got.items()}
fp["옛판"] = bool(OLD.search(" ".join(text[:6])))
sec, sub, mach, label = 0, "", None, ""
got_cost: dict[tuple, tuple] = {}
item_rates = rates_in(f"{fp['이름']} {fp['규격']}")
sec_rates: set[float] = set()
def unmix(got: dict | None) -> dict | None:
"""율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
if got and not got["율"]:
for v in got.get("인자") or []:
if round(v, 4) in item_rates | sec_rates and 0 < v < 1:
return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]}
return got
for raw in text[1:]:
row = raw.rstrip()
h, s = _HEAD.match(row), _SUB.match(row)
if h or s:
if h:
sec, sub = int(h.group(1)), ""
sec_rates = rates_in(h.group(2))
else:
sub = s.group(1)
sec_rates = sec_rates | rates_in(s.group(2))
title = (h or s).group(2)
ts = mtypes(title)
mach = (ts, specnums(title)) if ts else None
label = title.strip() if ts else ""
for k, v in _PCT.findall(row):
fp["율"].append([k, float(v)])
cm = _COLON.match(row)
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
who = hopyo_basis.trade_names().get(name) if cm else None
if who:
rhs = cm.group("rhs")
got = unmix(parse_rhs(rhs))
if got:
_labor(fp, who, got["품"], got["밑품"], got["율"], "식")
elif r := _ratio(rhs):
_labor(fp, who, r, r, [], "역산")
continue
if cm and name in COST:
got = unmix(parse_rhs(cm.group("rhs")))
if got and mach:
key = (sec, sub, label)
prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
if key not in got_cost or prio < got_cost[key][0]:
got_cost[key] = (prio, mach, got)
continue
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None})
continue
mm = _MAT.match(row)
if mm and not cm and (got := parse_rhs(mm.group("rhs"))):
fp["자재"].append(
{"이름": mm.group("name").strip(), "품": got["품"], "밑품": got["밑품"]}
)
continue
if not (h or s):
ts = mtypes(row)
if ts and "%" not in row and not cm:
mach, label = (ts, specnums(row)), row.strip()
for row in text[1:]:
fp["율"] += [[k, float(v)] for k, v in _PCT.findall(row)]
if not _SECTION.match(row):
for k, v in _COEF.findall(row):
fp["계수"].setdefault(k, float(v))
for (sec_, sub_, lab), (_, (ts, sp), got) in got_cost.items():
where = f"{sec_}.{sub_}" if sub_ else str(sec_)
one = {"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"]}
fp["장비"].append({**one, "율": got["율"], "절": where, "글": lab})
fp["몫"] = shares.sections(text[1:])
return fp
def _stmate(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
"""STmate — 자원 품이 곧 품(K5 `stmate`)."""
def _unmix(one: dict[str, Any], rates: set[float]) -> tuple[float, list]:
"""(밑품, 율) — 율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
if not one.get("율"):
for v in one.get("인자") or []:
if round(v, 4) in rates and 0 < v < 1:
return one["밑품"] / v, [[round(v, 4), "섞음"]]
return one["밑품"], one.get("율") or []
def _units(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
fp = empty(line, block)
text = [str(t) for t in block.get("글") or []][:60]
fp["조건"] = {
k: sorted(v) for k, v in conds(" ".join([fp["이름"], fp["규격"], *text[:4]])).items()
}
fp["옛판"] = bool(OLD.search(" ".join(text[:4])))
for t in text:
for k, v in _COEF.findall(t):
fp["계수"].setdefault(k, float(v))
for k, v in _PCT.findall(t):
fp["율"].append([k, float(v)])
rates = rates_in(f"{fp['이름']} {fp['규격']}")
lab_rate = min(rates) if len(rates) >= 2 else None
mach_rate = max(rates) if len(rates) >= 2 else None
seen: Counter = Counter()
for x in block.get("자원") or []:
kind, q = x.get("갈래"), x.get("품")
nm, sp = str(x.get("이름") or ""), str(x.get("규격") or "")
if not q:
_text(fp, block)
item_rates = rates_in(f"{fp['이름']} {fp['규격']}")
pair = sorted(item_rates) if len(item_rates) >= 2 else []
lab_rate, mach_rate = (pair[0], pair[-1]) if pair else (None, None)
for one in block.get("항목") or []:
kind, q, how = one.get("묶음"), one.get("품"), one.get("어떻게")
if one.get("밖") or (q is None and how != "별도"):
continue
seen[(x.get("코드"), round(float(q), 9))] += 1
if seen[(x.get("코드"), round(float(q), 9))] > 1: # 노무비 · 재료비 · 경비 세 줄 같은 품
continue
q = float(q)
ts = mtypes(f"{nm} {sp}")
if kind == "L":
if who := trade(nm):
base = q / lab_rate if lab_rate else q
_labor(fp, who, q, base, [[lab_rate, "섞음"]] if lab_rate else [], "QTY")
elif kind == "X" or (kind == "S" and ts):
one = {"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q}
base = q / mach_rate if mach_rate else q
fp["장비"].append({**one, "밑시간": base, "율": [], "절": "0", "글": f"{nm} {sp}"})
elif kind in ("M", "S"):
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q})
fp["몫"] = shares.sections_stm(block.get("자원") or [], text)
text = f"{one.get('이름') or ''} {one.get('규격') or ''}".strip()
rates = item_rates | set(one.get("절율") or [])
if kind == "L" and (who := one.get("직종") or trade(one.get("이름"))):
if how == "QTY":
base, r = (q / lab_rate, [[lab_rate, "섞음"]]) if lab_rate else (q, [])
else:
base, r = _unmix(one, rates) if how == "식" else (one["밑품"], one["율"])
_labor(fp, who, q, base, r, how)
elif kind == "X" or (kind == "E" and mtypes(text)):
if how == "QTY":
base, r = (q / mach_rate if mach_rate else q), []
else:
base, r = _unmix(one, rates)
sizes = specnums(one.get("규격") or "") | specnums(one.get("이름") or "")
got = {"갈래": sorted(mtypes(text)), "규격": sorted(sizes), "시간": q}
fp["장비"].append({**got, "밑시간": base, "율": r, "절": one.get("절"), "글": text})
elif kind in ("M", "E") and (how in ("식", "별도", "QTY")):
fp["자재"].append({"이름": text, "품": q, "밑품": one.get("밑품", q)})
fp["몫"] = shares.sections(block.get("항목") or [])
return fp
@@ -282,7 +158,7 @@ def direct(hopyo: dict[str, Any]) -> dict[str, Any] | None:
fp = empty({k: hopyo.get(k) for k in ("이름", "규격", "단위")})
texts = [str(t) for t in hopyo.get("근거글") or []]
texts += [f"{x.get('이름')} {x.get('규격')}" for x in lines if x.get("갈래") == "근거글"]
fp["출처"] = hopyo_basis.sources(" ".join(texts))
fp["출처"] = hopyo_est.sources(" ".join(texts))
hit = False
for x in lines:
q = x.get("수량")