Files
eomsangdonandClaude Sonnet 5.5 430aaa9aae feat(797-1): 엑셀 불러오기 요소 연결 — 기계 이음 [우리 단가로] 셈 · 유가 · 환율 이음 · 산근 속 산근 형제 참조 · 읽기 불가 사유 응답
- 기계(EQ) 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(옛 Decimal × dict 실패 387 블록 풀림) · 시간당 자료 없는 EQ = 엑셀 단가 그대로
- 내보내기 `_m01` = 유가(MO) · 환율(FX) 유일 짝만 자동 · 여럿 = 표지 · 화폐는 이름 그대로일 때만(「유로폼」 오판 막음)
- 산근 속 산근 = 같은 엑셀 안쪽 블록을 형제 가져온 식 SK 로 먼저 만들어 `참조`(비목 한 줄 부름 = `참조비목`) · 단가기준 따라감 · 못 잇는 줄 = 얼림 + 표지
- 쓰는 곳 · 층 복사 · 프로젝트 굳힘이 가져온 호표 참조를 봄(`Labor_Ref.refs_in` · `_move_doc`)
- 올림 응답 `읽기불가` + 화면 토스트
- 시험 test_797_1_import_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 14:17:06 +09:00

617 lines
30 KiB
Python

"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 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
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"'([^']*)'")
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
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
if kind not in ("인력", "재료", "기계"):
return None, None
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
if kind == "재료":
if elem.oil_rows(line["이름"]):
kind = "유가"
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
kind = "환율"
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
got = [
{"키": r["키"], "규격": str(r.get("상세구분") or "")}
for r in elem.oil_rows(line["이름"])
]
spec = line["규격"].replace(" ", "")
got = (
[x for x in got if x["규격"] == spec]
or [x for x in got if spec and spec in x["규격"]]
or got
)
else:
got = hrow.m01_picks(kind, line)
keys = {x.get("키") for x in got if x.get("키")}
if len(keys) == 1:
return keys.pop(), None
if len(keys) > 1 and kind in ("유가", "환율"):
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
return None, None
# ── 내보낼 로직 틀 ─────────────────────────────────────────────────────
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]] = [] # 누계 % 덧줄 · 그때 줄 수
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 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 {}
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
one = resource(o.names.item(name), pr.get("단위"), cf, res, link)
self.line(one, node, True, src)
if many:
o.glyph(src["행"], src["글"], mark=many)
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)
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 키(이음) 찾기."""
me = prices.get(block["코드"]) or {}
meta = me.get("meta") or {}
o = Out(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.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)
# ── EST ────────────────────────────────────────────────────────────────
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
def _est_res(role: dict, value: float, comp: str | None) -> 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["비목"]}
return res | ({"모호": True} if role.get("모호") else {})
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
des = ev.designate(lines, want)
mine = {k: c for c, k in des.items() if isinstance(k, int)}
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
rows = block.get("글행") or []
if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a)
o.marks.append(f"{NOTE}{want['칸']}")
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("종류")
at, glyph = rows[k] if k < len(rows) else 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, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
continue
if form not in ("돈", "값"):
shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None
if shown is None:
o.glyph(at, glyph)
else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존)
o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림")
continue
head = {} if comp else {"합밖": True}
try:
place = _place(L.get("규칙"))
if k not in val:
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
if kind == "단가줄":
i, 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] == i)
res = _est_res(role, price, comp)
link, many = _m01(res["갈래"], res) if keys else (None, None)
qty = text(L["식"], {}, {i: "1"})
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
one |= {**head, **({"자리": place} if place else {})}
o.add_line(one, True, at, glyph, L["값"])
if many:
o.glyph(at, glyph, mark=many)
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
elif role.get("갈림") in ("문맥", "기계 세 줄"):
o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}")
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.glyph(at, glyph, mark=str(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, at, glyph, L["값"])
if k in val:
ref[k] = name
check = _money(want) | {"계": want["T"]}
return o.done("비목버림", None, check, "단가산출", file)