"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음). 793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → 로직 JSON 한 개(`_틀.md` 7 장 GX 꼴 · 셈 = `master_formula.run` → `master_cut.engine`). 내보낸 로직 꼴: - 키 · 이름 · 결과단위 · 입력 [] · 끝전 · 조정 · 단계 [{이름, 단, 줄, 제외?, 원문, 검산}] - 중간 [{이름, 식, 표시?, 원문}] — STmate = 식(같은 이름 다시 대입 = `이름_2`) · EST = 얼린 값 - 호표 [{종류 로직, 요소 None, 이름, 단위, 수량, 값묶음, 자원, 번지?, 합밖?, 자리?, 원문, 검산}] · 값묶음 = 「엑셀 단가 그대로」 · 자원 = 이음 열쇠 {꼴, 갈래, 코드?, 이름, 규격, 단위, 단가 {L, M, S}, 비목?, 옮김?, M01?} (M01 = 1차 줄 판정 `M02_Hopyo_Row.m01_picks` 유일 짝 · 나중 [우리 단가로]) - 덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?, 원문, 검산}] — 율 · 특수 % · 앞줄 참조 · 값 줄 - 글줄 [{자리, 글, 행?, 표지?}] — 셈 없는 원본 글(자리 = 그 앞 줄 이름 · `mid:이름` · "" = 맨 위) - 가져옴 {꼴, 코드, 검산 {노무비, 재료비, 경비, 계}, 계약?, 표지 [..]} 검산 = 엑셀 값(줄마다 · 블록) · 표지 = 못 옮긴 까닭(0 이 목표). EST 는 블록 성분을 낸 줄만 합에 듦(나머지 줄 = 합밖) · STmate 계약단가(낙찰률)는 B09 몫 — 가져옴.계약. """ from __future__ import annotations import re from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_San_Eval as ev from M02_MasterTemplete import M02_San_Expr as ex COMP = {"L": "노무비", "M": "재료비", "S": "경비"} _CODE = {v: k for k, v in COMP.items()} _SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움) KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"} KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"} RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"} RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"} _KEY = re.compile(r"[A-Z]{2}\d{6}") _LABEL = re.compile(r"'([^']*)'") class EmitError(Exception): """엔진 식으로 못 옮김 — 줄 표지.""" # ── 이름 · 수 ────────────────────────────────────────────────────────── def num(v: float) -> str: """수 → 엔진 수 글(지수 없음 · 정수는 소수점 없이).""" if float(v).is_integer(): return str(int(v)) return format(Decimal(repr(float(v))), "f") class Names: """블록 안 이름 — 줄 · 중간은 한 벌(덧줄 · 중간 = 식 이름 꼴) · 단계는 따로.""" def __init__(self) -> None: self.used: set[str] = set(RESERVED) self.steps: set[str] = set() def line(self, base: str) -> str: """식 이름 꼴(덧줄 · 중간 · 호표 모두 — `줄.'이름'` · 단계 줄 목록).""" s = re.sub(r"[^\w가-힣]", "", base or "") or "줄" if s[0].isdigit() or _KEY.fullmatch(s): s = "줄" + s out, n = s, 1 while out in self.used: n += 1 out = f"{s}_{n}" self.used.add(out) return out def item(self, base: str) -> str: """호표 이름(읽는 글 그대로 · 작은따옴표만 뺌 · `줄.'이름'` 으로 부름).""" s = (base or "").replace("'", "").strip() or "줄" out, n = s, 1 while out in self.used: n += 1 out = f"{s} {n}" self.used.add(out) return out def step(self, base: str) -> str: s = (base or "").replace("'", "").strip() or "계" out, n = s, 1 while out in self.steps: n += 1 out = f"{s} {n}" self.steps.add(out) return out # ── 식 나무 → 엔진 글 ────────────────────────────────────────────────── def text(node: tuple, names: dict[str, str], swap: dict | None = None, kept: dict | None = None): """`M02_San_Expr` 나무 → 엔진 식 글 · names = {원 이름: 엔진 이름} · swap = {수 토큰 차례: 글} · kept = {id(끼움): 엔진 이름}.""" kind = node[0] if kind == "오류": raise EmitError(node[1]) if kind == "수": return swap[node[2]] if swap and node[2] in swap else num(node[1]) if kind == "이름": if node[1] not in names: raise EmitError(f"미정의 변수 {node[1]}") return names[node[1]] if kind == "참조": return "1" if kind == "끼움": if kept and id(node) in kept: return kept[id(node)] return "(" + text(node[2], names, swap, kept) + ")" if kind == "음": return "-(" + text(node[1], names, swap, kept) + ")" if kind == "함수": args = [text(a, names, swap, kept) for a in node[2]] if node[1] == "int": return f"버림({args[0]}, 0)" return f"반올림({args[0]}, {args[1] if len(args) > 1 else 0})" a, b = text(node[2], names, swap, kept), text(node[3], names, swap, kept) if node[1] == "^": k = node[3][1] if node[3][0] == "수" else None if k is None or not float(k).is_integer() or not 0 <= k <= 12: raise EmitError("거듭제곱 — 엔진에 없음") return "(" + " * ".join([f"({a})"] * int(k)) + ")" if k else "1" if node[1] == "*" and "1" in (a, b): # 단가 자리(1) 곱은 뺌 return b if a == "1" else a return f"({a} {node[1]} {b})" def _place(rule: str | None) -> dict | None: """줄 자리 규칙 이름 → 엔진 `자리`(버림1 = 엔진 기본 = 칸 없음).""" if not rule or rule == "버림1": return None m = re.fullmatch(r"(버림|반올림|올림)(\d)", rule) if not m: raise EmitError(f"줄 자리 「{rule}」 — 엔진에 없음") return {"방법": m.group(1), "자리": int(m.group(2))} def bundle(src: dict) -> dict[str, Decimal]: """자원 이음 열쇠 → 값묶음 — 비목 하나(옮김이면 그 비목으로) · 없으면 셋.""" price, one, to = src["단가"], src.get("비목"), src.get("옮김") out = dict.fromkeys(COMP.values(), Decimal(0)) if one: out[COMP[to or one]] = Decimal(str(price.get(one) or 0)) else: for c, name in COMP.items(): out[name] = Decimal(str(price.get(c) or 0)) return out def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: """단가 바꿈 — 자원이 where 칸과 같은 호표 줄 단가 {L, M, S} 고침 · 값묶음 다시 · 고친 줄 수.""" n = 0 for item in logic.get("호표", []): src = item.get("자원") or {} if all(src.get(k) == v for k, v in where.items()): src["단가"] = {**src["단가"], **price} item["값묶음"] = bundle(src) n += 1 return n def _m01(kind: str, src: dict) -> str | None: """1차 줄 판정 유일 짝 M01 키 — 없거나 여럿 = None.""" if kind not in ("인력", "재료", "기계"): return None from M02_MasterTemplete import M02_Hopyo_Row as hrow got = hrow.m01_picks(kind, {k: src.get(k) or "" for k in ("이름", "규격", "단위")}) keys = {x.get("키") for x in got if x.get("키")} return keys.pop() if len(keys) == 1 else None # ── 내보낼 로직 틀 ───────────────────────────────────────────────────── class Out: def __init__(self, key: str, name: str, unit: str, form: str, code: Any) -> None: self.names = Names() self.mid: list[dict] = [] self.items: list[dict] = [] self.adds: list[dict] = [] self.steps: list[dict] = [] self.texts: list[dict] = [] self.marks: list[str] = [] self.step_src: dict[str, dict] = {} # 단계 이름 → {원문, 검산}(엔진 단계 칸 밖) self.last = "" # 글줄 자리 self.row = {"키": key, "이름": name or key, "결과단위": f"원/{unit or '식'}", "입력": []} self.head = {"꼴": form, "코드": code} def text_line(self, glyph: str, at: Any = None, mark: str | None = None) -> None: where = "행" if self.head["꼴"] == "STmate" else "차례" one = {"자리": self.last, "글": glyph} | ({where: at} if at is not None else {}) self.texts.append(one | ({"표지": mark} if mark else {})) if mark: self.marks.append(f"{at}: {mark}") def middle(self, name: str, formula: str, show: int | None, source: dict) -> None: self.mid.append({"이름": name, "식": formula, **({"표시": show} if show else {})}) self.mid[-1]["원문"] = source self.last = f"mid:{name}" def add_line(self, one: dict, item: bool) -> dict: (self.items if item else self.adds).append(one) self.last = one["이름"] return one def done(self, end: str, adjust: dict | None, check: dict, **more: Any) -> dict: out = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds} if self.steps: out["단계"] = self.steps out |= {"끝전": end, **({"조정": adjust} if adjust else {})} out |= {"글줄": self.texts, "가져옴": {**self.head, "검산": check, "표지": self.marks}} if self.step_src: out["가져옴"]["단계"] = self.step_src out["가져옴"] |= more return out def _money(d: dict | None) -> dict[str, Any]: d = d or {} return {COMP[c]: d.get(c) or 0 for c in COMP} # ── STmate ───────────────────────────────────────────────────────────── def _label(node: dict) -> str: """원문 첫 글 조각 「'합판(내수) :'」 → 「합판(내수)」.""" m = _LABEL.search(node.get("글") or "") return m.group(1).split(":")[0].strip() if m else "" class _Stm: """STmate 블록 한 벌 옮김 — 변수 이름 짝 · 그림자 셈(엑셀 셈 실패 줄 가름) · 계 단계.""" def __init__(self, o: Out, block: dict, prices: dict, keys: bool) -> None: self.o, self.prices, self.keys = o, prices, keys self.rows = {ln["행"]: ln for ln in block["몸"]} self.V: dict[str, float] = {} # 그림자 셈(엑셀 셈과 같은 값 · 실패 자리 가름) self.names: dict[str, str] = {} # STmate 변수 → 엔진 중간 이름 self.pending: list[str] = [] # 마지막 계 뒤 합에 드는 줄 self.calls: dict[str, tuple[str, set]] = {} # 줄 이름 → (엔진 부름 글, 값 든 비목) self.tsum: dict[str, str] = {} # 누계번지 → 단계 이름 self.addr: set[str] = set() self.last_step: str | None = None self.order: list[str] = [] # 합에 드는 줄(원문 차례) self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`) self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때) self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수 def xl(self, at: Any) -> dict: ln = self.rows.get(at) or {} return {c: ln.get(k) or 0 for c, k in (("L", "D"), ("M", "E"), ("S", "F"))} def brackets(self, node: tuple, src: dict, kept: dict) -> None: """끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례).""" kind = node[0] if kind == "끼움": self.brackets(node[2], src, kept) if node[1]: body = text(node[2], self.names, kept=kept) eng = self.o.names.line(node[1].lower()) self.names[node[1].lower()] = eng kept[id(node)] = eng self.o.middle(eng, body, None, src) elif kind == "셈": self.brackets(node[2], src, kept) self.brackets(node[3], src, kept) elif kind == "음": self.brackets(node[1], src, kept) elif kind == "함수": for a in node[2]: self.brackets(a, src, kept) def coef(self, node: tuple, src: dict) -> str: kept: dict = {} self.brackets(node, src, kept) return text(node, self.names, kept=kept) def shadow(self, node: tuple) -> float: trial = dict(self.V) v = ex.value(node, {"V": trial, "R": 1.0}) self.V = trial return v def assign(self, node: dict, src: dict) -> None: for part in node["대입"]: try: v = self.shadow(part["식"]) body = self.coef(part["식"], src) except (ex.ExprError, EmitError) as error: if part["이름"] is None: continue self.o.text_line(node["글"], src["행"], f"셈 못 함 — {error}") return if part["이름"] is None: continue self.V[part["이름"]] = ex.rnd(v, 2) if part["찍기"] else v eng = self.o.names.line(part["이름"]) self.names[part["이름"]] = eng self.o.middle(eng, body, 2 if part["찍기"] else None, src) def total(self, node: dict, src: dict) -> None: o = self.o name = o.names.step(str(self.rows.get(src["행"], {}).get("B") or "계")) step = {"이름": name, "단": node["단"], "줄": self.pending} step |= {"제외": True} if node["제외"] else {} mine, keep = list(self.pending), [] for low, got in self.open: (mine.extend(got) if low < node["단"] else keep.append((low, got))) self.open = keep if node["제외"] else [*keep, (node["단"], mine)] for n in mine if node["제외"] else []: self.out[n] = len(self.order) o.step_src[name] = {"원문": src, "검산": _money(self.xl(src["행"]))} o.steps.append(step) self.pending = [] if node["누계번지"]: self.tsum[node["누계번지"]] = name self.last_step = name o.last = f"단계:{name}" def line(self, one: dict, node: dict, item: bool) -> None: if node.get("번지") and node["번지"].startswith("A"): one["번지"] = node["번지"] self.addr.add(node["번지"]) money = one.get("값묶음") or {} comps = {c for c in COMP if money.get(COMP[c])} if item else {_CODE[one["비목"]]} self.calls[one["이름"]] = (f"줄.'{one['이름']}'" if item else one["이름"], comps) if node.get("빼기"): one["합밖"] = True else: self.pending.append(one["이름"]) self.order.append(one["이름"]) self.o.add_line(one, item) def ref(self, node: dict, src: dict, cf: str) -> None: o, code = self.o, node["자원"]["코드"] pr = self.prices[code] unt = (pr.get("meta") or {}).get("UNT") one_, to = node["자원"]["비목"], node["자원"]["옮김"] name = _label(node) or str(pr.get("이름") or code) xl = self.xl(src["행"]) if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시 dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0]) if unt == "T%": last = self.last_step base = f"단계.'{last}'.계" if last else f"({' + '.join(map(_SUM.format, COMP))})" formula = f"{base} * ({cf})" else: formula = f"{_SUM.format(unt[0])} * ({cf}) / 100" one = {"이름": o.names.line(name), "식": formula, "비목": COMP[dst]} one |= {"원문": src, "검산": {COMP[dst]: xl[dst]}} self.fix.append((one, len(self.order))) return self.line(one, node, False) res = {"꼴": "STmate", "갈래": KIND.get(code[0], "고정"), "코드": code} res |= {k: pr.get(k) for k in ("이름", "규격", "단위")} res |= {"단가": {c: pr.get(c) or 0 for c in COMP}} res |= {"비목": one_, "옮김": to} if one_ else {} if self.keys and (m01 := _m01(res["갈래"], res)): res["M01"] = m01 one = {"종류": "로직", "요소": None, "이름": o.names.item(name)} one |= {"단위": pr.get("단위") or "", "수량": cf, "값묶음": bundle(res), "자원": res} self.line(one | {"원문": src, "검산": _money(xl)}, node, True) def rate(self, node: dict, src: dict, cf: str) -> None: rate, o = node["율"], self.o area = rate["범위"] def base(c: str) -> str: if area == "A": got = [f"번지.{s}.{COMP[c]}" for s in rate["번지"] if s in self.addr] return " + ".join(got) or "0" if area == "T": step = self.tsum.get(rate["번지"][0]) return f"단계.'{step}'.{COMP[c]}" if step else "0" if area == "C": # 마지막 계 뒤 줄 — 비목 하나인 줄만 부름으로 더함 got = [] for n in self.pending: call, comps = self.calls[n] if len(comps) > 1: raise EmitError("율 범위 C 에 비목 여럿인 줄 — 엔진 꼴 없음") got += [call] if comps == {c} else [] return " + ".join(got) or "0" return _SUM.format(c) pct = f"({cf})" if rate["곱"] else f"({cf}) / 100" src_, dst = rate["비목"], rate["옮김"] if src_: parts = [(dst or src_, base(src_))] elif dst: parts = [(dst, " + ".join(f"({base(c)})" for c in COMP))] else: parts = [(c, base(c)) for c in COMP] label = _label(node) or "율" xl = self.xl(src["행"]) for c, b in parts: nm = o.names.line(label if len(parts) == 1 else f"{label}_{COMP[c]}") one = {"이름": nm, "식": f"({b}) * {pct}", "비목": COMP[c]} one |= {"원문": src, "검산": {COMP[c]: xl[c]}} if "@합" in b: self.fix.append((one, len(self.order))) self.line(one, node, False) def finish(self) -> None: """누계 % 밑 = 그 줄 앞까지 합 — 엔진 비목 합(호표 다 · 앞 덧줄 · 제외 단계 뺌)에서 뒤에 오는 호표를 빼고 아직 안 빠진 제외 단계 줄을 더함.""" for one, pos in self.fix: for c, name in COMP.items(): mark = _SUM.format(c) if mark not in one["식"]: continue minus = [n for n in self.order[pos:] if n not in self.out] plus = [n for n in self.order[:pos] if self.out.get(n, -1) >= pos] terms = [] for sign, group in (("-", minus), ("+", plus)): for n in group: call, comps = self.calls[n] if c not in comps or (sign == "-" and not call.startswith("줄.")): continue if len(comps) > 1: raise EmitError(f"누계 % 밑에 비목 여럿인 줄 「{n}」 — 엔진 꼴 없음") terms.append(f" {sign} {call}") one["식"] = one["식"].replace(mark, f"({name}{''.join(terms)})" if terms else name) def _cols(row: dict) -> dict[str, Any]: return _money({"L": row["D"], "M": row["E"], "S": row["F"]}) | {"계": row["C"] or 0} def stmate(block: dict, nodes: list[dict], prices: dict, rule: str, key: str, keys: bool = False): """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → 로직 JSON. rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키 찾기.""" me = prices.get(block["코드"]) or {} meta = me.get("meta") or {} o = Out(key, block.get("이름"), me.get("단위"), "STmate", block["코드"]) st = _Stm(o, block, prices, keys) for node in nodes: kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""} if kind in ("빈", "글"): if src["글"].strip(): o.text_line(src["글"], src["행"]) elif kind == "대입": st.assign(node, src) elif kind == "계": st.total(node, src) else: try: if node.get("오류"): raise EmitError(node["오류"]) if kind == "참조" and node["자원"]["코드"] not in prices: raise EmitError(f"단가 없음 {node['자원']['코드']}") st.shadow(node["식"]) cf = st.coef(node["식"], src) (st.rate if kind == "율" else st.ref)(node, src, cf) except (ex.ExprError, EmitError) as error: o.text_line(src["글"], src["행"], f"셈 못 함 — {error}") try: st.finish() except EmitError as error: o.marks.append(str(error)) tails = [t for t in block["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")] kye = (tails or [block["머리"]])[0] adjust, sbm = None, (meta.get("SBM") or "").strip() if meta.get("ADJ") == "T" and sbm in COMP: adjust = {"옮김": COMP[sbm]} more = {} if meta.get("UC") == "T" and meta.get("NAG"): # 계약단가 = B09 몫(엔진 밖) more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])} end = "큰나머지" if rule.startswith("합버림") else "비목버림" return o.done(end, adjust, _cols(kye), **more) # ── EST ──────────────────────────────────────────────────────────────── _VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=") def _est_res(role: dict, value: float, comp: str | None, keys: bool) -> dict: """EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때).""" p = role["정체"] pc = p["비목"] if p["비목"] in COMP else (comp or "S") res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]} res |= {"이름": p["이름"], "규격": p["규격"], "단위": None} res |= {"단가": {pc: value}} res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]} if role.get("모호"): res["모호"] = True if keys and (m01 := _m01(res["갈래"], res)): res["M01"] = m01 return res def est(block: dict, lines: list[dict], want: dict, key: str, keys: bool = False) -> dict: """EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) + 총괄표 성분 want {T, L, M, S} → 로직 JSON · 성분을 낸 줄만 합(나머지 = 합밖).""" des = ev.designate(lines, want) mine = {k: c for c, k in des.items() if isinstance(k, int)} o = Out(key, block.get("이름"), block.get("단위"), "EST", block.get("번")) for c, k in des.items(): if k is None: o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음") val = ev.run_est(lines) ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합) for L in lines: k, form, kind = L["차례"], L["꼴"], L.get("종류") src = {"차례": k, "글": L["원글"]} comp = mine.get(k) if form == "빈": continue if form == "변수" and L.get("값") is not None: m = _VAR.search(L["원글"]) eng = o.names.line(m.group(1) if m else "변수") o.middle(eng, num(L["값"]), None, src) # 얼린 값(금액엔 보인 값이 박힘) continue if form not in ("돈", "값"): o.text_line(L["원글"], k) continue head = {"원문": src, "검산": {COMP[comp or L.get("비목") or "S"]: L["값"]}} head |= {} if comp else {"합밖": True} try: place = _place(L.get("규칙")) if k not in val: raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림") if kind == "단가줄": at, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가") price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == at) res = _est_res(role, price, comp, keys) one = {"종류": "로직", "요소": None, "이름": o.names.item(L.get("라벨") or "")} one |= {"단위": "", "수량": text(L["식"], {}, {at: "1"}), "값묶음": bundle(res)} one |= {"자원": res, **head, **({"자리": place} if place else {})} o.add_line(one, True) ref[k] = f"줄.'{one['이름']}'" continue if kind == "값줄": parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]] formula = " + ".join(parts) else: swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref} formula = text(L["식"], {}, swap) except EmitError as error: o.marks.append(f"{k}: {error}") formula, place = num(L["값"]), None name = o.names.line(L.get("라벨") or "줄") one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]} o.add_line(one | head | ({"자리": place} if place else {}), False) if k in val: ref[k] = name check = _money(want) | {"계": want["T"]} return o.done("비목버림", None, check)