Files
Aislo/M02_MasterTemplete/M02_Hopyo_ReadEst.py
T
eomsangdonandClaude Opus 5.5 660e3318a9 feat(778-3): 단위 줄 정규화 — 단가산출 블록 · 일위대가 줄 = 노무 · 기계 · 자재 · 율 · 하위 · 값 줄(D-1 · 요구 622 · 623 · 624)
- 새 M02_Hopyo_UnitLine — 단위 줄 꼴 · 자체 검산(줄 재현 0.5 % · 줄합 = 블록 금액 덮음) · 값 줄 → 하위 가름 · 율 줄 기준꼴 · 기준줄 · 할증 풀이 · 일위대가 줄 투영(묶음 · 재현 · % 기준) · 두 어댑터 끝에 settle
- ReadEst.items — 줄 금액 · 율 · 값 줄 · 합계 · 소계 절 = 밖 · 끝 점 「28,500. ×」 · 이름 속 = · / 자재 · 「(지주)」 · 「\/㎡」 금액 꼬리 · 「직종 : P」 선언 줄 뺌 · 자재 손료율 표지 · 기계 비목마다 매개값 계수 · 절 = 제목
- Stmate — 같은 기계 코드 두 묶음 따로(울진대흥 1129 · 2119 · 울진소광 1448 단가 두 배 흠) · 비목마다 H 다르면 계수(봉화 덤프 (Cm-t1)/Cm · 영월 물탱크 ×0.722) · PR · RR 율 행(기준 표지 PA01M 꼴) · 이름 없는 율 행 = 위 라벨 줄 이름 · 기준글 · G 열 「* N'%'」 자원 = 율 줄 · 자재 손료율 · D 행 = 하위 · N 열 C = 밖
- Store.BOOK 3 — 읽기 꼴 바뀜(옛 판 HP = 다시 올림)
- 여덟 파일 불리는 블록: 줄 재현 811/836 → 836/842 · 덮음 201 → 204/206(남은 둘 = 엑셀 합이 진동로울러 노무 뺌) · 일위대가 줄 1,344/1,350
- 새 시험 test_778_3_hopyo_unit_lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:20:03 +09:00

