Files
Aislo/M02_MasterTemplete/M02_Doc_NativeEst.py
T
eomsangdonandClaude Opus 5.5 624fd3a555 feat(812-3): 가져온 단가산출 좌측 차림 — 경쟁사 머리 줄 = 항목 · 변수 대입 = 공식 묶음 · 근거 글 = 글 줄
- 새 M02_Doc_NativeLayout — 원본 글줄 차림 표지로 금액 줄 묶음(= 항목 제목) + 문서 쌓기(기본과 다를 때만) · EST 금액 차례 = 원본 차례(셈 같을 때만) · 합계 절 뺌 · 줄 쌓기 모델 stackOf · saveOf · hiddenOf 파이썬 거울
- M02_Doc_NativeEst.tags — 글줄 차림 머리(단 > 0 · 소계 구간 첫 글 · 번호 글) · 합계 · 글(분수 그림 위아래 줄 뺌)
- M02_San_Emit.stmate — 글 줄 표지 · 원본 차림 표지(계 줄마다 항목)
- M02_Doc_Import_Shape.shape — 차림 tag · apply 부름
- 줄 쌓기 hiddenOf — 가져온 값 옵션 칸도 공식 묶음에 보임(원파일 꼬리표 · 같은 값) · 옵션 가로 칸 고침 = 수 그대로(글이면 셈 「—」 흠 고침)
- 시험 test_812_3 — 견본 다섯(거창 1 · 2 · 41 · 15 · 봉화 14) · 전수 2,220 블록 금액 같음 · TS 왕복 · 거울 100 % · M01 1,518 차림 그대로
- 낡은 시험 고침 test_810_3 — 옵션 칸 ∩ 공식 묶음 = 빈 → 옵션 칸 ⊆ 공식 묶음(812-3 으로 가져온 값이 공식 묶음에도 보임)

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

593 lines
30 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()
# ── 작은 도구 ──────────────────────────────────────────────────
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 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) -> None:
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
got = [self.money(ln) for ln in rows]
parts = [ln["비목"] for ln in rows]
same = all(got) and len({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:
self.subtotal(total, [call for _, call, _ in self.done[-len(rows) :]])
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}
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), "이음": self.logic_link(r), "자원": r}
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)
if total:
self.register(total, f"줄.'{name}'")
self.got["확정"] += 3
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])
r["갈래"] = "기계" if r["갈래"] == "기계" else r["갈래"]
name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}")
link, _ = em._m01(r["갈래"], r) if self.keys else (None, None)
one = em.resource(name, r.get("단위"), qty, r, link)
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.got["확정"] += bool(got)
self.register(ln, f"줄.'{name}'")
def logic_link(self, r: dict) -> str | None:
"""기계 = 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 그 밖 = 없음(산근 = 형제 SK 참조)."""
if not self.keys or r.get("갈래") != "기계":
return None
key, _ = em._m01("기계", r)
if not key:
return None
from M02_MasterTemplete import M02_Doc_Import as dim
got = dim.hourly_link(key, "노무비")
if not got:
return None
args = "".join(f", {k}={v}" for k, v in got["입력"].items())
return f"로직({got['키']}{args})"
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
if all(r.get("결과") for r in rows):
self.triple(rows, total)
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