Files
Aislo/M02_MasterTemplete/M02_Hopyo_Stmate.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

399 lines
19 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 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 +
목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴
(`M02_Hopyo_Excel.read_book`).
일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 ·
D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 ·
다음 열 금액 · M 비고) → 「합계」 줄에서 끝.
줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) ·
코드 없는 줄 = 호표 `근거글`.
갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 ·
X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 ·
PR · 단위 % = % · 그 밖 기타.
단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 ·
소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 ·
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `ReadEst.sources` ·
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름) ·
금액 = 머리 줄 C~F(1 단위) · 항목(778-3 · 단위 줄 꼴 = `M02_Hopyo_UnitLine`) = 행마다
자원 L 노무 · X 기계 · M 자재 · S 경비 = E 값(품 = H · 단가 I~L · 줄 금액 C · 어떻게 「QTY」) ·
기계 셋 줄 = 같은 코드 · 절 · 아직 빈 비목일 때만 하나(같은 코드 두 묶음 = 따로 ·
비목마다 H 가 다르면 `계수`) · G 열 「&자원& * N'%'」 = R 율(기준값 = I) ·
PR · RR 행 = R 율(PR = N % · RR = 곱 · 기준 = G 열 표지 「&PA01M:M&」 앞) ·
D 코드 행 = S 하위 · N 열 「C…」 = `밖`(소계(제외금액)) · 자원 = 자원 코드 줄 옛 칸.
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
SHEET = "일위대가표"
CALC = "단가산출근거"
LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표")
SHEETS = (SHEET, CALC, *LISTS)
_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
_META = re.compile(r"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
_CALC_AMOUNT = {"합계": 2, "노무비": 3, "재료비": 4, "경비": 5} # 단가산출근거 C~F 금액
_CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~L 단가(T · L · M · S)
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
FORMULA_COL, OUT_COL = 6, 13 # G 식 · N 합 갈 곳(C = 셈 밖 「소계(제외금액)」)
COSTS = ("노무비", "재료비", "경비")
_KIND = {"L": "L", "X": "X", "M": "M", "S": "E"} # 자원 코드 머리 → 단위 줄 묶음(S 경비 = 값)
_RES_CODE = re.compile(r"^[LMXS]\d{5}$")
_RATE = re.compile(r"^(?:PR|RR)[A-Z]?_") # 율 행 「PR_1」 · 「RRS_(8)_1」
_SUB_D = re.compile(r"^(D\d{5})_\d+$") # 다른 블록 부름
_PCT_REF = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&\s*\*\s*[\d.]+\s*'%'\s*=") # 율 꼴 자원 행
_LOSS = re.compile(r"\*\s*([\d.]+)\s*'%'") # 자재 손료율 「* 0.443'%'」
_BASE_WORD = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%") # 율 기준글 「(인건비의 3%)」
_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*")
_TAG = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&") # 율 기준 표지 「&PA01M:M&」 → PA01M
def is_stmate(names: Any) -> bool:
return SHEET in names and CALC in names
def _cell(row: list[Any], col: int) -> Any:
return row[col] if col < len(row) else None
def _s(value: Any) -> str:
return hopyo_excel._text(value)
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None:
for col in cols:
hit = _CODE.match(_s(_cell(row, col)))
if hit:
return hit.group(1)
return None
def _calc_money(row: list[Any], cols: dict[str, int]) -> dict[str, int | float | None]:
return {k: hopyo_excel._out(v) for k, v in hopyo_excel._money(row, cols, 0).items()}
def _add(into: dict[str, Any], more: dict[str, Any]) -> None:
"""기계 셋 줄(노무비 · 재료비 · 경비) 단가 · 금액을 한 항목에 더함."""
for k, v in more.items():
if v is not None:
into[k] = hopyo_excel._out(hopyo_excel._num(into.get(k) or 0) + hopyo_excel._num(v))
def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
"""목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸
「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열)."""
out: dict[str, dict[str, str]] = {}
for title in LISTS:
for row in sheets.get(title) or []:
meta = _META.search(" ".join(_s(v) for v in row))
code = meta.group(1) if meta else _code(row, range(len(row)))
if code and _s(_cell(row, 1)):
out[code] = {
"이름": _s(row[1]),
"규격": _s(_cell(row, 2)),
"단위": _s(_cell(row, 3)),
}
return out
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
def _part(row: list[Any]) -> str | None:
"""기계 셋 줄 가운데 이 줄 비목(I~L 단가 칸 · 노무비 › 재료비 › 경비)."""
return next((k for k in COSTS if _cell(row, _CALC_PRICE[k]) not in (None, "")), None)
def _same_machine(last: dict[str, Any] | None, code: str, title: str, part: str | None) -> bool:
"""바로 앞 기계 줄과 한 기계(셋 줄)인가 — 같은 코드 · 절 · 이 비목이 아직 빔
(같은 코드 두 묶음 = 따로 · 울진대흥 1129)."""
return bool(
last
and last["묶음"] == "X"
and last["코드"] == code
and last["절"] == title
and part
and part not in last["_q"]
)
def _unit(row: list[Any], code: str, title: str, info: dict[str, str]) -> dict[str, Any]:
"""자원 행 → 단위 줄(L 노무 · X 기계 · M 자재 · S 경비 = E 값 · G 열 「'%'」 = R 율)."""
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
qty = hopyo_excel._num(_cell(row, QTY_COL))
q = float(qty) if qty is not None else None
one = {
"묶음": _KIND.get(code[0], "E"),
"코드": code,
"이름": name.strip() or info.get("이름", ""),
"규격": spec.strip() if name.strip() else info.get("규격", ""),
"단위": info.get("단위", ""),
"품": q,
"절": title,
}
if rate := _PCT_REF.search(_s(_cell(row, FORMULA_COL))): # 「기구손료 : &L00020& * 2'%'」
base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"]))
more = {"기준": rate.group(1), "기준값": float(base) if base is not None else None}
return {**one, "묶음": "R", "품": q / 100 if q is not None else None, **more}
if one["묶음"] == "M" and (loss := _LOSS.search(_s(_cell(row, FORMULA_COL)))):
one["손료율"] = round(float(loss.group(1)) / 100, 9) # 「&M00081& * 1.03'㎡'* 0.443'%'」
return one
def _rate_row(row: list[Any], code: str, text: str, label: str) -> dict[str, Any]:
"""PR · RR 율 행 → 단위 줄 R(기준 = G 열 표지 「&PA01M:M&」 앞 · PR = N % · RR = 곱) · 이름이
없는 식 줄(「210,212.3 * 0.09」)은 위 라벨 줄(「기구손료 : (가공노력품의 9%)」)에서 이름 ·
기준글 · 못 찾으면 이름 빈 줄 + `이름없음`."""
qty = hopyo_excel._num(_cell(row, QTY_COL))
base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"]))
tag = _TAG.search(_s(_cell(row, FORMULA_COL)))
rate = float(qty) / (100 if code.startswith("PR") else 1) if qty is not None else None
name = text.split(":")[0] if ":" in text else ""
if not re.search(r"[가-힣A-Za-z]", name):
name = _NUMBERED.sub("", label.split(":")[0] if ":" in label else label)
word = _BASE_WORD.search(f"{text} {label}")
return {
"묶음": "R",
"코드": code,
"이름": name.strip()[:40],
"규격": "",
"품": rate,
"기준": tag.group(1) if tag else "",
"기준값": float(base) if base is not None else None,
**({"기준글": word.group(1)} if word else {}),
**({} if name.strip() else {"이름없음": True}),
}
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원, 금액, 항목}}."""
names = hopyo_est.trade_names()
out: dict[str, dict[str, Any]] = {}
block: dict[str, Any] | None = None
title = label = "" # 절 제목 · 율 행 위 라벨 줄(코드 · 금액 없는 글)
for at, row in enumerate(rows, 1):
head = next(
(hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None
) # 머리 = G 열(판마다 B 열 글이 다름)
if head:
code = head.group(1)
info = table.get(code) or {}
block = out.setdefault(
code,
{
"번": int(code[1:]),
"코드": code,
"이름": info.get("이름", ""),
"규격": info.get("규격", ""),
"단위": info.get("단위", ""),
"행": [at, at],
"글": [],
"금액": _calc_money(row, _CALC_AMOUNT),
"항목": [],
},
)
title = label = ""
continue
if block is None:
continue
first = _flat(_cell(row, 0))
if first.startswith("총계"):
block["행"][1] = at
block = None
continue
text, cell = _s(_cell(row, 1)), _s(_cell(row, RES_COL))
res = _RES.match(cell)
code = res.group(1) + res.group(2) if res else None
if not code:
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, FORMULA_COL)))
code = alt.group(1) if alt else None
rate = None if code else _RATE.match(cell)
sub = None if code or rate else _SUB_D.match(cell)
if not text and not code:
continue
block["행"][1] = at
if cell == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
if not block["이름"]:
name, _, unit = text.rpartition("/")
block["이름"], block["단위"] = name.strip() or text, unit.strip()
continue
if text and _flat(text) not in ("소계", "계"):
if len(block["글"]) < hopyo_est.GLINES:
block["글"].append(text[: hopyo_est.GCHARS])
if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text):
title = sec.group(1).strip()
money = _calc_money(row, _CALC_AMOUNT)
place = {
"단가": _calc_money(row, _CALC_PRICE),
"금액": money.get("계"),
"행": at,
"절": title,
"윗절": title,
**({"밖": True} if _s(_cell(row, OUT_COL)).startswith("C") else {}),
}
if rate:
block["항목"].append({**_rate_row(row, cell, text, label), **place, "어떻게": "율"})
continue
if code or sub or money.get("계") is not None:
label = "" # 라벨은 바로 위 글 줄만
elif text:
label = text.strip()
if sub and sub.group(1) != block["코드"] and money.get("계"):
qty = hopyo_excel._num(_cell(row, QTY_COL))
one = {"묶음": "S", "코드": sub.group(1), "이름": text[:40], "규격": ""}
more = {"품": float(qty) if qty is not None else None, "어떻게": "QTY"}
block["항목"].append(
{**one, **more, **place, "하위": {"종류": "단산", "번": int(sub.group(1)[1:])}}
)
continue
if not code:
continue
last, part = block["항목"][-1] if block["항목"] else None, _part(row)
if code[0] == "X" and _same_machine(last, code, title, part):
_add(last["단가"], place["단가"]) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
last["금액"] = hopyo_excel._out(
hopyo_excel._num(last["금액"] or 0) + hopyo_excel._num(place["금액"] or 0)
)
last["_q"][part] = hopyo_excel._num(_cell(row, QTY_COL))
continue
one = _unit(row, code, title, table.get(code) or {})
who = hopyo_est.trade(one["이름"]) if one["묶음"] == "L" else None
one = {**one, **({"직종": who} if one["묶음"] == "L" else {}), "밑품": one["품"]}
one.update(율=[], 인자=[], 어떻게="율" if one["묶음"] == "R" else "QTY", **place)
if one["묶음"] == "X":
one["_q"] = {part: hopyo_excel._num(_cell(row, QTY_COL))} if part else {}
block["항목"].append(one)
for block in out.values():
for one in block["항목"]:
if "_q" in one:
unit_line.machine_done(one)
block["자원"] = [ # 옛 칸(S1 투영) — 자원 코드 줄만
{"갈래": one["코드"][0], **{k: one[k] for k in ("코드", "이름", "규격", "품", "절")}}
for one in block["항목"]
if _RES_CODE.match(one["코드"])
]
block["출처"] = hopyo_est.sources(" ".join(block["글"]))
trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"]
block["직종"] = list(dict.fromkeys(t for t in trades if t))
return out
# ── 일위대가표 ────────────────────────────────────────────────────────
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
want_head = done = False
for at, row in enumerate(rows, 1):
first = _s(_cell(row, 0))
hit = hopyo_excel._HOPYO.fullmatch(_flat(first))
if hit:
code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None)
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []})
raw.append([])
want_head, done = True, False
continue
if not hopyos or done:
continue
hopyo = hopyos[-1]
if want_head:
hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3)))
hopyo["금액"] = {}
want_head = False
continue
if _flat(first).startswith("합계") or _flat(first) == "계":
hopyo["금액"] = hopyo_excel._money(row, _COLS, 1)
done = True # 끝 — 다음 호표까지 줄 안 받음
continue
if not first:
continue
code = _code(row)
hopyo["행"][1] = at
if not code:
if re.search(r"[0-9A-Za-z가-힣]", first):
hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip())
continue
if code[0] in "TC" and code[1].isdigit():
continue # 계 · 소계 · 계약단가
line = {
"차례": len(raw[-1]),
"이름": first,
"규격": _s(_cell(row, 1)),
"수량": hopyo_excel._num(_cell(row, 2)),
"단위": _s(_cell(row, 3)),
"단가": hopyo_excel._money(row, _COLS, 0),
"금액": hopyo_excel._money(row, _COLS, 1),
"비고": _s(_cell(row, NOTE)),
"코드": code,
"행": at,
}
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[0] == "D"
else {"종류": "일위", "번": None, "코드": code}
if code[0] == "B"
else None
)
raw[-1].append(line)
return hopyos, raw
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
if not hopyos:
raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
table = _lists(sheets)
blocks = _blocks(sheets.get(CALC) or [], table)
by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]}
for line in (one for lines in raw for one in lines):
ref = line["참조"] or {}
if ref.get("종류") == "일위":
ref["번"] = by_code.get(ref["코드"])
elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])):
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
out = []
for hopyo, lines in zip(hopyos, raw, strict=True):
for key in ("이름", "규격", "단위"):
hopyo.setdefault(key, "")
if not hopyo["근거글"]:
del hopyo["근거글"]
out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]})
keep = (
"번",
"코드",
"이름",
"규격",
"단위",
"행",
"출처",
"직종",
"글",
"자원",
"금액",
"항목",
)
return {
"꼴": "STmate",
"시트": SHEET,
"호표": out,
"단산": [{k: b[k] for k in keep} for b in blocks.values()],
"검산": {"어긋남": [], "총괄표": 0},
}