- 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 773-3) - M02_Machine_Find.peers · narrow — 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아 후보 가운데 키 하나면 이음 · 여럿 · 없음 = 그대로 - EST 원파일 MachMainDat 묶기(M02_San_Estx.makes) · EST 엑셀 「기계경비」 시트(M02_San_Xlsx.makes) → 자원 「같은구성」 - 저장 묶음(M02_Doc_FromNative) · 줄 만들기(M02_Doc_NativeEst · M02_San_Emit) 가 「같은구성」 을 넘김 - 앞서 짠 우리 표 값 대조는 걷음 · STC = 구성 표(BDQTY) 난독화로 없음 · STmate 엑셀 중기사용료 = 같은 파일 단독 짝 없음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
31 KiB
Python
641 lines
31 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
|
|
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)
|