Files
eomsangdonandClaude Opus 5.5 02cabeccf2 feat(785-1): 단가산출 블록 글 → 절 나무 · 상수 · 대입식 · 선택값 · 절 몫(새 M02_Hopyo_Formula) · Book 단산 블록 글행 · 절 칸
- 새 M02_Hopyo_Formula — tree · head · works · line_of · factors · find(순수 · M01 · 엔진 안 부름)
- ReadEst 글행 남김 · 절 나무 칸 · Excel Book 단산 keep 에 글행 · 절
- 꼴 = PLAN 785 이음 줄(785-1)

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

353 lines
14 KiB
Python
Raw Permalink 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 일위대가 실무자료 단가산출 블록 글 → 절 나무 · 선택값 · 상수 · 대입식 · 몫(PLAN 785-1 ·
요구 642 · 설계 642-A 2-2 ⓑ · 2-5 ④-2 · ④-3 B2~B4).
글 먼저 · 찾기 없음 — 글에 적힌 것만 꼴로 옮김(값을 짓지 않음 · 못 읽은 것은 안 냄) ·
M01 · 엔진 안 부름.
절 나무 `tree(글, 글행, 항목)` → [절] — 절 = {번, 층, 제목, 갈래, 행 [시작, 끝], 글 [첫, 끝],
상수, 식, 선택값, 몫, 소계, 항목, 아래}:
층 1 「1.」 · 2 「가.」 · 3 「1)」 · 빠진 번호 그대로(단산6 1 → 3) ·
갈래 = 합계(합계 · 소계 · 총계) · 비목(재료비 · 인건비 · 노무비 · 경비 …) · 일(그 밖) ·
상수 = 「q = 0.6㎥ K = 0.55」 · 「L = 6m {경사도 = 35°(α=8)}」 · 「운반회수 : n = 42회/일」 →
{기호, 값, 단위, 덧?, 이름?, 행} ·
식 = 「기호 = 식 = … = 값」 → {기호, 글, 값, 단위, 꼴 곱|합|분수, 분자, 분모, 대입, 행} —
곱 = 「×」 「÷」 「/」 만 · 괄호 뒤 「÷」 는 괄호 안 모두 분모 ·
대입 = 기호 식과 수 식의 인자 자리가 같을 때 {기호: 수} ·
합(「+」) · 분수(「──」 그림 줄)는 값만 ·
선택값 = 「※ …」 · 작업조건 줄 {글, 행} ·
몫 = 그 절 비목 줄(노무비 · 재료비 · 경비) 「단가 ÷ Q」 · 「단가 × Q」 뒤 곱수(모두 같을 때) ·
없으면 직종 줄 「단가 × 품」 뒤 % 곱수 → {글, 값, 행} ·
소계 = 합계 갈래 아래 절(없으면 그 절 「소계」 줄) 비목 값 {노무비, 재료비, 경비, 계} ·
항목 = 행이 그 절(아래 절 뺌) 안인 단위 줄 차례.
머리 `head(글, 글행)` = 첫 절 앞 줄 {행, 상수, 식, 선택값}.
일 절 `works(나무)` = 일 갈래 중 아래에 일 절이 없는 절 · 하나도 없으면 [](블록 통째 한 일).
"""
from __future__ import annotations
import re
from typing import Any
NUM = r"[\d,]+(?:\.\d+)?"
_LEVELS = (
(1, re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")),
(2, re.compile(r"^\s*([가-하])\s*\.\s*(.*)$")),
(3, re.compile(r"^\s*(\d+)\)\s*(.*)$")),
)
_DECIMAL = re.compile(r"^\s*\d+\.\d")
_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)")
_COSTS = ("재료비", "인건비", "노무비", "경비", "기계경비", "자재비", "재료", "인건", "노무")
_SEG = re.compile(
r"(?:^\s*|(?<=\s\s)|(?<=[{(]))(?:(?P<label>[가-힣]{2,8})\s*[::]\s*)?"
r"(?P<sym>[A-Za-z][A-Za-z0-9]{0,3}|[가-힣]{2,6}|[α-ωΑ-Ω])\s*=(?!=)\s*"
)
_UNIT = r"[^\s\d×xX*÷/()=+,{}\[\]─]+(?:/[^\s\d×xX*÷/()=+,{}\[\]─]+)?"
_VALUE = re.compile(
r"^\s*(?P<num>" + NUM + r")\s*(?P<unit>" + _UNIT + r")?\s*(?:\((?P<note>[^()]*)\))?"
r"[\s)}\]]*$"
)
_TOKEN = re.compile(
r"\s*(?P<op>[×*÷/])?\s*(?:(?P<open>\()|(?P<close>\))|(?P<num>"
+ NUM
+ r")\s*(?P<unit>"
+ _UNIT
+ r")?|(?P<sym>[A-Za-z][A-Za-z0-9]*))"
)
_COLON = re.compile(
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,20}?)\s*[::]\s*(?P<rhs>.*)$"
)
_CHOICE = re.compile(r"^\s*※|작업\s*조건")
_MONEY = {"노무비": "노무비", "재료비": "재료비", "경비": "경비", "계": "계", "소계": "계"}
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _num(text: str | None) -> float | None:
try:
return float(str(text).replace(",", ""))
except (TypeError, ValueError):
return None
def _kind(title: str) -> str:
flat = _flat(title)
if _TOTAL.search(flat):
return "합계"
head = re.split(r"[(::]", flat)[0]
return "비목" if head in _COSTS or head.endswith(_COSTS[:5]) and len(head) <= 6 else "일"
# ── 줄 하나 → 상수 · 식 ─────────────────────────────────────────────────
def factors(expr: str) -> list[tuple[str, dict[str, Any]]] | None:
"""「1 ㎡ × 575㎏/㎡ ÷ (qs × K)」 → [(곱|나눔, {수|기호, 단위})] · 「+」 · 못 읽으면 None."""
if "+" in expr or "─" in expr:
return None
out: list[tuple[str, dict[str, Any]]] = []
stack = [1] # 괄호마다 부호(1 곱 · -1 나눔)
need = False # 다음 인자 · 괄호 앞에 연산이 있어야 함
at, text = 0, expr.strip()
while at < len(text):
hit = _TOKEN.match(text, at)
if not hit or hit.end() == at:
return None
at = hit.end()
op = hit.group("op")
sign = -1 if op in ("÷", "/") else 1
if hit.group("close"):
if len(stack) == 1 or op:
return None
stack.pop()
need = True
continue
if need != bool(op):
return None # 두 인자 사이 연산 없음 · 연산 뒤 인자 없음
if hit.group("open"):
stack.append(stack[-1] * sign)
need = False
continue
need = True
one: dict[str, Any] = {}
if hit.group("num"):
one["수"] = _num(hit.group("num"))
one["단위"] = hit.group("unit") or ""
else:
one["기호"], one["단위"] = hit.group("sym"), ""
out.append(("곱" if stack[-1] * sign > 0 else "나눔", one))
return out if out and len(stack) == 1 else None
def _value(text: str) -> dict[str, Any] | None:
hit = _VALUE.match(text)
if not hit:
return None
out = {"값": _num(hit.group("num")), "단위": hit.group("unit") or ""}
if hit.group("note"):
out["덧"] = hit.group("note").strip()
return out
def _last_value(text: str) -> dict[str, Any] | None:
"""식 끝 「= 23.1 ㎥/hr」 · 「= 5분(왕복)」 · 「= 7.79 회」."""
hit = re.search(r"(" + NUM + r")\s*(" + _UNIT + r")?\s*(?:\(([^()]*)\))?\s*$", text)
if not hit:
return None
out = {"값": _num(hit.group(1)), "단위": hit.group(2) or ""}
if hit.group(3):
out["덧"] = hit.group(3).strip()
return out
def _formula(sym: str, body: str) -> dict[str, Any] | None:
parts = [p.strip() for p in body.split("=")]
end = _last_value(parts[-1]) if len(parts) > 1 else None
if end is None:
return None
first = parts[0]
out: dict[str, Any] = {"기호": sym, "글": f"{sym} = {body.strip()}", **end}
if "─" in first:
return {**out, "꼴": "분수"}
got = factors(first)
if got is None:
return {**out, "꼴": "합" if "+" in first else "분수"}
out["꼴"] = "곱"
out["분자"] = [f for way, f in got if way == "곱"]
out["분모"] = [f for way, f in got if way == "나눔"]
numbers = factors(parts[1]) if len(parts) > 2 else None
if numbers and len(numbers) == len(got):
out["대입"] = {
f["기호"]: n["수"]
for (way, f), (way2, n) in zip(got, numbers, strict=True)
if "기호" in f and "수" in n and way == way2
}
return out
def line_of(text: str, at: int | None = None) -> dict[str, list[dict[str, Any]]]:
"""줄 하나 → {상수 [..], 식 [..]} — 「기호 = 」 마디(줄 머리 · 두 빈칸 · 「{」 「(」 뒤)마다."""
out: dict[str, list[dict[str, Any]]] = {"상수": [], "식": []}
hits = list(_SEG.finditer(text))
for i, hit in enumerate(hits):
end = hits[i + 1].start() if i + 1 < len(hits) else len(text)
body = text[hit.end() : end]
sym, label = hit.group("sym"), hit.group("label")
if (got := _value(body.strip().rstrip("{(").strip())) is not None:
one = {"기호": sym, **got, **({"이름": label} if label else {}), "행": at}
out["상수"].append(one)
elif (got := _formula(sym, body)) is not None:
out["식"].append({**got, **({"이름": label} if label else {}), "행": at})
return out
# ── 절 몫 · 소계 ───────────────────────────────────────────────────────
def _share(lines: list[tuple[str, int | None]]) -> dict[str, Any] | None:
"""비목 줄 「단가 ÷ Q × 575 / 1750 × 90%」 → 「÷ Q」 뒤 곱수(비목 줄 모두 같을 때) ·
없으면 직종 줄 「단가 × 품인 × 10%」 품 뒤 % 곱수."""
costs: list[tuple[str, float, int | None]] = []
trades: list[tuple[str, float, int | None]] = []
for text, at in lines:
hit = _COLON.match(text)
if not hit or "=" not in hit.group("rhs"):
continue
name, rhs = _flat(hit.group("name")), hit.group("rhs").split("=")[0]
got = factors(rhs)
if not got or len(got) < 3 or any("수" not in f for _, f in got):
continue
tail = got[2:]
if name in ("노무비", "재료비", "경비"):
pass
elif got[1][1]["단위"].startswith("인"):
tail = [t for t in tail if t[1]["단위"] == "%"]
if not tail or len(tail) != len(got) - 2:
continue
else:
continue
value = 1.0
for way, f in tail:
n = f["수"] / 100 if f["단위"] == "%" else f["수"]
value = value * n if way == "곱" else value / n
words = " ".join(f"{'×' if way == '곱' else '/'} {f['수']:g}{f['단위']}" for way, f in tail)
(costs if name in ("노무비", "재료비", "경비") else trades).append((words, value, at))
for group in (costs, trades):
if group and len({w for w, _, _ in group}) == 1:
words, value, at = group[0]
return {"글": words, "값": round(value, 6), "행": at}
return None
def _totals(lines: list[tuple[str, int | None]], only_sum: bool) -> dict[str, float | None]:
out: dict[str, float | None] = {}
for text, _ in lines:
hit = _COLON.match(text)
if not hit:
continue
key = _MONEY.get(_flat(hit.group("name")))
if not key or (only_sum and key != "계"):
continue
rhs = hit.group("rhs")
if re.search(r"[×*÷]", rhs.split("=")[0]):
continue
nums = re.findall(NUM, rhs)
out[key] = _num(nums[-1]) if nums else None
return out
# ── 절 나무 ────────────────────────────────────────────────────────────
def _title(text: str) -> tuple[int, str, str] | None:
if _DECIMAL.match(text):
return None
for depth, pattern in _LEVELS:
if hit := pattern.match(text):
return depth, hit.group(1), hit.group(2).strip()
return None
def _fill(one: dict[str, Any], lines: list[tuple[str, int | None]]) -> None:
for text, at in lines:
got = line_of(text, at)
one["상수"] += got["상수"]
one["식"] += got["식"]
if _CHOICE.search(text):
one["선택값"].append({"글": text.strip(), "행": at})
def head(text: list[str], rows: list[int] | None = None) -> dict[str, Any]:
"""첫 절 앞 머리 줄 → {행, 상수, 식, 선택값}."""
rows = rows or [None] * len(text)
lines = []
for raw, at in zip(text, rows, strict=True):
if _title(str(raw)):
break
lines.append((str(raw), at))
out: dict[str, Any] = {"행": [lines[0][1], lines[-1][1]] if lines else None}
out |= {"상수": [], "식": [], "선택값": []}
_fill(out, lines)
return out
def tree(
text: list[str], rows: list[int] | None = None, items: list[dict[str, Any]] | None = None
) -> list[dict[str, Any]]:
"""블록 몸 글(머리 줄 뺌) → 절 나무 — 꼴은 머리 글."""
rows = list(rows) if rows else [None] * len(text)
roots: list[dict[str, Any]] = []
path: list[dict[str, Any]] = []
own: dict[int, list[tuple[str, int | None]]] = {}
for at, (raw, row) in enumerate(zip(text, rows, strict=True)):
line = str(raw)
if got := _title(line):
depth, no, title = got
while path and path[-1]["층"] >= depth:
path.pop()
one = {"번": no, "층": depth, "제목": title, "갈래": _kind(title), "행": [row, row]}
one |= {"글": [at, at], "상수": [], "식": [], "선택값": [], "몫": None, "소계": {}}
one |= {"항목": [], "아래": []}
(path[-1]["아래"] if path else roots).append(one)
path.append(one)
own[id(one)] = [(line, row)]
continue
if not path:
continue
own[id(path[-1])].append((line, row))
for one in path:
one["행"][1], one["글"][1] = row, at
for one in _walk(roots):
lines = own[id(one)]
_fill(one, lines)
one["몫"] = _share(lines)
total = next((k for k in one["아래"] if k["갈래"] == "합계"), None)
if one["갈래"] == "합계":
one["소계"] = _totals(lines, False)
elif total is not None:
one["소계"] = _totals(own[id(total)], False)
else:
one["소계"] = _totals(lines, True)
if items:
_attach(roots, items)
return roots
def _walk(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for one in nodes:
out.append(one)
out += _walk(one["아래"])
return out
def _attach(roots: list[dict[str, Any]], items: list[dict[str, Any]]) -> None:
"""단위 줄 차례 → 행이 든 가장 깊은 절."""
for at, item in enumerate(items):
row = item.get("행")
if not isinstance(row, int):
continue
best = None
for one in _walk(roots):
lo, hi = one["행"]
if isinstance(lo, int) and isinstance(hi, int) and lo <= row <= hi:
if best is None or one["층"] > best["층"]:
best = one
if best is not None:
best["항목"].append(at)
def works(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""일 절 — 일 갈래 중 아래에 일 절이 없는 절(비목 · 합계 아래만 있으면 그 절)."""
out = []
for one in nodes:
if one["갈래"] != "일":
continue
kids = works(one["아래"])
out += kids or [one]
return out
def find(nodes: list[dict[str, Any]], path: str) -> dict[str, Any] | None:
"""「1」 · 「1.가」 → 절."""
first, _, rest = path.partition(".")
for one in nodes:
if one["번"] == first:
return find(one["아래"], rest) if rest else one
return None