"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7) — 단가산출 블록을 맨 윗 절(1. 2. 3.) · 작은 절(가. 나.)로 갈라 몫마다 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터. 엑셀꼴 = 「직종 : 노임 × 식 = 금액」(식을 못 풀면 금액 ÷ 노임) · 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q × 몫」 · 소계 · 합계 절 뺌. STmate = 자원 L · X(절 = 자원 `절` · 없으면 한 몫). 금액 안 씀. 규칙은 336 실험(C5 `c01_trees.sections` · `sections_stm`) 그대로 · 장비 갈래는 `M02_Hopyo_Words.mtypes`. """ from __future__ import annotations import ast import operator import re from typing import Any from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc _SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I) _OPS = { ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Add: operator.add, ast.Sub: operator.sub, } _TRADE = re.compile( r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$" ) _SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$") _EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$") _SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)") _SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$") _COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:") def trade(name: Any) -> str | None: """노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None.""" names = hopyo_basis.trade_names() flat = re.sub(r"\s+", "", str(name or "")) return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat)) def eq_key(text: Any) -> str | None: """장비 글 → 「굴착기|0.6」(갈래 + 로 묶음 · 크기 없으면 「굴착기|」) · 장비 아니면 None.""" kinds = mtypes(text) if not kinds: return None m = _SIZE.search(nfkc(text).replace(",", "")) size = ( m.group(1).rstrip("0").rstrip(".") if m and "." in m.group(1) else m.group(1) if m else "" ) return f"{'+'.join(sorted(kinds))}|{size}" def _eval(node: ast.AST) -> float: if isinstance(node, ast.Expression): return _eval(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value) if isinstance(node, ast.BinOp) and type(node.op) in _OPS: return _OPS[type(node.op)](_eval(node.left), _eval(node.right)) if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): return -_eval(node.operand) raise ValueError("식 아님") def qty_of(expr: str) -> float | None: """「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9.""" s = re.sub(r"\([^()0-9]*\)", "", str(expr)).replace(",", "") s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s) s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*") s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위 s = re.sub(r"[^\d.+\-*/() ]", " ", s) s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4 s = re.sub(r"\(\s*\)", " ", s).strip() m = re.match(r"\s*\d+(?:\.\d+)?", s) if not m: return None s = "1" + s[m.end() :] s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱 try: v = _eval(ast.parse(s, mode="eval")) except (SyntaxError, ValueError, ZeroDivisionError): return None return v if 0 < v < 1e4 else None def _part(title: str, parent: str) -> dict[str, Any]: return {"제목": title, "절": parent, "노임": {}, "장비": {}} def sections(texts: list[str]) -> list[dict[str, Any]]: """블록 몸 글 → 몫마다 {제목, 절, 노임, 장비} — 노임 · 장비가 하나도 없는 몫은 뺌.""" out: list[dict[str, Any]] = [] cur: dict[str, Any] | None = None eq, parent = None, "" for raw in texts: line = str(raw).rstrip() sec = _SECTION.match(line) if sec and not re.match(r"^\s*\d+\.\d", line): title = sec.group(2).strip() if _SKIP_SEC.match(title.replace(" ", "")): cur = None continue parent = title cur = _part(title, title) out.append(cur) eq = eq_key(title) continue sub = _SUBLABEL.match(line) if cur is not None else None if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")): cur = _part(f"{parent} · {sub.group(1).strip()}", parent) out.append(cur) if cur is None: if out or not line.strip(): continue cur = _part("", "") out.append(cur) if re.search(r"(소계|합계|인부소계)", line.replace(" ", "")): eq = None continue sub = _SUBLABEL.match(line) if sub: eq = eq_key(sub.group(1)) elif not _COSTLINE.match(line) and (k := eq_key(line)): eq = k m = _EQ_COST.match(line) if m and eq: q = qty_of(f"{m.group(1)} {m.group(2)}") if q and q < 50: cur["장비"][eq] = cur["장비"].get(eq, 0) + q continue t = _TRADE.match(line) if t and (who := trade(t.group(1))): q = qty_of(f"{t.group(2)} {t.group(3)}") if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만) q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", "")) if q: cur["노임"][who] = cur["노임"].get(who, 0) + q return [s for s in out if s["노임"] or s["장비"]] def sections_stm(res: list[dict[str, Any]], texts: list[str]) -> list[dict[str, Any]]: """STmate 자원(L · X) → 몫(자원 `절` = 글 줄 차례면 그 앞 맨 윗 절 · 글이면 그 제목 · 없으면 한 몫).""" marks = [] for at, t in enumerate(texts): sec = _SECTION.match(t) if sec and not re.match(r"^\s*\d+\.\d", t): if not _SKIP_SEC.match(sec.group(2).replace(" ", "")): marks.append((at, sec.group(2).strip())) def which(where: Any) -> str: if isinstance(where, str): return where if isinstance(where, int) and not isinstance(where, bool): got = [m for m in marks if m[0] <= where] return got[-1][1] if got else "" return "" secs: dict[str, dict[str, Any]] = {} seen = set() for x in res: q, kind = x.get("품"), x.get("갈래") if not q or kind not in ("L", "X") or (x.get("코드"), q, x.get("절")) in seen: continue seen.add((x.get("코드"), q, x.get("절"))) title = which(x.get("절")) s = secs.setdefault(title, _part(title, title)) if kind == "L" and (who := trade(x.get("이름"))): s["노임"][who] = s["노임"].get(who, 0) + float(q) elif kind == "X": k = eq_key(f"{x.get('이름') or ''} {x.get('규격') or ''}") or f"?{x.get('코드')}" s["장비"][k] = s["장비"].get(k, 0) + float(q) return [s for s in secs.values() if s["노임"] or s["장비"]]