392 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 일위대가 실무자료 EST(엑셀꼴) 단가산출 어댑터(PLAN 776-2 · 요구 621-1 ② 정규화) —
엑셀 「단가산출」 시트 → 블록 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`.
형식(EST 글)을 아는 곳은 여기까지.
블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
금액 = 머리 줄 돈 칸(1 단위).
항목(Book 블록 항목 · 778-3) = 글 줄 → 단위 줄(꼴 = `M02_Hopyo_UnitLine`) · 줄 금액 = 「= 금액」 ·
끝 점(「28,500. ×」) 뗌 · 율(호표 줄 이름 · 규격)에 기대지 않는 것만(섞음 율은 지문 몫):
L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) ·
X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나
(품 = 노무비(단가 > 0) › 경비 › 재료비 줄 · 비목마다 매개값이 다르면 `계수`) ·
M 자재 = 「이름 단가 × 수량 = 금액」(이름 속 = · / 도) · 「이름 : 별도계상」(품 없음) ·
R 율 = 「기준 × N% = 금액」(라벨 = 이 줄 · 앞 줄 · 기준 = 「노무비의」 앞 낱말) ·
E 값 = 셈 글 아닌 「이름 : … = 금액」(하위 가름은 `UnitLine.settle`) ·
절 = 제목(작은 절 = 「윗 제목 · 작은 제목」) · 합계 · 소계 절 안 줄 = `밖`.
"""
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_UnitLine as unit_line
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs
SHEET = "단가산출"
GLINES, GCHARS, WIDE = 200, 300, 140
_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
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
# 항목 풀이 — 지문(`Finger`)과 같은 규칙
COST = ("노무비", "재료비", "경비")
SKIPW = frozenset(
"소계 계 합계 인부소계 자재소계 주자재소계 운반 상차 기계 다짐 적사 살수 집토 암절취 채집 인력 "
"장비 작업조건 1일시공량 작업면적 1시간작업량 기계채집 구조물터파기".split()
)
_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*÷/].*=.*)$"
)
_SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
# 단위 줄(778-3) — 줄 금액 · 끝 점 · 율 · 느슨한 자재 · 셈 글 · 합계 절
NUM = r"[\d,]+(?:\.\d+)?"
_AMOUNT = re.compile( # 끝 「= 금액」 — 원 · ₩ · 「\/㎡」 단위 · 「(지주)」 꼬리 허용
r"=\s*[A-Z]?\s*(" + NUM + r")\s*\.?\s*(?:원|₩)?\s*(?:\\?/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
)
_DOT = re.compile(r"(\d)\.(?=\s|$|%)")
_DECR = re.compile(r"[×xX*]\s*(0?\.\d+)\s*=")
_PCTN = re.compile(r"(\d+(?:\.\d+)?)\s*%")
_BASEW = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%")
_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)")
_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*") # 「3.」 · 「가.」 · 「1)」 머리
_TOP_L = re.compile(r"^\s*\d+\s*\.\s*(.*)$")
_SUB_L = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
_DECIMAL = re.compile(r"^\s*\d+\.\d")
_LOOSE = re.compile(
r"^\s*(?P<name>[^\d\s::].*?[A-Za-z가-힣)\]])\s+(?P<rhs>" + NUM + r"\s*[×xX*÷/].*=.*)$"
)
_CALC = re.compile(
r"(^|[\s(])(Q\d?|q\w?|K|k|f\d?|E\w?|Cm|L|V\d|T|rt|n|t\d|A|N|W|V|S)\s*[=:]|─|㎥/hr|m3/hr|/hr\b"
)
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 trade(name: Any) -> str | None:
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
names, flat = trade_names(), _flat(name)
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
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 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 _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["_글행"].append(at)
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)
for block in blocks.values():
block["항목"] = items(block["글"], block.pop("_글행"))
return blocks
# ── 글 → 단위 줄 ───────────────────────────────────────────────────────
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 _num(text: str) -> float | None:
try:
return float(text.replace(",", ""))
except ValueError:
return None
def amount(row: str) -> float | None:
"""줄 끝 「= 금액」(원 · 끝 점 · 앞 글자 하나 허용)."""
m = _AMOUNT.search(row.replace("W", ""))
return _num(m.group(1)) if m else None
def _item(kind: str, name: str, got: dict[str, Any], how: str, **more: Any) -> dict[str, Any]:
keep = {"품": got.get("품"), "밑품": got.get("밑품")}
keep |= {"율": got.get("율") or [], "인자": got.get("인자") or []}
return {"묶음": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more}
def _material(hit: Any, row: str, money: float | None, place: dict) -> dict[str, Any]:
"""자재 줄 「이름 단가 × 수량 [× 손료율] = 금액」 — 손료율 = 식 속 % · 끝 「× 0.xx =」."""
got = parse_rhs(hit.group("rhs"))
more = {"단가": {"재료비": got["단가"]} if got else {}, "금액": money, **place}
one = _item("M", hit.group("name").strip(), got or {}, "식", **more)
pct = [v for v, note in (got or {}).get("율") or [] if note == "%"]
dec = _DECR.search(row)
dec = float(dec.group(1)) if dec and 0.5 <= float(dec.group(1)) < 1 else None
if (
pct or dec
): # ponytail: 끝 「× 0.95 =」 = 손료율(0.5~1) 어림 — 글에 「손료」 표지가 생기면 그것으로
one["손료율"] = pct[-1] if pct else dec
return one
def _rate(row: str, prev: str, cm: Any) -> dict[str, Any]:
"""율 줄 「기준 × N% = 금액」(라벨 = 이 줄 이름 · 없으면 앞 줄) →
{이름, 품 = 율, 기준값, 기준}."""
pm = _PCTN.findall(row) or _PCTN.findall(prev)
rate = float(pm[-1]) / 100 if pm else None
dec = _DECR.search(row)
if dec and not _PCTN.findall(row):
rate = float(dec.group(1))
body = row.split(":", 1)[-1] if cm else row
par = re.search(r"\(([^()]*\d[^()]*)\)\s*[×xX*]", body)
if par and "+" in par.group(1):
base: float | None = sum(_num(x) or 0 for x in re.findall(NUM, par.group(1)))
else:
hit = re.search(r"(" + NUM + r")\s*[×xX*]\s*(?:" + NUM + r")\s*(?:%|=)", body)
hit = hit or re.search(r"(" + NUM + r")", body)
base = _num(hit.group(1)) if hit else None
word = _BASEW.search(row) or _BASEW.search(prev)
label = _NUMBERED.sub("", (cm.group("name") if cm else prev)).strip()[:40]
return {"이름": label, "품": rate, "기준값": base, "기준": word.group(1) if word else ""}
def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]:
"""블록 몸 글(머리 뺌) → 단위 줄(글 차례 · 기계 = 절 · 장비마다 하나 · 첫 비목 줄 자리 ·
품 = 노무비(단가 > 0) › 경비 › 재료비 줄) · 합계 · 소계 절 안 = `밖` · 값 → 하위 가름은 책
단위(`M02_Hopyo_UnitLine.settle`)."""
rows = rows or [0] * len(text)
names = trade_names()
out: list[dict[str, Any]] = []
machines: dict[tuple, dict[str, Any]] = {}
sec, sub, mach, label, total, prev = 0, "", False, "", False, ""
top = path = "" # 절 제목 · 작은 절이면 「윗 제목 · 작은 제목」
whole_out = False # 합계 · 소계 맨 윗 절 안
sec_rates: set[float] = set()
for raw, at in zip(text, rows, strict=True):
row = str(raw).rstrip()
h, s = _SEC.match(row), _SUB.match(row)
if (lt := _TOP_L.match(row)) and not _DECIMAL.match(row): # 몫 절 · 셈 밖(띄어 쓴 「가 .」)
top = path = lt.group(1).strip()
whole_out = total = bool(_TOTAL.search(top))
elif ls := _SUB_L.match(row):
total = whole_out or bool(_TOTAL.search(ls.group(1)))
path = path if total else f"{top} · {ls.group(1).strip()}"
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 = bool(ts)
label = title.strip() if ts else ""
else:
row = _DOT.sub(r"\1", row) # 「28,500. ×」 끝 점
place = {
"절": path,
"윗절": top,
"절율": sorted(sec_rates),
"행": at,
**({"밖": True} if total else {}),
}
money = amount(row)
cm = _COLON.match(row)
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
who = names.get(name) if cm else None
if who:
rhs = cm.group("rhs")
raw_name, how = cm.group("name").strip(), {"직종": who, "금액": money, **place}
if got := parse_rhs(rhs):
out.append(_item("L", raw_name, got, "식", 단가={"노무비": got["단가"]}, **how))
elif r := _ratio(rhs):
got = {"품": r, "밑품": r}
out.append(_item("L", raw_name, got, "역산", 단가={}, **how))
elif money is not None: # 품 없는 「직종 : P」 선언 줄은 뺌(값은 뒤 줄)
out.append(_item("L", raw_name, {}, "식", 단가={}, **how))
prev = row
continue
if cm and name in COST:
got = parse_rhs(cm.group("rhs"))
if mach and (got or money is not None):
key = (sec, sub, label)
one = machines.get(key)
if one is None:
one = _item("X", label, {}, "식", 단가={}, 금액=0.0, _순=9, _q={}, **place)
machines[key] = one
out.append(one)
one["금액"] = round(one["금액"] + (money or 0), 6)
if got:
prio = (
0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
)
if prio < one["_순"]:
one.update({k: got[k] for k in ("품", "밑품", "율", "인자")}, _순=prio)
one["단가"][name] = got["단가"]
one["_q"][name] = got["품"]
prev = row
continue
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
more = {"단가": {}, "금액": 0.0, **place}
out.append(_item("M", cm.group("name").strip(), {}, "별도", **more))
prev = row
continue
mm = _MAT.match(row)
if mm and not cm:
out.append(_material(mm, row, money, place))
prev = row
continue
if not (h or s):
ts = mtypes(row)
if ts and "%" not in row and not cm:
mach, label = True, row.strip()
if (
money is None
or total
or (cm and (name in ("소계", "계", "합계") or name.endswith("소계")))
):
prev = row
continue
dec = _DECR.search(row)
loose = None if cm else _LOOSE.match(row)
if loose and (names.get(_flat(loose.group("name"))) or _BASEW.search(loose.group("name"))):
loose = None # 직종 · 「…의 N%」 이름 = 자재 아님
if loose: # 자재(이름 속 = · / · 손료율 % 도)
out.append(_material(loose, row, money, place))
elif "%" in row or (dec and "%" in prev):
got = {"묶음": "R", "규격": "", **_rate(row, prev, cm), "단가": {}, "금액": money}
out.append({**got, "어떻게": "율", **place})
elif cm and not _CALC.search(row):
first = re.search(r"(" + NUM + r")\s*[×xX*]", cm.group("rhs"))
price = {"계": _num(first.group(1))} if first else {}
one = {"묶음": "E", "이름": cm.group("name").strip()[:40], "규격": "", "품": None}
out.append({**one, "단가": price, "금액": money, "어떻게": "값", **place})
prev = row
for one in machines.values():
unit_line.machine_done(one)
return out