Files
Aislo/M02_MasterTemplete/M02_Hopyo_Shares.py
T
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보)
- Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음
- 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미
- 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:36:23 +09:00

178 lines
7.2 KiB
Python
Raw 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 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["장비"]]