Files
Aislo/M02_MasterTemplete/M02_Doc_NativeEst.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

599 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()
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 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({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:
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}
parts = em.machine_parts(r) 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}
link = em.hourly_text(parts[0]["키"]) if parts else None
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": link, "자원": 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)
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['비목']]}")
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
one = em.resource(name, r.get("단위"), qty, r, parts[0]["키"] if parts else None)
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
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
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