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>
This commit is contained in:
2026-11-04 22:13:41 +09:00
co-authored by Claude Opus 5.5
parent c7d2e99a25
commit 02cabeccf2
3 changed files with 360 additions and 5 deletions
+1 -1
View File
@@ -366,7 +366,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목")
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "글행", "금액", "항목", "절")
basis = [{k: b[k] for k in keep} for b in blocks.values()]
book = {
"꼴": "엑셀꼴",
+352
View File
@@ -0,0 +1,352 @@
"""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
+7 -4
View File
@@ -5,7 +5,7 @@
블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 책 = 앞 낱말(`book_of` · 781-1: 산림 ·
임도(옛) · 사방(옛) · 건설 부문 · 그 밖 건설품셈(부문 모름)) ·
부문 풀이 · 옛 번호 대응 = `M02_Hopyo_Basis.settle` ·
부문 풀이 · 옛 번호 대응 = `M02_Hopyo_Clause.settle` ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
@@ -28,6 +28,7 @@ from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Formula as formula
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
@@ -171,8 +172,8 @@ def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액, 항목}}`
(행 = 엑셀 1부터)."""
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 글행, 금액, 항목,
절}}`(행 = 엑셀 1부터 · 글행 = 글 줄마다 행 · 절 = `M02_Hopyo_Formula.tree`)."""
if not rows:
return {}
start, col, money = _columns(rows)
@@ -222,7 +223,9 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
if name and name not in block["직종"]:
block["직종"].append(name)
for block in blocks.values():
block["항목"] = items(block["글"], block.pop("_글행"))
block["글행"] = block.pop("_글행")
block["항목"] = items(block["글"], block["글행"])
block["절"] = formula.tree(block["글"], block["글행"], block["항목"])
return blocks