- M02_San_Expr — eval 없는 식 나무 · 셈 · 줄 자리 규칙(버림 · 반올림 · 올림 × 0~3) - M02_San_Stmate — 숨은 G 열 원문 → 줄 나무(계 5 단 · (-=) · 대입 · [A] · ;; · &L/M/S/X/D& · &P/R& 율 · :=A01 · -> · // 덧글) - M02_San_Est — 보이는 글 → 식 복원(단가 · 앞줄 · 상수 역할 · 값 줄 숨은 합 · 변수 얼림 · 단가 정체 · 모호 표지) - M02_San_Eval — STmate 셈(특수 % · 끝전 두 갈래 · 결과금액 조정 · 계약단가) · EST 다시 셈 · 줄마다 검산 · 단가 전파 - M02_San_Book — 같은 책 블록 · 단가표 읽기(읽기만) - test_793_1 — STmate 233/233 · 금액 줄 1,315 · 계 줄 695 · EST 222/222 · 줄 2,901/2,903 · 모호 9 · 전파 견본 7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
7.2 KiB
Python
192 lines
7.2 KiB
Python
"""STmate 산근 글 식 파서(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-B` 3 장 · eval 없음).
|
||
|
||
단가산출근거 시트 숨은 G 열 원문 한 줄 → 줄 나무(dict). 셈은 `M02_San_Eval.run_stmate`.
|
||
|
||
줄 꼴(`종류`):
|
||
- 빈 · 글 — 셈 없음(`'글'` · 단위 조각 · `// 덧글` 뺀 뒤 빈 것)
|
||
- 계 — `(=)`~`(=====)` 단 1~5 · `(-=)` 제외(위 합에서 뺌) · `(=):T01` 누계번지
|
||
- 대입 — `X = 식` 전 자리 · `X = 식 =` 소수 2 자리 반올림 · 한 줄 여러 대입(`,`) ·
|
||
맨 식(끼움 `[A]` 만 먹음)
|
||
- 참조 — `&L..&` · `&M..&` · `&S..&` · `&X..L/M/S(:L/M/S)&` · `&D..&` · `&B..&` 단가 × 계수 식
|
||
(특수 % 코드 L% · M% · T% 는 목록표 UNT 로 셀 때 가림)
|
||
- 율 — `&P{A01|A0102..|T01|C_|S_}{L|M|S|}:{L|M|S|}& × n'%'` 범위 금액 × n % · `&R..&` × n
|
||
- 공통 칸 — 숨김(`;;`) · 번지(`:=A01` · `:=T01`) · 빼기(`->` = 번지엔 쌓되 합에서 뺌) ·
|
||
찍기(끝 `=` 또는 번지) · 식(계수 나무 · `("참조",)` = 단가 자리 1) · 오류(읽기 실패 까닭)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_San_Expr as ex
|
||
|
||
REF = re.compile(r"&([A-Z]+)([^&]*)&")
|
||
_TOK = re.compile(r"\s*(?:(\d+\.?\d*|\.\d+)|([A-Za-z][A-Za-z0-9_]*)|(@R)|(.))")
|
||
_TOTAL = re.compile(r"^\((-?)(=+)\)(?::(T\d+))?")
|
||
_NAME_EQ = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_]*\s*=(?!=)")
|
||
_ASSIGN = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*=(?!=)(.*)$")
|
||
_ADDR = re.compile(r":=\s*([AT]\d+)\s*$")
|
||
_RATE_TAIL = re.compile(r"^(\d*|_)([LMS]?)(?::([LMS]?))?$")
|
||
_CODE_TAIL = re.compile(r"^([LMS]?)(?::([LMS]))?$")
|
||
RATE_KINDS = ("PA", "PT", "PC", "PS", "RA", "RT", "RC", "RS")
|
||
OPS = "+-*/^"
|
||
|
||
|
||
def tokens(text: str) -> list[tuple]:
|
||
"""STmate 식 글 → 토큰(끼움 `[..]` 안은 다시 토큰) · 모르는 기호 = ExprError."""
|
||
out: list[tuple] = []
|
||
at = 0
|
||
while at < len(text):
|
||
if text[at].isspace():
|
||
at += 1
|
||
continue
|
||
if text[at] == "[":
|
||
end = text.find("]", at)
|
||
if end < 0:
|
||
raise ex.ExprError("끼움 괄호 짝 없음")
|
||
inner = text[at + 1 : end]
|
||
if "=" in inner:
|
||
name, body = inner.split("=", 1)
|
||
out.append(("끼움", name.strip(), tokens(body)))
|
||
else:
|
||
out.append(("끼움", None, tokens(inner)))
|
||
at = end + 1
|
||
continue
|
||
m = _TOK.match(text, at)
|
||
if not m or m.end() == at:
|
||
break
|
||
at = m.end()
|
||
num, name, ref, other = m.groups()
|
||
if num:
|
||
out.append(("수", float(num)))
|
||
elif name:
|
||
out.append(("이름", name))
|
||
elif ref:
|
||
out.append(("참조",))
|
||
elif other and other.strip():
|
||
if other in OPS:
|
||
out.append(("연산", other))
|
||
elif other in "(),":
|
||
out.append((other,))
|
||
else:
|
||
raise ex.ExprError(f"모르는 기호 {other!r}")
|
||
return out
|
||
|
||
|
||
def expr(text: str) -> tuple:
|
||
"""식 글 → 나무 · 실패 = `("오류", 까닭)`(셀 때 터짐 — 측정 도구와 같은 차례)."""
|
||
try:
|
||
return ex.parse(tokens(text))
|
||
except ex.ExprError as error:
|
||
return ("오류", str(error))
|
||
|
||
|
||
def split_top(text: str, sep: str = ",") -> list[str]:
|
||
"""괄호 밖 `sep` 로 나눔."""
|
||
out, depth, cur = [], 0, ""
|
||
for ch in text:
|
||
if ch in "([":
|
||
depth += 1
|
||
elif ch in ")]":
|
||
depth -= 1
|
||
if ch == sep and depth == 0:
|
||
out.append(cur)
|
||
cur = ""
|
||
else:
|
||
cur += ch
|
||
out.append(cur)
|
||
return out
|
||
|
||
|
||
def _clean(g: str) -> tuple[str, bool, str | None]:
|
||
"""원문 → (셈 글, 숨김, 덧글) — `//` 뒤 덧글 · `;;` 숨은 줄 · `'글'` 지움."""
|
||
raw, _, note = g.partition("//")
|
||
hidden = raw.lstrip().startswith(";;")
|
||
s = raw.lstrip()[2:] if hidden else raw
|
||
s = re.sub(r"'[^']*'", " ", s).replace("'", " ")
|
||
return s.strip(), hidden, (note.strip() or None) if _ else None
|
||
|
||
|
||
def _ref_line(st: str, m: re.Match) -> dict[str, Any]:
|
||
kind, tail = m.group(1), m.group(2)
|
||
pre = _NAME_EQ.sub(" ", st[: m.start()])
|
||
body = pre + " @R " + st[m.end() :]
|
||
out: dict[str, Any] = {"빼기": "->" in body}
|
||
body = body.replace("->", " ")
|
||
addr = _ADDR.search(body)
|
||
out["번지"] = addr.group(1) if addr else None
|
||
if addr:
|
||
body = body[: addr.start()]
|
||
out["찍기"] = body.rstrip().endswith("=") or bool(addr)
|
||
out["식"] = expr(body.rstrip().rstrip("="))
|
||
if kind in RATE_KINDS:
|
||
mt = _RATE_TAIL.match(tail)
|
||
if not mt:
|
||
return {**out, "종류": "율", "오류": f"율 꼬리 {tail}"}
|
||
num = mt.group(1)
|
||
area = kind[1]
|
||
if area == "A":
|
||
spots = ["A" + num[i : i + 2] for i in range(0, len(num), 2)]
|
||
elif area == "T":
|
||
spots = ["T" + num]
|
||
else:
|
||
spots = []
|
||
rate = {"곱": kind[0] == "R", "범위": area, "번지": spots}
|
||
rate |= {"비목": mt.group(2) or None, "옮김": mt.group(3) or None}
|
||
return {**out, "종류": "율", "율": rate}
|
||
digits = re.match(r"^\d*", tail).group(0)
|
||
mc = _CODE_TAIL.match(tail[len(digits) :])
|
||
if not mc:
|
||
return {**out, "종류": "참조", "오류": f"참조 꼬리 {tail[len(digits) :]}"}
|
||
src = {"코드": kind + digits, "비목": mc.group(1) or None, "옮김": mc.group(2)}
|
||
return {**out, "종류": "참조", "자원": src}
|
||
|
||
|
||
def _assign_line(st: str) -> dict[str, Any]:
|
||
parts: list[dict[str, Any]] = []
|
||
for part in split_top(st):
|
||
p = part.strip()
|
||
if not p:
|
||
continue
|
||
ma = _ASSIGN.match(p)
|
||
if ma:
|
||
body = ma.group(2).strip()
|
||
show = body.endswith("=")
|
||
body = body.rstrip("=").strip()
|
||
if body:
|
||
parts.append({"이름": ma.group(1).lower(), "식": expr(body), "찍기": show})
|
||
else:
|
||
node = expr(p.rstrip("=").strip())
|
||
if node[0] not in ("오류", "이름"): # 맨 낱말 · 못 읽는 글 = 셈 없음
|
||
parts.append({"이름": None, "식": node, "찍기": False})
|
||
if not parts:
|
||
return {"종류": "글"}
|
||
return {"종류": "대입", "대입": parts}
|
||
|
||
|
||
def parse_line(g: str | None) -> dict[str, Any]:
|
||
"""G 열 원문 한 줄 → 줄 나무."""
|
||
st, hidden, note = _clean(g or "")
|
||
head = {"숨김": hidden, "덧글": note}
|
||
tot = _TOTAL.match(st.replace(" ", ""))
|
||
if tot:
|
||
return {**head, "종류": "계", "단": len(tot.group(2)), "제외": tot.group(1) == "-"} | {
|
||
"누계번지": tot.group(3)
|
||
}
|
||
if not st:
|
||
return {**head, "종류": "빈"}
|
||
refs = list(REF.finditer(st))
|
||
if len(refs) > 1:
|
||
return {**head, "종류": "참조", "오류": "참조 둘 이상", "식": ("오류", "참조 둘 이상")}
|
||
if refs:
|
||
return {**head, **_ref_line(st, refs[0])}
|
||
return {**head, **_assign_line(st)}
|
||
|
||
|
||
def parse_block(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""[{행, G}] → 줄 나무 목록(행 · 원문 붙임)."""
|
||
return [
|
||
{"행": ln.get("행"), "글": ln.get("G") or "", **parse_line(ln.get("G"))} for ln in lines
|
||
]
|