"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음). 793-1 줄 나무(`M02_San_Stmate`) → `{로직, 원본}`(EST = 원파일 · 엑셀 둘 다 `M02_Doc_NativeEst` · 812-1) — 꼴 = 793-2 가져온 식 SK 만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` → `master_cut.engine` · `요소` 는 SK 서버가 채움). - 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표, 덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?}], 단계? [{이름, 단, 줄, 제외?}], 끝전, 조정?} · STmate 변수 = 식(다시 대입 = `이름_2`) · EST 변수 = 얼린 값 · EST 는 블록 성분을 낸 줄만 합 (나머지 = 합밖) - 호표 줄 = {종류 인력|재료|기계, 이름, 규격, 단위, 수량, 비목, 단가(엑셀), 이음(M01 키 | null)} (비목 하나 자원) | {종류 로직, 이름, 단위, 수량, 값묶음(엑셀), 이음 null}(기계 셋 · 산근 · 고정) · 번지? · 합밖? · 자리? · 덧칸 `자원` {꼴, 갈래, 코드?, 종류?, 이름, 규격, 단위, 단가 {L, M, S}, 비목?, 옮김?}(경쟁사 코드 · 엑셀 단가 원본 · `reprice`) · 이음 = 1차 줄 판정 `M02_Hopyo_Row.m01_picks` 유일 짝(keys=True 일 때) - 원본 = {파일, 시트, 행 [처음, 끝], 갈래 STmate|EST, 엑셀금액 {노무비, 재료비, 경비, 계}(1 단위 · 계약 전), 글줄 [{행, 글, 자리?, 자리들?, 값?, 표지?}], 코드, 표지 [..], 계약? {낙찰률, 금액}} · 글줄 자리 = 셈 줄 이름 | `mid:이름` | `단계:이름` · 값 = 그 줄 엑셀 금액(계) 표지 = 못 옮긴 까닭(0 이 목표) · STmate 계약단가(낙찰률)는 B09 몫. 「참고 · …」 표지(`NOTE`) = 엑셀에 없는 정보를 채우거나 건너뛴 자리(797-1a · 요구 658) — 금액 줄 못 읽음 · 단가 후보 여럿 · 단가 추정 · 당초 칸 사용 · 조정 비율 미지원 · 낙찰률 미적용. """ 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 from M02_MasterTemplete.M02_San_Link import combo_link, hourly_text # noqa: F401 — 밖에서 em. 으로 부름 from M02_MasterTemplete.M02_San_Link import m01 as _m01 COMP = {"L": "노무비", "M": "재료비", "S": "경비"} _CODE = {v: k for k, v in COMP.items()} _SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움) KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"} RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"} RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"} _KEY = re.compile(r"[A-Z]{2}\d{6}") _LABEL = re.compile(r"'([^']*)'") NOTE = "참고 · " # 사람 확인 표지 머리(못 옮김 표지와 가름) 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 resource(name: str, unit: Any, qty: str, res: dict, link: str | None) -> dict: """자원 → 호표 줄 — 비목 하나인 인력 · 재료 · 기계 = 단가 줄 · 그 밖 = 로직 값묶음 줄.""" one = {"이름": name, "규격": res.get("규격") or "", "단위": unit or "", "수량": qty} if res.get("비목") and res["갈래"] in ("인력", "재료", "기계"): price = Decimal(str(res["단가"].get(res["비목"]) or 0)) one |= {"종류": res["갈래"], "비목": COMP[res.get("옮김") or res["비목"]], "단가": price} return {**one, "이음": link, "자원": res} return {**one, "종류": "로직", "값묶음": bundle(res), "이음": None, "자원": res} 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} if "단가" in item: item["단가"] = Decimal(str(src["단가"].get(src["비목"]) or 0)) else: item["값묶음"] = bundle(src) n += 1 return n # ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ───────────────────────────────── def machine_parts(src: dict, same: list | None = None) -> list[dict]: """기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [] · same = 같은 파일에서 구성이 같은 단독 기계 [[이름, 규격]](816-2 · 있으면 「후보 여럿」을 가름).""" if src.get("갈래") != "기계": return [] from M02_MasterTemplete import M02_Machine_Find as mfind return mfind.narrow(mfind.find_all(src.get("이름"), src.get("규격")), same) def first_spec(one: dict, parts: list[dict]) -> dict: """조합 기계 첫 줄 규격 = 그 기계 규격(칩 글 · 「0.7+브레이카」 아님 · 814-1) — 자원 원문은 그대로.""" if len(parts) > 1 and parts[0].get("규격") is not None: one["규격"] = parts[0]["규격"] return one def part_line(main: dict, part: dict, names: "Names") -> dict: """조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음.""" res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True} res.pop("조합첫", None) res["단가"] = dict.fromkeys(res.get("단가") or COMP, 0) title = part["후보"][0]["이름"] if part["키"] else part["이름"] one = {"이름": names.item(str(title)), "규격": part["규격"], "단위": main.get("단위") or ""} one |= {"수량": main["수량"], "자원": res} if main.get("종류") == "로직": # 우리 조합 식(814-1 `조합식`)에 든 다음 줄 = 이음 없음 · 값 0 link = None if res.get("조합식") else hourly_text(part["키"]) one |= {"종류": "로직", "값묶음": bundle(res), "이음": link} else: one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]} return one | ({"자리": main["자리"]} if main.get("자리") else {}) def part_marks(src: dict, parts: list[dict]) -> list[str]: """부분 표지 — 못 이은 부분(후보 여럿 · 못 찾음) · 조합 기계(경비 한 덩이 = 첫 줄 · 다음 줄 0).""" from M02_MasterTemplete import M02_Machine_Find as mfind out = [m for p in parts if (m := mfind.mark(p, p["이름"], p["규격"]))] if len(parts) > 1: said = " ".join(x for x in (str(src.get("이름") or ""), str(src.get("규격") or "")) if x) names = " + ".join( f"{p['후보'][0]['이름']} {p['후보'][0]['규격']}" if p["키"] else p["이름"] for p in parts ) out.append( f"{NOTE}조합 기계 — 경비는 한 덩이 「{said}」 = {names} · 엑셀 값은 첫 줄 · 다음 줄 0" ) return out # ── 내보낼 로직 틀 ───────────────────────────────────────────────────── class Out: def __init__(self, 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.at: dict[Any, dict] = {} # 행 → 글줄 self.marks: list[str] = [] self.row = {"이름": name or str(code), "결과단위": f"원/{unit or '식'}", "입력": []} self.head = {"갈래": form, "코드": code} def glyph(self, at: Any, glyph: str, where: str | None = None, value: Any = None, mark=None): """원본 글줄 한 줄(같은 행이면 자리만 더함).""" one = self.at.get(at) if one is None: one = {"행": at, "글": glyph} self.texts.append(one) self.at[at] = one if where and "자리" in one: one.setdefault("자리들", [one["자리"]]).append(where) elif where: one["자리"] = where if value is not None: one["값"] = value if mark: one["표지"] = f"{one['표지']} / {mark}" if one.get("표지") else mark self.marks.append(f"{at}: {mark}") def middle(self, name: str, formula: str, show: int | None, at: Any, glyph: str) -> None: self.mid.append({"이름": name, "식": formula, **({"표시": show} if show else {})}) self.glyph(at, glyph, f"mid:{name}") def add_line(self, one: dict, item: bool, at: Any, glyph: str, value: Any) -> dict: (self.items if item else self.adds).append(one) self.glyph(at, glyph, one["이름"], value) return one def done(self, end: str, adjust: dict | None, check: dict, sheet: str, file: str, **more): logic = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds} logic |= {"단계": self.steps} if self.steps else {} logic |= {"끝전": end, **({"조정": adjust} if adjust else {})} rows = [x["행"] for x in self.texts if isinstance(x["행"], int)] or [0] source = {"파일": file, "시트": sheet, "행": [min(rows), max(rows)], **self.head} source |= {"엑셀금액": check, "글줄": self.texts, "표지": self.marks, **more} return {"로직": logic, "원본": source} 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]] = [] # 누계 % 덧줄 · 그때 줄 수 self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번) 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["행"], 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.glyph(src["행"], src["글"], mark=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["행"], 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.glyph(src["행"], src["글"], f"단계:{name}", self.rows.get(src["행"], {}).get("C")) o.steps.append(step) self.pending = [] if node["누계번지"]: self.tsum[node["누계번지"]] = name self.last_step = name def line(self, one: dict, node: dict, item: bool, src: dict) -> 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 "값묶음" in one 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, src["행"], src["글"], self.rows.get(src["행"], {}).get("C")) 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) if node.get(_MERGED): # 814-1 — 기계 세 줄(노 · 재 · 경) = 기계 한 줄 · 이름 = 기계 name = str(pr.get("이름") or code) 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]} self.fix.append((one, len(self.order))) return self.line(one, node, False, src) 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}} sole = [c for c in COMP if res["단가"][c]] if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음) one_, to = sole[0], None res |= {"비목": one_, "옮김": to} if one_ else {} parts = machine_parts(res) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 if parts: res["찾기"], link, many = parts[0], parts[0]["키"], None res |= {"조합첫": True} if len(parts) > 1 else {} else: link, many = _m01(res["갈래"], res) if self.keys else (None, None) one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts) if parts and one["종류"] == "로직": one["이음"] = combo_link(one, parts) or hourly_text(link) self.line(one, node, True, src) for at, glyph in node.get(_MERGED) or []: # 묶은 다른 두 줄 원문 = 같은 자리 o.glyph(at, glyph, one["이름"], self.rows.get(at, {}).get("C")) bare = {k: v for k, v in node.items() if k not in ("번지", _MERGED)} for p in parts[1:]: self.line(part_line(one, p, o.names), bare, True, src) seen = (res.get("이름"), res.get("규격")) marks = [] if seen in self.noted else part_marks(res, parts) self.noted.add(seen) for m in [*marks, *([many] if many else [])]: o.glyph(src["행"], src["글"], mark=m) 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 "율" 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]} if "@합" in b: self.fix.append((one, len(self.order))) self.line(one, node, False, src) 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 _num(v: Any) -> float: try: return float(str(v).strip()) except ValueError: return 0.0 def _ratios(meta: dict) -> str: """결과금액 조정 비율 ADJL · ADJM · ADJS 중 1 아닌 것 → 「노무비 0.9 · …」 · 모두 1 = 빈 글.""" pairs = [(COMP[c], meta.get(f"ADJ{c}")) for c in COMP] return " · ".join(f"{n} {v}" for n, v in pairs if v is not None and abs(_num(v) - 1) > 1e-9) _MERGED = "_묶음줄" # 세 줄을 묶은 줄 나무 — 나머지 두 줄 [(행, 글)] _BAD = ("셈 못 함", "엔진 꼴 없음") def triples(nodes: list[dict]) -> list[list[int]]: """814-1 — 같은 기계(X 코드)의 노 · 재 · 경 세 참조 줄(같은 수량 식 · 번지 · 빼기 · 옮김 없음) 자리 [[i, j, k], …] — 사이 빈 줄은 건너뜀.""" out, k = [], 0 while k < len(nodes): at, j = [k], k + 1 while len(at) < 3 and j < len(nodes): if nodes[j].get("종류") == "빈" and not str(nodes[j].get("글") or "").strip(): j += 1 continue at.append(j) j += 1 if len(at) == 3 and _same_machine([nodes[i] for i in at]): out.append(at) k = j else: k += 1 return out def merge(nodes: list[dict], groups: list[list[int]]) -> list[dict]: """세 줄 자리 → 비목 없는 한 줄(값묶음 · 조합이면 그 뒤 기계마다 줄) · 나머지 두 줄 원문은 같은 자리.""" first = {g[0]: g for g in groups} drop = {i for g in groups for i in g[1:]} out = [] for i, node in enumerate(nodes): if i in first: res = {**node["자원"], "비목": None, "옮김": None} rest = [(nodes[j].get("행"), nodes[j].get("글") or "") for j in first[i][1:]] out.append({**node, "자원": res, _MERGED: rest}) elif i not in drop: out.append(node) return out def _same_machine(three: list[dict]) -> bool: if any(x.get("종류") != "참조" or x.get("오류") or not x.get("자원") for x in three): return False res = [x["자원"] for x in three] if not str(res[0].get("코드") or "").startswith("X") or len({r["코드"] for r in res}) != 1: return False if sorted(str(r.get("비목")) for r in res) != ["L", "M", "S"] or any( r.get("옮김") for r in res ): return False return all(len({repr(x.get(k)) for x in three}) == 1 for k in ("식", "번지", "빼기")) def stmate( block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = "" ) -> dict: """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}. rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기 · 기계 세 줄 = 한 줄(`triples` · 814-1) — 묶어서 셈 못 하는 줄이 생기면 묶지 않은 꼴.""" groups = triples(nodes) out = _stmate(block, merge(nodes, groups), prices, rule, keys, file) bad = _bad(out) if not groups or not bad: return out took: list[ list[int] ] = [] # 묶어서 셈 못 하는 세 줄만 그대로(율 범위 C · 누계 % 가 비목 하나를 부름) base = _bad(_stmate(block, nodes, prices, rule, keys, file)) for g in groups: if _bad(_stmate(block, merge(nodes, [*took, g]), prices, rule, keys, file)) <= base: took.append(g) return _stmate(block, merge(nodes, took), prices, rule, keys, file) def _bad(out: dict) -> int: return sum(1 for m in out["원본"]["표지"] if any(b in m for b in _BAD)) def _stmate(block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool, file: str) -> dict: me = prices.get(block["코드"]) or {} meta = me.get("meta") or {} o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"]) o.head["차림"] = True # 812-3 — 계 단으로 항목 · 글 줄(`M02_Doc_NativeLayout`) st = _Stm(o, block, prices, keys) for node in nodes: kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""} if kind in ("빈", "글"): if src["글"].strip().strip("'").strip(): o.glyph(src["행"], src["글"]) o.at[src["행"]]["차림"] = "글" # 812-3 줄 쌓기 글 줄 · 제목(`M02_Doc_NativeLayout`) elif src["글"].strip(): o.glyph(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.glyph(src["행"], src["글"], mark=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]} elif meta.get("ADJ") == "T" and _ratios(meta): # 비율 조정 — 엔진에 없음(몰래 건너뛰지 않음) o.marks.append(f"{NOTE}조정 비율 미지원 · 사람 확인 ({_ratios(meta)})") if meta.get("UC") != "T" and _num(meta.get("NAG")): o.marks.append( f"{NOTE}낙찰률 {meta['NAG']} 있으나 계약단가 아님(UC) — 낙찰률 미적용 · 사람 확인" ) 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), "단가산출근거", file, **more)