- 새 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>
353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""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
|