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:
@@ -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 = {
|
||||
"꼴": "엑셀꼴",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user