Files
Aislo/M02_MasterTemplete/M02_San_Emit.py
T
eomsangdonandClaude Opus 5.5 3d4b2ec1d1 feat(812-9): 가져온 기계 잇기 다시 — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 범위 규격 · 별칭 먼저 · 조합 기계 둘 · 조회어 기종만
- 새 `M02_Machine_Find` — 기종 = 기계표 이름 · 괄호 붙임 · 상세구분(낱말표 24 안 씀) · 소리 표기 한 글(로우더 = 로더 · 로울러 = 롤러 · 브레이카 = 브레이커 · 굴삭기 = 굴착기 · 램 머 = 래머 …) · 기계표 괄호 낱말로 좁힘(표에 없는 낱말 발파 · 리핑 = 안 봄) · 규격 = 수 + 단위 전부(범위 · 복수 · 인치 = ㎝) · 하나 = 이음 · 여럿 = 「기계 후보 n — 고르기」 · 0 = 「기계 못 찾음」 표지(가까운 규격으로 대신 잇지 않음)
- 별칭 먼저 — 마스터 층 + 지금 층 별칭 장부 기계 짝(이름 + 규격 · 이름만 · 규격 속 할증 글 뺌) · `M02_Doc_Match_Note._ALIAS` 여섯 쌍 걷음(소리 표기 = 정규화 · 같은 기종 = 기계표 · 별칭 장부)
- 조합 기계 = 기계마다 항목 하나(요구 738-3 · 제목 = 그 기계 · 줄 쌓기 차림 `M02_Doc_NativeLayout`) · 줄 둘(「+」 · 괄호 속 다른 기종 · 「…조합」 · 덤프트럭(자동덮개) = 덤프트럭 + 자동덮개시설) — 같은 수량 식 · 엑셀 값은 첫 줄 · 다음 줄 0 + 표지 「조합 기계 — 경비는 한 덩이」 · 우리 단가로 = 각자 시간당 경비
- 원파일 EST · STmate · 엑셀 읽기기 · 일위대가 호표(`Hopyo_Elem` · `Hopyo_Fixed._eqs` · `Hopyo_Row.m01_picks`)가 같은 찾기 · STmate 묶음 기계 줄도 시간당 경비 이음
- 문서 머리 `가져온.기계`(기호마다 판정 · 후보 · 찾기어) · 못 이은 기계 줄도 기계 줄(이름 단추 · `owned_of` 파이썬 · TS 짝) · 이름 단추 조회어 = 기종 이름만(규격 뺌) · 고르면 엑셀 값 기억 그대로(금액 안 바뀜) · 문서 [저장] = 고른 EQ → 이 층 별칭 장부 줄 → M01 별칭 장부 후보(승인 흐름 그대로) · 장부 짝 글 = 「기계 EQ…」
- 마스터 층 별칭 장부(resources/master_template/실무자료/맞춤.json) = 사용자 승인 다섯(요구 735-5 · 736) — 아스팔트페이버(피니셔) = 아스팔트 피니셔 · 디젤 엔진 = 엔진(디젤) · 워터제트 = 워터젯트 · 버킷준설기 = 버킷식준설기 · 바이브레이트 봉상후렉시블 = 콘크리트 진동기 엔진식 ø45(2.6㎾)
- 시험 — 새 test_812_9(예 전부 · 정규화 · 괄호 · 범위 · 조합 둘 · 별칭 먼저 · 표지 · 고르면 별칭 줄 · 금액 전후) · test_812_3 전석쌓기 · 측구터파기 연암 항목을 기계마다 항목으로 고침(조합 기계 둘로 가름 · 요구 738-3 이 바꾼 까닭)
- 전수 목록 docs/raw/요구_1104/_목록_812-9_기계이음.xlsx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 21:02:29 +09:00

608 lines
30 KiB
Python

"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(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
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
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 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 == "기계"
): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음)
from M02_MasterTemplete import M02_Machine_Find as mfind
got = mfind.find(line["이름"], line["규격"])
return got["키"], mfind.mark(got, line["이름"], line["규격"])
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
def hourly_text(key: str | None) -> str | None:
"""기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None."""
if not key:
return None
from M02_MasterTemplete import M02_Doc_Import as dim
got = dim.hourly_link(key, "노무비")
if not got:
return None
return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})"
# ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ─────────────────────────────────
def machine_parts(src: dict) -> list[dict]:
"""기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = []."""
if src.get("갈래") != "기계":
return []
from M02_MasterTemplete import M02_Machine_Find as mfind
return mfind.find_all(src.get("이름"), src.get("규격"))
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("종류") == "로직":
one |= {"종류": "로직", "값묶음": bundle(res), "이음": hourly_text(part["키"])}
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 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 = resource(o.names.item(name), pr.get("단위"), cf, res, link)
if parts and one["종류"] == "로직":
one["이음"] = hourly_text(link)
self.line(one, node, True, src)
bare = {k: v for k, v in node.items() if k != "번지"}
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)
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["코드"])
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)