- 금액 줄 못 읽음: 결과 뒤 꼬리 글은 떼고 금액 줄로 읽음(2016 틀린 줄 2 → 0) · 못 읽는 글끝 · 식못읽음 = 표지 + 보인 값 보존 - 단가 후보 여럿 · 사람 확인 / 추정(문맥 · 기계 세 줄) 표지 — 임의 선택 대신 표지 - 당초 / 적용 칸 표지(Book.applied_how) · 조정 비율 · 낙찰률 미지원 표지 - FromHopyo.keep 이 읽기 불가 사유(빈 파일 · DRM 암호 · 형식 오류)를 돌려줌 - 사람 확인 표지는 「참고 · 」 머리 — 못 옮김 표지와 가름 · test_793_2 는 못 옮김 표지만 셈(낡은 기대 수정) - test_797_1a 신설: 작은 견본 · 표지 없는 틀린 줄 0 · 표본 밖 수목 142 · 대흥공통 46 · 2016 374 고정 · 버림1 일치율 감시 - 금액 결과 그대로(엔진 · 수식 · Copy 손대지 않음) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
326 lines
13 KiB
Python
326 lines
13 KiB
Python
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
|
||
|
||
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
|
||
|
||
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
|
||
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
|
||
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
|
||
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
|
||
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
|
||
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
|
||
바로 앞 돈 · 값 줄 이음 합)
|
||
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import collections
|
||
import re
|
||
from typing import Any
|
||
|
||
from M02_MasterTemplete import M02_San_Expr as ex
|
||
from M02_MasterTemplete.M02_San_Book import flat
|
||
|
||
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
|
||
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
|
||
EPS = ex.EPS
|
||
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
|
||
OPS |= {"-": "-", "−": "-"}
|
||
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
|
||
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
|
||
RES = re.compile(
|
||
r"^\s*(?:[A-Z]\s+)?("
|
||
+ NUMRE
|
||
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
|
||
)
|
||
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
|
||
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
|
||
TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」
|
||
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
|
||
|
||
|
||
def num(s: str) -> float:
|
||
return float(s.replace(",", "").rstrip(".") or 0)
|
||
|
||
|
||
def tokens(seg: str) -> list[tuple] | None:
|
||
"""식 조각 → Expr 토큰 · 숫자 식이 아니면(기호 · 수 둘 잇달아 · 연산 없음) None."""
|
||
s = seg.replace("(", "(").replace(")", ")").replace("x", "x")
|
||
prev = None
|
||
while prev != s: # 수 없는 괄호 글 「(품할증)」
|
||
prev = s
|
||
s = re.sub(r"\([^()\d]*\)", " ", s)
|
||
out: list[tuple] = []
|
||
i, want, depth = 0, True, 0
|
||
while i < len(s):
|
||
ch = s[i]
|
||
if ch.isspace():
|
||
i += 1
|
||
continue
|
||
m = re.match(NUMRE, s[i:])
|
||
if m and (i == 0 or not (s[i - 1].isalpha() and s[i - 1].isascii())):
|
||
if not want:
|
||
return None
|
||
out.append(("수", num(m.group(0))))
|
||
i += len(m.group(0))
|
||
want = False
|
||
while i < len(s): # 단위 글 「인」 「㎡」 「본/일」
|
||
m2 = _UNIT.match(s[i:])
|
||
if m2 and m2.group(0).strip() and not re.fullmatch(r"\s*[xX]", m2.group(0)):
|
||
i += len(m2.group(0))
|
||
continue
|
||
break
|
||
continue
|
||
if ch == "%":
|
||
if want:
|
||
return None
|
||
out.append(("%",))
|
||
i += 1
|
||
continue
|
||
if ch in OPS:
|
||
if ch in "xX" and want:
|
||
return None
|
||
if ch == "/" and not re.match(r"\s*[\d(.]", s[i + 1 :]):
|
||
return None
|
||
if ch in "--−" and want:
|
||
out.append(("연산", "-"))
|
||
i += 1
|
||
continue
|
||
if want:
|
||
return None
|
||
out.append(("연산", OPS[ch]))
|
||
i += 1
|
||
want = True
|
||
continue
|
||
if ch == "(":
|
||
if not want:
|
||
return None
|
||
depth += 1
|
||
out.append(("(",))
|
||
i += 1
|
||
continue
|
||
if ch == ")":
|
||
depth -= 1
|
||
if depth < 0 or want:
|
||
return None
|
||
out.append((")",))
|
||
i += 1
|
||
continue
|
||
if ch.isalpha() or ch == "_":
|
||
return None
|
||
i += 1 # 장식(₩ 등)
|
||
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
|
||
return None
|
||
return out
|
||
|
||
|
||
def calc(node: tuple, sub: dict | None = None) -> float | None:
|
||
try:
|
||
return ex.value(node, {"V": {}, "sub": sub})
|
||
except ex.ExprError:
|
||
return None
|
||
|
||
|
||
def label_comp(label: str) -> str | None:
|
||
w = flat(label)
|
||
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
|
||
|
||
|
||
def parse_line(text: str) -> dict[str, Any]:
|
||
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
|
||
t = text.strip()
|
||
if not t:
|
||
return {"꼴": "빈"}
|
||
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
|
||
return {"꼴": "그림" if "─" in t else "글"}
|
||
label, rhs = "", t
|
||
mc = _LABEL.match(t)
|
||
if mc and not re.search(r"\d\s*$", mc.group("l")):
|
||
label, rhs = mc.group("l"), mc.group("r")
|
||
if VARLINE.match(rhs) or VARLINE.match(t):
|
||
return {"꼴": "변수", "조각": rhs.split("=")}
|
||
if "=" not in rhs:
|
||
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
|
||
if label and m:
|
||
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
|
||
return {"꼴": "글"}
|
||
segs = rhs.split("=")
|
||
mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a)
|
||
if not mres:
|
||
lead = re.match(r"\s*(" + NUMRE + r")", segs[-1])
|
||
if (
|
||
label and lead and re.search(r"\d", "=".join(segs[:-1]))
|
||
): # 금액 줄 못 읽음 — 보인 값 보존
|
||
return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))}
|
||
return {"꼴": "글끝"}
|
||
shown = num(mres.group(1))
|
||
toks = None
|
||
for seg in reversed(segs[:-1]):
|
||
toks = tokens(seg)
|
||
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
|
||
starts = {m.start() for m in re.finditer(NUMRE, seg)}
|
||
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
|
||
for p in sorted(starts):
|
||
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
|
||
continue
|
||
toks = tokens(seg[p:])
|
||
if toks:
|
||
label = label or seg[:p]
|
||
break
|
||
if toks:
|
||
break
|
||
if not toks:
|
||
return {"꼴": "식못읽음", "라벨": label, "값": shown}
|
||
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
|
||
|
||
|
||
# ── 블록 풀이 ──────────────────────────────────────────────────────────
|
||
def _ident(p: dict[str, Any]) -> tuple:
|
||
return (p["종류"], p["이름"], p["규격"])
|
||
|
||
|
||
def _money(L: dict, prices_by_value: dict, results: list) -> None:
|
||
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
|
||
comp = L["비목"]
|
||
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
|
||
roles: dict[int, dict[str, Any]] = {}
|
||
price_at = None
|
||
for i, v in nums:
|
||
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
|
||
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
|
||
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
|
||
if comp:
|
||
cands = [p for p in cands if p["비목"] == comp] or cands
|
||
if v == 0 and comp and not cands:
|
||
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
|
||
first = i == nums[0][0]
|
||
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
|
||
price_at = i
|
||
roles[i] = {"갈래": "단가", "후보": cands}
|
||
break
|
||
for i, v in nums:
|
||
if i in roles:
|
||
continue
|
||
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
|
||
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
|
||
L["역할"] = roles
|
||
L["셈"] = calc(L["식"])
|
||
L["규칙"] = ex.fit(L["셈"], L["값"])
|
||
if price_at is not None:
|
||
L["종류"] = "단가줄"
|
||
pc = roles[price_at]["후보"]
|
||
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
|
||
L["후보수"] = len({_ident(p) for p in pc})
|
||
elif any(r["갈래"] == "앞줄" for r in roles.values()):
|
||
L["종류"] = "참조줄"
|
||
else:
|
||
L["종류"] = "상수줄"
|
||
|
||
|
||
def _value(L: dict, lines: list, results: list) -> None:
|
||
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
|
||
v, how = L["값"], None
|
||
for r in reversed(results):
|
||
rule = ex.fit(r[1], v)
|
||
if rule:
|
||
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
|
||
break
|
||
if not how and flat(L["라벨"]) in ("계", "합계"):
|
||
got: dict[str, dict] = {}
|
||
for x in reversed(lines):
|
||
c = x.get("비목")
|
||
if c and x.get("꼴") in ("돈", "값") and c not in got:
|
||
got[c] = x
|
||
if len(got) == 3:
|
||
break
|
||
if got:
|
||
rule = ex.fit(sum(x["값"] for x in got.values()), v)
|
||
if rule:
|
||
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
|
||
if not how:
|
||
acc, used = 0.0, []
|
||
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
|
||
if len(used) >= 25:
|
||
break
|
||
acc += x["값"]
|
||
used.append(x["차례"])
|
||
rule = ex.fit(acc, v)
|
||
if rule and len(used) > 1:
|
||
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
|
||
break
|
||
L["종류"] = "값줄"
|
||
L["숨은"] = how
|
||
L["규칙"] = how["규칙"] if how else None
|
||
|
||
|
||
def _var(L: dict) -> None:
|
||
segs = L.pop("조각")
|
||
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
|
||
if not m:
|
||
L["종류"] = "변수글"
|
||
return
|
||
shown, toks = num(m.group(1)), None
|
||
for seg in reversed(segs[1:-1]):
|
||
toks = tokens(seg)
|
||
if toks:
|
||
break
|
||
L["값"] = shown
|
||
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
|
||
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
|
||
|
||
|
||
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
|
||
by_value = collections.defaultdict(list)
|
||
for p in prices:
|
||
by_value[p["값"]].append(p)
|
||
lines: list[dict[str, Any]] = []
|
||
results: list[tuple] = [] # (차례, 값, 비목)
|
||
for k, raw in enumerate(text):
|
||
L = {"차례": k, "원글": raw, **parse_line(raw)}
|
||
if L["꼴"] == "돈":
|
||
_money(L, by_value, results)
|
||
results.append((k, L["값"], L["비목"]))
|
||
elif L["꼴"] == "값":
|
||
_value(L, lines, results)
|
||
results.append((k, L["값"], L["비목"]))
|
||
elif L["꼴"] == "변수":
|
||
_var(L)
|
||
lines.append(L)
|
||
identify(lines)
|
||
return lines
|
||
|
||
|
||
def identify(lines: list[dict[str, Any]]) -> None:
|
||
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
|
||
head = ""
|
||
for L in lines:
|
||
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
|
||
head = L["원글"]
|
||
if L.get("종류") != "단가줄":
|
||
continue
|
||
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
|
||
cands = role["후보"]
|
||
ids = {_ident(p) for p in cands}
|
||
how = "값"
|
||
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
|
||
k0, inter = L["차례"], set(ids)
|
||
for M in lines[max(0, k0 - 3) : k0 + 4]:
|
||
if M is L or M.get("종류") != "단가줄":
|
||
continue
|
||
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
|
||
ids2 = {_ident(p) for p in r2["후보"]}
|
||
if ids2 & inter and len(ids2 & inter) < len(inter):
|
||
inter &= ids2
|
||
ids, how = inter, "기계 세 줄"
|
||
if len(ids) > 1:
|
||
ctx = flat(L["원글"]) + "|" + flat(head)
|
||
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
|
||
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
|
||
role["모호"] = len(ids) != 1
|
||
role["갈림"] = how
|
||
role["남은"] = len(ids)
|
||
pick = sorted(ids)[0]
|
||
p = next(p for p in cands if _ident(p) == pick)
|
||
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
|