Files
Aislo/M02_MasterTemplete/M02_Doc_NativeEst.py
T
eomsangdonandClaude Opus 5.5 ff19138727 feat(816-2): 후보 여럿 기계를 같은 파일 안 구성 대조로 가름
- 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 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>
2026-11-11 19:46:23 +09:00

627 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""EST 원파일 산근 블록 → 가져온 로직 줄(PLAN 810-2 · 요구 705 · 706 · 서버 단독 · 읽기만).
바탕 = `M02_San_Estx.read` 구조 줄(칸 뜻 `resources/master_data/_원파일파서_틀.md`) — 엑셀 글 추정 대신
원파일 표지로 정함(`M02_San_Est` 값 역색인 안 씀):
단가 = `\\x03` 수 + 자원 코드(LinkIndex) · 변수 = `\\x11` 수(앞 변수 줄 값) · 줄참조 = `\\x0c` 수 ·
결과 = `\\x12` 수 · 비목 = SCheck(음수 = 그 줄이 냄 · 율 · 소계 양수 = 대상).
꼴 = 793-2a 내보내기(`M02_San_Emit.Out`) 그대로 → `M02_Sangeun_Imported.create` · `M02_Doc_Import.derive`
가 수식 문서로(엑셀 길과 같은 길 · 같은 연결 규칙 `M02_San_Emit._m01` · 797-1 기계 시간당 이음).
변수 대입 줄 = 중간(변수 그대로 · 공식 글 · 분수 그림 ─ 은 위아래 줄 칸 · 결과와 맞을 때만 식 ·
아니면 결과 값 + 표지) · 자원 줄 = 호표(단가 = 원파일 값 · 수량 = 식 · 변수 = 이름) ·
기계 · 산근 · 견적 세 줄(같은 코드 · 노 · 재 · 경 · 같은 수량 식) = 로직 값묶음 한 줄(소계 줄 = 글) ·
소계(61) = 합 밖 덧줄(출처 `SUB` · 문서에서 비목 칸 비움) + 아래 줄이 그 비목으로 합에 듦 ·
율(58 · 62) = 덧줄(줄참조 = 앞 줄 부름 · 구간 `\\x07X\\x07` = `\\x06X\\x06` 줄 합) ·
합계(\\x1a) = 글(블록 합 = 문서 비목 합 · 끝수 버림 0).
못 읽은 줄 = 보인 결과 값 얼림 + 「참고 · …」 표지(`NOTE`) — 지어 넣는 값 없음.
"""
from __future__ import annotations
import re
from M02_MasterTemplete import M02_Doc_NativeLayout as lay
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Expr as ex
COMP = em.COMP
NOTE = em.NOTE
SUB = "원파일 소계" # 덧줄 출처 — 합 밖 소계(문서 비목 칸 비움 · `M02_Doc_Import`)
TRIPLE = (101, 104, 105) # 견적 · 산근 · 기계 — 노 · 재 · 경 세 줄
GALLAE = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
GALLAE["표준"] = "고정"
NATURAL = {"노임": "L", "자재": "M"}
_SENT = 7_770_000.0 # 변수 이름 자리 표 수(토큰 읽기용 · 셈 전에 값으로 바꿈)
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
_DROP = re.compile(r"\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\x06[A-Z]\x06")
_GROUP = re.compile(r"\x07([A-Z])\x07\s*\x1a[^\x1a]*\x1a")
_CTRL = re.compile(r"[\x00-\x1f]")
GUESS = "추정"
_COND = re.compile(r"품셈|기준|적용|이상|이하|미만|초과")
def _clean(raw: str) -> str:
return _CTRL.sub("", _DROP.sub(" ", raw or ""))
def _close(a: float, b: float) -> bool:
return abs(a - b) < 1e-6 * max(1.0, abs(b))
def _fit(got: float, want: float) -> str | None:
"""보인 값이 되는 자리 규칙 — `M02_San_Expr.fit`(3 자리까지) + 버림 · 반올림 4~6 자리(EST Diget)."""
if rule := ex.fit(got, want):
return rule
for n in (4, 5, 6):
q = 10**n
if abs(ex.math.floor(round(got * q, 6)) / q - want) < 1e-9:
return f"버림{n}"
if abs(ex.math.floor(got * q + 0.5 + 1e-9) / q - want) < 1e-9:
return f"반올림{n}"
return None
class Block:
"""EST 산근 블록 한 벌 → `{로직, 원본}`."""
def __init__(self, blk: dict, no: int, keys: bool, file: str, names: dict[str, int]) -> None:
name = str(blk.get("이름") or blk.get("코드") or "")
if blk.get("규격"):
name = f"{name}({blk['규격']})"
self.o = em.Out(name, blk.get("단위"), "EST", no)
self.o.head |= {"원파일": blk.get("코드")}
self.blk, self.keys, self.file, self.nos = blk, keys, file, names
self.lines: list[dict] = blk.get("줄") or []
self.vars: dict[str, tuple[str, float]] = {} # 원 이름 → (엔진 이름, 값)
self.done: list[tuple[float, str, dict]] = [] # (결과, 부름 글, 줄) — 줄참조 찾기
self.groups: dict[str, list[tuple[str, float]]] = {} # 구간 글자 → (부름 글, 결과)
self.extra: dict[int, dict] = {} # 행 → 소계가 정한 비목(아래 줄)
self.got = {"확정": 0, "표지": 0, "추정": 0}
self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌)
self.frac: set[int] = set()
self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번)
# ── 작은 도구 ──────────────────────────────────────────────────
def mark(self, ln: dict, why: str) -> None:
self.o.glyph(ln["행"], ln.get("보임") or "", mark=f"{NOTE}{why}")
self.got["표지"] += 1
def _sub(self, seg: str) -> tuple[str, dict[float, str]]:
"""식 조각 속 아는 변수 이름 → 표 수(뒤에서 이름으로 바꿈)."""
out, k = {}, 0
for name in sorted(self.vars, key=len, reverse=True):
pat = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(name)}(?![A-Za-z0-9_])")
if pat.search(seg):
k += 1
mark = _SENT + k
out[mark] = name
seg = pat.sub(em.num(mark), seg)
return seg, out
def formula(
self, seg: str, want: float | None, extra: dict[float, float] | None = None
) -> tuple[tuple, dict, str | None] | None:
"""식 조각 → (나무, {토큰 차례: 변수 이름}, 자리 규칙) · 결과와 맞지 않으면 None ·
extra = {표 수: 값}(구간 합 자리)."""
seg, names = self._sub(_power(seg.replace("·", "×")))
toks = est.tokens(seg)
if not toks:
return None
try:
tree = ex.parse(toks)
except ex.ExprError:
return None
vars_ = {n[2]: names[n[1]] for n in ex.numbers(tree) if n[1] in names}
sub = {i: self.vars[v][1] for i, v in vars_.items()}
extra = extra or {}
sub |= {n[2]: extra[n[1]] for n in ex.numbers(tree) if n[1] in extra}
try:
got = ex.value(tree, {"V": {}, "sub": sub})
except ex.ExprError:
return None
same = want is None or abs(got - want) <= 1e-9 * max(1.0, abs(want))
rule = "그대로" if same else _fit(got, want)
return (tree, vars_, rule) if rule else None
def ref_of(self, value: float, before: int) -> str | None:
"""줄참조 수 → 앞 줄 부름(가장 가까운 같은 결과)."""
for v, call, ln in reversed(self.done):
if ln["행"] < before and _close(v, value):
return call
return None
def var_of(self, value: float) -> str | None:
for name, (_, v) in reversed(list(self.vars.items())):
if _close(v, value):
return name
return None
# ── 변수 줄 ────────────────────────────────────────────────────
def assigns(self, i: int, ln: dict) -> bool:
"""「q = 0.7 K = 0.55 f = 1 / 1.325 = 0.75」 · 분수 그림 Q = ── = ── = 23.39 → 중간."""
text = _clean(ln.get("글") or "")
hits: list[re.Match] = []
for m in _ASSIGN.finditer(text): # 앞 대입 값이 끝난 뒤(연산 · = 뒤 아님)만 새 대입
gap = text[hits[-1].end() : m.start()] if hits else ""
if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)):
hits.append(m)
lead = text[: hits[0].start()].strip(" ○●·-") if hits else ""
if not hits or (lead and not re.search(r"[::]$", lead)):
return False
for n, m in enumerate(hits):
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
self.assign(i, ln, m.group(1), text[m.end() : end])
return True
def assign(self, i: int, ln: dict, name: str, rhs: str) -> None:
parts = rhs.split("=")
lead = re.match(r"\s*(" + est.NUMRE + r")", parts[-1])
if not lead:
return
value = est.num(lead.group(1))
body = None
for seg in parts[:-1]:
if "─" in seg:
seg = self.fraction(i, seg)
got = self.formula(seg, value) if seg else None
if got:
body = self.text(got)
break
eng = self.o.names.line(name)
if body is None:
body = em.num(value)
if len(parts) > 1:
self.mark(ln, f"변수 {name} 공식 글 못 읽음 — 결과 값 {em.num(value)}")
self.vars[name] = (eng, value)
self.o.middle(eng, body, None, ln["행"], ln.get("보임") or "")
self.got["확정"] += 1
def fraction(self, i: int, seg: str) -> str:
"""분수 그림 ─ = 위 줄 · 아래 줄 같은 차례 칸(node `native-text` 와 같은 읽기)."""
def cols(k: int) -> list[str]:
if not 0 <= k < len(self.lines):
return []
t = _clean(self.lines[k].get("글") or "")
return re.sub(r"\s*([×÷·+*/−-])\s*", r"\1", t).strip().split()
tops, bottoms, at = cols(i - 1), cols(i + 1), [0]
self.frac |= {self.lines[k]["행"] for k in (i - 1, i + 1) if 0 <= k < len(self.lines)}
def one(_: re.Match) -> str:
k = at[0]
at[0] += 1
top = tops[k] if k < len(tops) else ""
low = bottoms[k] if k < len(bottoms) else ""
return f"({top})/({low})" if top and low else "?"
return re.sub(r"─+", one, seg)
def text(self, got: tuple, swap: dict[int, str] | None = None) -> str:
"""식 글 — 자리 규칙(그대로 빼고)을 식 안 끝수로(금액 줄은 `money` 가 버림1 을 넘김 = 줄 끝수)."""
tree, vars_, rule = got
swap = {**{i: self.vars[v][0] for i, v in vars_.items()}, **(swap or {})}
body = em.text(tree, {}, swap)
m = re.fullmatch(r"(버림|반올림|올림)(\d)", rule or "")
if m:
body = f"{m.group(1)}({body}, {m.group(2)})"
return body
# ── 금액 줄 ────────────────────────────────────────────────────
def money(self, ln: dict) -> tuple[str, dict | None] | None:
"""금액 줄 식 → (식 글(단가 자리 = 1 · 변수 = 이름 · 줄참조 = 부름), 자리) · 못 읽음 None."""
raw = ln.get("글") or ""
group: dict[float, str] = {}
sums: dict[float, float] = {}
for k, m in enumerate(_GROUP.finditer(raw)):
calls = self.groups.get(m.group(1)) or []
if not calls:
return None
mark = _SENT + 500 + k
group[mark] = "(" + " + ".join(c for c, _ in calls) + ")"
sums[mark] = sum(v for _, v in calls)
raw = raw.replace(m.group(0), f" {em.num(mark)} ", 1)
text = _clean(raw)
head = text.split("=", 1)[0]
if ":" in head:
text = text.split(":", 1)[1]
parts = text.split("=")
want = ln["결과"][-1] if ln.get("결과") else None
if want is None or len(parts) < 2:
return None
price = (ln.get("연결단가") or [None])[0]
for seg in parts[:-1]:
bare = re.fullmatch(r"\s*(" + est.NUMRE + r")\s*", seg)
if bare and price is not None and _close(est.num(bare.group(1)), want):
return "1", None # 단가 그대로(× 1)
got = self.formula(seg, want, sums)
if not got:
continue
swap: dict[int, str] = {}
used_price = False
for _, v, idx in ex.numbers(got[0]):
if idx in got[1]:
continue
if v in group:
swap[idx] = group[v]
elif price is not None and not used_price and _close(v, price):
swap[idx], used_price = "1", True
elif v in (ln.get("변수") or []) and self.var_of(v):
swap[idx] = self.vars[self.var_of(v)][0]
elif v in (ln.get("줄참조") or []) and (call := self.ref_of(v, ln["행"])):
swap[idx] = call
if price is not None and not used_price:
continue
place = em._place(got[2]) if got[2] != "그대로" else None
return self.text((got[0], got[1], None), swap), place
return None
def frozen(self, ln: dict, why: str) -> tuple[str, dict | None]:
self.mark(ln, f"{why} — 보인 값 {em.num(ln['결과'][-1])} 얼림")
return em.num(ln["결과"][-1]), None
def res(self, ln: dict, part: str | None, price: float | None) -> dict:
r = ln.get("자원") or {}
kind = r.get("종류")
out = {"꼴": "EST", "갈래": GALLAE.get(kind, "고정"), "종류": kind, "코드": r.get("코드")}
out |= {"이름": r.get("이름"), "규격": r.get("규격") or "", "단위": r.get("단위")}
if kind == "산근" and r.get("코드") in self.nos:
out["이름"] = f"단산{self.nos[r['코드']]}" # 같은 파일 안쪽 블록(797-1 형제 SK)
out["원이름"] = r.get("이름")
if part:
nat = NATURAL.get(kind, part)
out |= {"단가": {nat: price}, "비목": nat, "열쇠비목": nat}
out["옮김"] = part if part != nat else None
return out
def quote(self, r: dict) -> tuple[str | None, str | None]:
"""견적 자원(갈래 고정) → 우리 자재 이음 · 표지(`M02_San_Link.quote`) · 그 밖 = (None, None)."""
return em._m01("고정", r) if self.keys and r.get("갈래") == "고정" else (None, None)
def register(self, ln: dict, call: str) -> None:
if ln.get("결과"):
self.done.append((ln["결과"][-1], call, ln))
g = re.search(r"\x06([A-Z])\x06", ln.get("글") or "")
if g:
self.groups.setdefault(g.group(1), []).append((call, (ln.get("결과") or [0.0])[-1]))
def item(self, ln: dict, part: str | None, inside: bool) -> None:
"""자원 줄(노임 · 자재 · 표준 · 견적) → 호표 단가 줄."""
price = (ln.get("연결단가") or [None])[0]
got = self.money(ln) if price is not None else None
qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1])
comp = part or NATURAL.get((ln.get("자원") or {}).get("종류")) or "S"
r = self.res(ln, comp, price if got else ln["결과"][-1])
name = self.o.names.item(ln.get("품명") or r.get("이름") or "줄")
link, many = em._m01(r["갈래"], r) if self.keys else (None, None)
one = em.resource(name, r.get("단위"), qty, r, link)
one |= {} if inside else {"합밖": True}
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
if many:
self.mark(ln, many.removeprefix(NOTE))
self.got["확정"] += bool(got)
self.register(ln, f"줄.'{name}'")
def triple(self, rows: list[dict], total: dict | None, back: int = 0) -> None:
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
got = [self.money(ln) for ln in rows]
parts = [ln["비목"] for ln in rows]
same = all(got) and len({repr(g) for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
if not same:
for ln, g in zip(rows, got):
self.machine_line(ln, g)
if total: # 소계 = 세 줄 + 바로 앞 같은 기계 낱 줄(`back`)
self.subtotal(total, [call for _, call, _ in self.done[-(len(rows) + back) :]])
return
qty, place = got[0]
prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows}
r = self.res(rows[0], None, None)
r |= {"단가": prices}
if quoted := self.quote(r)[1]: # 815-1 K5 — 세 줄 견적 = 값묶음 그대로 · 표지 한 번
self.mark(rows[0], quoted.removeprefix(NOTE))
make = (rows[0].get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조
parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
head = next(
(b for b in reversed(self.lines[: self.lines.index(rows[0])])
if (b.get("자원") or {}).get("코드") == r.get("코드") and not b.get("비목")),
{},
) # fmt: skip
title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄"
name = self.o.names.item(title)
one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty}
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": None, "자원": r}
em.first_spec(one, parts)
if parts: # 814-1 조합(굴착기 + 브레이커 · 집게) = 우리 조합 식 한 줄
one["이음"] = em.combo_link(one, parts) or em.hourly_text(parts[0]["키"])
one |= {"자리": place} if place else {}
value = total["결과"][-1] if total and total.get("결과") else None
for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄)
self.o.glyph(ln["행"], ln.get("보임") or "")
at = total or rows[0]
value = value if value is not None else sum(ln["결과"][-1] for ln in rows)
self.o.add_line(one, True, at["행"], at.get("보임") or "", value)
call = self.parts(one, parts, at, rows[0])
if total:
self.register(total, call)
self.got["확정"] += 3
def parts(self, one: dict, parts: list[dict], at: dict, ln: dict) -> str:
"""조합 기계 다음 줄(값 0 · 제 이음) · 기계 표지(블록에 한 번) → 이 기계 부름 글(줄 합)."""
calls = [f"줄.'{one['이름']}'"]
for p in parts[1:]:
extra = em.part_line(one, p, self.o.names)
self.o.add_line(extra, True, at["행"], at.get("보임") or "", None)
calls.append(f"줄.'{extra['이름']}'")
seen = (one["자원"].get("이름"), one["자원"].get("규격"))
if seen not in self.noted:
self.noted.add(seen)
for m in em.part_marks(one["자원"], parts):
self.mark(ln, m.removeprefix(NOTE))
return calls[0] if len(calls) == 1 else f"({' + '.join(calls)})"
def machine_line(self, ln: dict, got: tuple | None) -> None:
qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1])
price = (ln.get("연결단가") or [ln["결과"][-1]])[0]
r = self.res(ln, ln["비목"], price if got else ln["결과"][-1])
name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}")
make = (ln.get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조
parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
link, many = (parts[0]["키"], None) if parts else self.quote(r)
one = em.resource(name, r.get("단위"), qty, r, link)
em.first_spec(one, parts)
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
if many:
self.mark(ln, many.removeprefix(NOTE))
self.got["확정"] += bool(got)
self.register(ln, self.parts(one, parts, ln, ln))
def subtotal(
self, ln: dict, kids: list[str], part: str | None = None, glyph: bool = True
) -> str:
"""합 밖 소계 덧줄 · glyph 거짓 = 글줄에 안 붙임(같은 줄 식이 붙음 · 61 「합 ÷ N = 결과」)."""
name = self.o.names.line(self.label(ln) or "소계")
one = {"이름": name, "식": " + ".join(kids) or "0", "비목": COMP.get(part or "", "경비")}
one |= {"합밖": True, "출처": SUB}
value = ln["결과"][-1] if ln.get("결과") else (ln.get("저장") or {}).get("T")
if glyph:
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", value)
else:
self.o.adds.append(one)
self.done.append((value or 0.0, name, ln))
return name
def sub61(self, i: int, ln: dict) -> None:
"""소계(61) — 앞 줄 결과 합(단 > 0 머리까지) · 아래 줄 비목 0 이면 그 줄이 소계 비목으로 합에 듦."""
want = (ln.get("줄참조") or [None])[0]
part = ln.get("비목")
start, skip = 0, set()
for k in range(i - 1, -1, -1): # node `native-text` 61 — 머리 · -20 줄까지(그 줄 포함)
b = self.lines[k]
if b.get("색인") == -1:
skip.add(b["행"])
if (b.get("단") or 0) > 0 or (b.get("색인") or 0) > 0 or b.get("색인") == -20:
start = b["행"]
break
kids = [d for d in self.done if start <= d[2]["행"] < ln["행"] and d[2]["행"] not in skip]
ok = want is not None and kids and _close(round(sum(k[0] for k in kids), 1), want)
if ok and ln.get("결과"): # 「인부소계 : ‹합› ÷ N = 결과」 — 합(합 밖) 뒤 식 줄이 합에 듦
self.subtotal({**ln, "결과": [want]}, [k[1] for k in kids], part, glyph=False)
got = self.money(ln)
body, place = got if got else self.frozen(ln, "소계 식 못 읽음")
name = self.o.names.line("소계식")
one = {"이름": name, "식": body, "비목": COMP.get(part or "", "경비")}
one |= ({} if part else {"합밖": True}) | ({"자리": place} if place else {})
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.done.append((ln["결과"][-1], name, ln))
self.got["확정"] += bool(got)
return
if ok and part and all(k[2].get("비목") is None and self._is_line(k[1]) for k in kids):
self.subtotal(ln, [k[1] for k in kids], part)
for _, call, kid in kids: # 아래 줄 = 소계 비목으로 합에 듦
self.joins(call, part)
self.got["확정"] += 1
return
if ok: # 그대로 — 소계 줄이 합에 듦
name = self.o.names.line("소계")
one = {
"이름": name,
"식": " + ".join(k[1] for k in kids),
"비목": COMP.get(part, "경비"),
}
one |= {} if part else {"합밖": True}
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", want)
self.done.append((want, name, ln))
self.got["확정"] += 1
return
value = want if want is not None else 0.0
name = self.o.names.line("소계")
one = {"이름": name, "식": em.num(value), "비목": COMP.get(part, "경비")}
one |= {} if part else {"합밖": True}
self.mark(ln, "소계 아래 줄 못 가림 — 보인 값 얼림")
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", value)
self.done.append((value, name, ln))
@staticmethod
def label(ln: dict) -> str:
"""「인부소계 : …」 → 「인부소계」(= 앞 쌍점만 · 낱말 아니면 빈 글)."""
head = _clean(ln.get("글") or "").split("=", 1)[0]
if ":" not in head:
return ""
got = re.sub(r"[^\w가-힣]", "", head.split(":", 1)[0])[:20]
return "" if not got or got[0].isdigit() else got
def _is_line(self, call: str) -> bool:
name = call[3:-1] if call.startswith("줄.'") else call
return any(one["이름"] == name for one in [*self.o.items, *self.o.adds])
def joins(self, call: str, part: str) -> None:
"""합 밖 호표 · 덧줄(부름 글) → 그 비목으로 합에 듦."""
name = call[3:-1] if call.startswith("줄.'") else call
for one in [*self.o.items, *self.o.adds]:
if one["이름"] != name:
continue
one.pop("합밖", None)
if one.get("종류") in ("인력", "재료", "기계") and "단가" in one:
one["비목"] = COMP[part]
r = one.get("자원") or {}
if r.get("비목"):
r["옮김"] = part if part != r["비목"] else None
elif "비목" in one and not one.get("종류"):
one["비목"] = COMP[part]
def rate(self, ln: dict) -> None:
got = self.money(ln)
body, place = got if got else self.frozen(ln, "율 식 못 읽음")
part = ln.get("비목")
name = self.o.names.line(self.label(ln) or "율")
one = {"이름": name, "식": body, "비목": COMP.get(part or "", "경비")}
one |= {} if part in COMP else {"합밖": True}
one |= {"자리": place} if place else {}
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.got["확정"] += bool(got)
self.register(ln, name)
def plain(self, ln: dict) -> None:
"""연결 없는 결과 줄 — 비목 있으면 덧줄(식) · 없으면 글 + 값(줄참조 대상이면 중간)."""
got = self.money(ln)
part = ln.get("비목")
if part in COMP:
body, place = got if got else self.frozen(ln, "식 못 읽음")
name = self.o.names.line("줄")
one = {"이름": name, "식": body, "비목": COMP[part]}
one |= {"자리": place} if place else {}
self.o.add_line(one, False, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.register(ln, name)
self.got["확정"] += bool(got)
return
eng = self.o.names.line("값")
body = got[0] if got else em.num(ln["결과"][-1])
if got and got[1]:
body = f"{got[1]['방법']}({body}, {got[1]['자리']})"
self.o.middle(eng, body, None, ln["행"], ln.get("보임") or "")
self.register(ln, eng)
def text_line(self, ln: dict) -> None:
"""글 줄 — 품셈 · 조건 이름만 있는 글(값 없음) = 「추정」 표지(연결 안 함 · 810-3 화면)."""
self.memo.append(ln["행"])
if _COND.search(ln["보임"]):
self.o.glyph(
ln["행"], ln["보임"], mark=f"{NOTE}{GUESS} — 품셈 조건 글(값 없음 · 연결 안 함)"
)
self.got["추정"] += 1
return
self.o.glyph(ln["행"], ln["보임"])
def tags(self) -> None:
"""812-3 차림 표지(`M02_Doc_NativeLayout`) — 글줄 `차림` = 머리(단 > 0 ·
소계 구간 첫 글 · 번호 글) · 합계 · 글(근거 · 메모)."""
self.o.head[lay.TAG] = True
for ln in self.lines:
one = self.o.at.get(ln["행"])
if one is None or ln["행"] in self.frac or "자리" in one:
continue
head = (ln.get("단") or 0) > 0 or ln.get("색인") == -20
if ln.get("합계"):
one[lay.TAG] = "합계"
elif head or (ln["행"] in self.memo and lay.NUMBERED.match(one["글"])):
one[lay.TAG] = "머리"
elif ln["행"] in self.memo:
one[lay.TAG] = "글"
# ── 한 벌 ─────────────────────────────────────────────────────
def run(self) -> dict:
for k, ln in enumerate(self.lines): # 「노무비 : ‹단가›」(식 · 결과 없음) = 단가 × 1
text = _clean(ln.get("글") or "")
if ln.get("연결단가") and not ln.get("결과") and "=" not in text and ln.get("비목"):
price = ln["연결단가"][0]
self.lines[k] = {**ln, "결과": [price], "글": f"{ln['글']} = {em.num(price)}"}
lines, i = self.lines, 0
while i < len(lines):
ln = lines[i]
link, part = ln.get("연결"), ln.get("비목")
if ln.get("합계") and link != 62:
if (ln.get("보임") or "").strip():
self.o.glyph(ln["행"], ln["보임"])
i += 1
continue
if link in TRIPLE and part in ("L", "M", "S"):
rows = [ln]
code = (ln.get("자원") or {}).get("코드")
while (
i + len(rows) < len(lines)
and lines[i + len(rows)].get("연결") == link
and lines[i + len(rows)].get("비목") in ("L", "M", "S")
and (lines[i + len(rows)].get("자원") or {}).get("코드") == code
):
rows.append(lines[i + len(rows)])
nxt = lines[i + len(rows)] if i + len(rows) < len(lines) else None
total = nxt if nxt and nxt.get("연결") == link and nxt.get("비목") == "T" else None
back, seen = (
0,
{r.get("비목") for r in rows},
) # 바로 앞 같은 기계 낱 줄(얼린 줄 · 814-1)
while i - back > 0 and len(seen) < 3:
b = lines[i - back - 1]
if (
(b.get("자원") or {}).get("코드") != code
or b.get("비목") in seen
or not b.get("결과")
):
break
seen.add(b.get("비목"))
back += 1
if all(r.get("결과") for r in rows):
self.triple(rows, total, back)
else:
for r in rows:
self.mark(r, "결과 없는 세 줄")
i += len(rows) + bool(total)
continue
if link in (61, 51, 68):
self.sub61(i, ln)
elif link in (58, 62, 52) and ln.get("결과"):
self.rate(ln)
elif ln.get("결과") and (ln.get("자원") or {}).get("코드") and ln.get("연결단가"):
self.item(ln, part if part in COMP else None, part in COMP)
elif ln.get("결과") and link in TRIPLE and part == "T":
self.o.glyph(ln["행"], ln.get("보임") or "", value=ln["결과"][-1])
self.register(ln, em.num(ln["결과"][-1]))
elif not ln.get("자원") and self.assigns(i, ln):
pass
elif ln.get("결과"):
self.plain(ln)
elif (ln.get("보임") or "").strip():
self.text_line(ln)
i += 1
self.tags()
saved = self.blk.get("저장") or {}
check = {COMP[c]: saved.get(c) or 0 for c in COMP} | {"계": saved.get("T") or 0}
out = self.o.done("비목버림", None, check, "산근", self.file)
out["원본"] |= {"원파일": True, "확정": dict(self.got)}
return out
def block(blk: dict, no: int, keys: bool = True, file: str = "", names: dict | None = None) -> dict:
"""EST 산근 블록(`M02_San_Estx.read` 산근 한 개 · 번 = 파일 안 차례 1부터) → {로직, 원본}.
names = 같은 파일 산근 코드 → 번(안쪽 산근 「단산N」)."""
return Block(blk, no, keys, file, names or {}).run()
_POW = re.compile(r"(\([^()]*\)|" + est.NUMRE + r"|[A-Za-z][A-Za-z0-9]*)\s*\^\s*(\d)(?![\d.])")
def _power(seg: str) -> str:
"""「x^2」 · 「(a+b)^2」 → 곱 되풀이(EST 토큰에 ^ 없음 · 정수 지수만)."""
for _ in range(8):
new = _POW.sub(lambda m: "(" + "×".join([m.group(1)] * int(m.group(2))) + ")", seg)
if new == seg:
break
seg = new
return seg