diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py new file mode 100644 index 000000000..302dc9bea --- /dev/null +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -0,0 +1,545 @@ +"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(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) diff --git a/resources/tester/test_793_2_san_emit.py b/resources/tester/test_793_2_san_emit.py new file mode 100644 index 000000000..a4b46ec45 --- /dev/null +++ b/resources/tester/test_793_2_san_emit.py @@ -0,0 +1,249 @@ +"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀. + +여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` → +`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) = +참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음. +단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 × +낙찰률 원 버림 = 엑셀 머리만 확인. 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip). +""" + +from __future__ import annotations + +import importlib +import math +from functools import lru_cache + +import helper_m02_hopyo_golden as golden +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠 +from M02_MasterTemplete import M02_San_Book as book +from M02_MasterTemplete import M02_San_Emit as em +from M02_MasterTemplete import M02_San_Est as est +from M02_MasterTemplete import M02_San_Eval as ev +from M02_MasterTemplete import M02_San_Stmate as stm + +mf = importlib.import_module("master_formula") +mcut = importlib.import_module("master_cut") +ms = importlib.import_module("master_formula_sum") + +STMATE = { + "봉화": (62, "비목버림"), + "동부청영월": (69, "합버림_큰나머지"), + "울진대흥": (47, "비목버림"), + "영덕계류": (15, "비목버림"), + "울진소광": (40, "비목버림"), +} +EST = {"북부청임도": 94, "북부청사방": 67, "거창": 61} +# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음 +EST_FROZEN = {"북부청임도": {27: ["37"], 33: ["51"]}} +K = ("노무비", "재료비", "경비", "계") +LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"} + + +def engine(logic: dict) -> dict: + master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}}) + return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {})) + + +def money(got: dict) -> tuple: + return tuple(int(got[k]) for k in K) + + +@lru_cache(maxsize=8) +def _load(tag: str) -> dict: + path = golden.FILES[tag] + if not path.exists(): + pytest.skip(f"실무 엑셀 없음 {path.name}") + return book.load(path) + + +def _stmate(tag: str, blk: dict) -> tuple[list, dict]: + nodes = stm.parse_block(blk["몸"]) + return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][1], "GX990001") + + +def _lines_equal(logic: dict, got: dict) -> list: + """줄마다 엔진 비목 금액 = 엑셀 · 단계 = 계표시 줄.""" + bad = [] + for item, line in zip([*logic["호표"], *logic["덧줄"]], got["줄"], strict=True): + for k, want in item["검산"].items(): + if abs(float(line["비목"].get(k) or 0) - want) >= 0.05: + bad.append((item["이름"], k, line["비목"].get(k), want)) + for step in got.get("단계", []): + want = logic["가져옴"]["단계"][step["이름"]]["검산"] + if any(abs(float(step[k]) - want[k]) >= 0.15 for k in want): + bad.append((step["이름"], want)) + return bad + + +@pytest.mark.parametrize("tag", list(STMATE)) +def test_stmate_engine_equals_excel(tag: str) -> None: + data = _load(tag) + n, wrong, lines, steps = 0, [], 0, 0 + for blk in data["블록"]: + if not any(ln["G"].strip() for ln in blk["몸"]): + continue + n += 1 + nodes, logic = _stmate(tag, blk) + got = engine(logic) + ref = ev.stmate_block(blk, nodes, data["단가"], STMATE[tag][1])["계"] + want = logic["가져옴"]["검산"] + same = money(got) == tuple(round(want[k]) for k in K) + same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) + bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + logic["가져옴"]["표지"] + if deal := logic["가져옴"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리 + cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]] + same &= cut == [deal["금액"][k] for k in K[:3]] + if not same or bad: + wrong.append((blk["코드"], money(got), bad[:3])) + lines += len(logic["호표"]) + len(logic["덧줄"]) + steps += len(got.get("단계", [])) + assert (n, wrong) == (STMATE[tag][0], []) + assert lines >= 1 and steps >= 1 + + +@pytest.mark.parametrize("tag", list(EST)) +def test_est_engine_equals_excel(tag: str) -> None: + data = _load(tag) + n, wrong = 0, [] + for blk in data["블록"]: + want = data["총괄"].get(blk["번"]) + if not want or not (want["T"] or any(want[c] for c in "LMS")): + continue + n += 1 + parsed = est.parse_block(blk["글"], data["단가"]) + logic = em.est(blk, parsed, want, "GX990002") + got = engine(logic) + ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"] + same = money(got) == (want["L"], want["M"], want["S"], want["T"]) + same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) + frozen = [m.split(":")[0] for m in logic["가져옴"]["표지"]] + bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], []): + wrong.append((blk["번"], money(got), bad[:3], frozen)) + assert (n, wrong) == (EST[tag], []) + + +@pytest.mark.parametrize( + "tag, code, labor, before, after", + [ + ("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)), + ("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)), + ("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)), + ], +) +def test_stmate_price_propagates(tag, code, labor, before, after) -> None: + blk = next(b for b in _load(tag)["블록"] if b["코드"] == code) + _, logic = _stmate(tag, blk) + assert money(engine(logic))[:3] == before + assert em.reprice(logic, {"코드": labor}, {"L": 180000}) >= 1 + assert money(engine(logic))[:3] == after + + +@pytest.mark.parametrize( + "tag, no, after", + [ + ("북부청사방", 31, (14877, 5491, 0)), + ("북부청임도", 2, (22774, 3235, 3830)), + ("거창", 1, (3485, 832, 1049)), + ("북부청사방", 3, (20147, 391, 0)), + ], +) +def test_est_price_propagates(tag, no, after) -> None: + data = _load(tag) + blk = next(b for b in data["블록"] if b["번"] == no) + logic = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no], "GX990002") + assert em.reprice(logic, LABOR, {"L": 180000.0}) >= 1 + assert money(engine(logic))[:3] == after + + +def test_link_keys_m01() -> None: + """자원 이음 열쇠 — 엑셀 품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키.""" + tag = "봉화" + blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102") + logic = em.stmate( + blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", "GX990001", True + ) + labor = next(x for x in logic["호표"] if x["이름"] == "보통인부") + assert labor["자원"] | {"M01": labor["자원"].get("M01")} == { + "꼴": "STmate", + "갈래": "인력", + "코드": "L00007", + "이름": "보통인부", + "규격": None, + "단위": "인", + "단가": {"L": 165545, "M": 0, "S": 0}, + "M01": "LB000002", + } + assert labor["값묶음"] == {"노무비": 165545, "재료비": 0, "경비": 0} + + +# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` 그대로) ── +def test_stmate_grammar_sample_engine() -> None: + prices = { + "M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}, "이름": "합판(내수)"}, + "L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}, "이름": "보통인부"}, + "L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}, "이름": "공구"}, + } + text = [ + "'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01", + ";;Q = 2.5 / 3 =", + "'보통인부' &L00007& * [Q] / 4 :=A02", + "&PA01M:M& * 11'%' =", + "&PA02L:S& * 1'%' = // 공구손료", + "(=)", + "&L00099& * 2 =", + "(-=)", + "'별도' &M00081& * 2 =", + "Q = 1 / 3 =", + "&L00099& * [Q] =", + "'뒤 인부' &L00007& * 0.01 =", + ] + rows = [{"행": i, "G": g} for i, g in enumerate(text)] + nodes = stm.parse_block(rows) + block = {"코드": "D00001", "이름": "견본", "머리": {}, "꼬리": [], "몸": rows} + block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0} + logic = em.stmate(block, nodes, prices, "비목버림", "GX990003") + assert [x["이름"] for x in logic["중간"]] == ["q", "q_2"] # 다시 대입 = 새 이름 + assert logic["중간"][0] == { + "이름": "q", + "식": "(2.5 / 3)", + "표시": 2, + "원문": {"행": 1, "글": text[1]}, + } + assert ms.check(logic, "견본") == [] and logic["가져옴"]["표지"] == [] + got = engine(logic) + lines, tot = ev.run_stmate(nodes, prices) + want = ev.block_total(tot, "비목버림") + assert money(got) == (want["L"], want["M"], want["S"], want["T"]) + by = {x["이름"]: x for x in got["줄"]} + assert by["보통인부"]["비목"]["노무비"] == 34350.5 # 165,545 × 0.83 / 4 + assert by["공구"]["비목"]["노무비"] == 687 # 노무 누계 × 2 % · (-=) 로 합에서 뺌 + assert logic["덧줄"][-1]["식"] == "(노무비 - 줄.'뒤 인부') * ((q_2)) / 100" # 뒤 줄을 뺀 누계 + assert ( + float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3 + ) # 34,350.5 × 0.33 % + + +def test_est_grammar_sample_engine() -> None: + prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}] + text = [ + "보통인부 : 172,068 × 0.09 인 = 15,486.1", + "공구손료 : 15,486.1 × 3 % = 464.5", + "소 계 : 15,950.6", + "노무비 : 15,950.6 × 1 = 15,950", + ] + lines = est.parse_block(text, prices) + want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0} + logic = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want, "GX990004") + assert [x["식"] for x in logic["덧줄"]] == [ + "((줄.'보통인부' * 3) / 100)", + "줄.'보통인부' + 공구손료", + "소계", + ] + assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0} + assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None] + assert money(engine(logic)) == (15950, 0, 0, 15950) + em.reprice(logic, LABOR, {"L": 180000.0}) + assert money(engine(logic)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음