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

668 lines
31 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 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독).
엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC ·
0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출
총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움):
변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가
앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) ·
합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) ·
「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래
「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 ·
노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) ·
「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) ·
「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_San_Xlsx as sx
SHEET = "단가산출"
LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58}
MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"}
MEAN[62] = "율"
LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"}
NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1
_NUM = re.compile(r"(?<![A-Za-z0-9_.,])(-?(?:" + NUM + r"))\.?(?![\d,])")
_HEAD = re.compile(r"^(\d+)\.(.*?)\[([^\[\]]*)\]\s*$")
_HEADING = re.compile(r"^\s*(?:\(\d+\)|(?:\d+|[가-하])\s*[.)])\s*")
_REF = re.compile(r"\*\s*(단산|견적)\s*(\d+)\s*참조\s*\*")
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
_GROUP62 = re.compile(r"^\s*\(?\s*([A-Z])\s*(" + NUM + r")((?:\s*\+\s*(?:" + NUM + r"))*)")
_UNITS = {"hr", "m", "km", "kg", "ton", "sec", "min", "mm", "cm", "ea", "t"}
_COLON = re.compile(r"[::]")
def _v(s: str) -> float:
return float(s.replace(",", "").rstrip("."))
def _close(a: float, b: float) -> bool:
return abs(a - b) < 1e-6 * max(1.0, abs(b))
def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None:
"""빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None."""
chars = [re.escape(c) for c in sx.flat(name)]
if not chars:
return None
m = re.compile(r"\s*".join(chars)).search(text, start)
return (m.start(), m.end()) if m else None
# ── 시트 → 블록 ───────────────────────────────────────────────────────
def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]:
"""단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」)."""
cols = sx.money_cols(rows) or [2, 3, 4, 5]
split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만)
out: list[dict[str, Any]] = []
for i, r in enumerate(rows):
head = r[1] if len(r) > 1 else None
m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None
if m and i > 2:
out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []})
out[-1]["저장"] = sx.money_of(r, cols)
continue
if not out:
continue
if split:
cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)]
text = sx.layout([(j, v) for j, v in cells if v.strip()], 2)
else:
text = sx._cell(head)
one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split}
out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)})
for b in out: # 끝 빈 줄(시트 꼬리)
while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]:
b["줄"].pop()
return out
# ── 줄 ────────────────────────────────────────────────────────────────
def _label(text: str) -> str | None:
"""「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None."""
m = _COLON.search(text)
if not m:
return None
return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()])))
def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]:
hi = len(text) if hi is None else hi
return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)]
def _result(text: str) -> tuple[int, int, float, int | None] | None:
"""마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리)."""
eq = text.rfind("=")
if eq < 0:
return None
m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :])
if not m:
return None
at = eq + 1
return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None
class Lines:
"""블록 한 벌 줄 → 구조 줄."""
def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None:
self.res, self.tags = res, tags
self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값
self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원}
self.triple = False # 바로 앞이 세 줄
self.heads: list[dict] = [] # 세 줄 머리 줄들
self.lines: list[dict[str, Any]] = []
# ── 꼴 ─────────────────────────────────────────────────────────
def blank(self, src: dict) -> dict[str, Any]:
return {
"행": src["행"],
"엑셀행": src["엑셀행"],
"글": src["보임"],
"보임": src["보임"],
"품명": None,
"규격": None,
"단위": None,
"단": 0,
"색인": 0,
"연결": 0,
"연결뜻": None,
"비목": None,
"자원": None,
"연결단가": [],
"변수": [],
"줄참조": [],
"결과": [],
"합계": False,
"자리": {"자리수": None, "방법": None},
"표지": None,
"기계": None,
"산근": None,
"저장": src.get("돈"),
"추정": False,
"_marks": [],
"_칸": src.get("칸"),
}
def resource(self, kind: str, one: dict) -> dict[str, Any]:
code = (
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
) # 원 코드 없음 = 표 줄 번
out = {"코드": code, "종류": kind, "표": one["표"]}
out |= {
k: one[k]
for k in ("이름", "규격", "단위") + (("같은구성",) if "같은구성" in one else ())
}
return out | {"저장": dict(one["값"])}
def link(self, ln: dict, kind: str, one: dict | None) -> None:
ln["연결"] = LINK[kind] if kind in LINK else 62
ln["연결뜻"] = MEAN.get(ln["연결"])
ln["추정"] = True
if one is not None:
ln["자원"] = self.resource(kind, one)
if kind == "산근":
ln["산근"] = ln["자원"]["코드"]
@staticmethod
def money_part(ln: dict) -> str | None:
"""금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None."""
got = [c for c in "LMS" if (ln["저장"] or {}).get(c)]
return got[0] if len(got) == 1 else ("T" if got else None)
@staticmethod
def closed(text: str, end: int) -> bool:
"""칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝)."""
gap = len(text[end:]) - len(text[end:].lstrip(" ."))
return gap >= 2 or not text[end:].strip()
def wrap(self, ln: dict, s: int, e: int, ch: str) -> None:
ln["_marks"].append((s, e, ch))
# ── 자원 찾기 ─────────────────────────────────────────────────
def names(self, ln: dict, one: dict, start: int = 0) -> int | None:
"""이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리."""
text = ln["보임"]
sp = _span(text, one["이름"], start)
if not sp:
return None
self.wrap(ln, *sp, "\x0e")
ln["품명"] = text[sp[0] : sp[1]]
end = sp[1]
if one.get("규격"):
m = re.match(r"\s*", text[end:])
at = end + m.end()
size = _span(text, one["규격"], at)
if size and size[0] - at <= 1:
lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0)
hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0)
self.wrap(ln, lo, hi, "\x13")
ln["규격"] = text[lo:hi]
end = hi
unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:])
if unit:
self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15")
ln["단위"] = unit.group(1)
end += unit.end(1)
return end
def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None:
"""노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저)."""
text, body = ln["보임"], sx.flat(ln["보임"])
best = None
for hit in _nums(text):
for kind, one in self.res["_값"].get(round(hit[2], 4), ()):
name = sx.flat(one["이름"])
if not name or name not in body:
continue
sp = _span(text, one["이름"])
if not sp or sp[1] > hit[0]:
continue
key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name))
if best is None or key > best[0]:
best = (key, kind, one, hit)
return best[1:] if best else None
def find_head(self, ln: dict) -> tuple[str, dict] | None:
"""세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격)."""
text = ln["보임"]
m = _REF.search(text)
if m:
kind = "산근" if m.group(1) == "단산" else "견적"
one = self.res["_번"].get((kind, int(m.group(2))))
if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)):
body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것)
hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body]
one = (
max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"])))
if hits
else None
)
return (kind, one) if one else None
if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"):
return None
body, best = sx.flat(text), None
for at in self.res["_기계틀"].finditer(body):
name = at.group(1)
for one in self.res["_기계"][name]:
size = sx.flat(one["규격"])
tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2]
if size and size not in tail:
continue
key = len(name) + len(size)
if best is None or key > best[0]:
best = (key, one)
return ("기계", best[1]) if best else None
# ── 줄 갈래 ───────────────────────────────────────────────────
def run(self, src: dict) -> dict[str, Any]:
ln = self.blank(src)
text = ln["보임"]
if not text.strip() and not ln["저장"]:
ln["연결"] = None
return self.done(ln)
heading = bool(_HEADING.match(text))
part = _label(text)
if heading:
ln["단"] = 1
title = sx.flat(_HEADING.sub("", text))
if re.search(r"[합소총]계|^계", title):
self.heads = [] # 합계 절 — 세 줄 머리 닫음
self.triple = False
if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx:
return self.done(self.triple_total(ln))
self.triple = False
if part in ("L", "M", "S") and not heading and self.triple_line(ln, part):
return self.done(ln)
got = self.find_item(ln)
total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸
if part and not heading and got is None and total:
return self.done(self.total_line(ln, part))
if got is not None:
return self.done(self.item(ln, *got))
if (head := self.find_head(ln)) is not None:
kind, one = head
self.link(ln, kind, one)
end = self.names(ln, one)
loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음
loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":")
if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
ln |= {"규격": None, "단위": None, "_marks": []}
self.plain(ln)
return self.done(ln)
self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False})
self.ctx = self.heads[-1]
return self.done(ln)
if self.subtotal(ln) or self.rate62(ln) or self.rate(ln):
return self.done(ln)
self.plain(ln)
return self.done(ln)
def sections(self, saved: dict) -> None:
"""절 소계(가 · 나 … 머리 아래 합계 줄 「노무비 : a + b = X」)가 뺀 줄 하나 = 합에 안 든 줄(비목 없음) —
절(앞 숫자 머리부터) 안 그 비목 줄 결과 합 − X 가 꼭 한 줄 값이고 나머지 줄 값은 모두 덧셈 글에 있을
때만 · 블록 금액도 그 줄을 빼야 맞을 때만(엑셀 글 덧셈 · 원파일 SCheck 0)."""
lines, start = self.lines, 0
for i, ln in enumerate(lines):
text = ln["보임"]
if ln["단"] == 1 and re.match(r"^\s*\d", text):
start = i
if not (ln["합계"] and ln["비목"] in ("L", "M", "S") and ln["결과"] and "+" in text):
continue
head = next((k for k in range(i - 1, start - 1, -1) if lines[k]["단"] == 1), None)
if head is None or not re.match(r"^\s*[가-하]", lines[head]["보임"]):
continue
part, want = ln["비목"], ln["결과"][-1]
kids = [
x for x in lines[start:head] if x["비목"] == part and x["결과"] and not x["합계"]
]
kids += [
x for x in lines[start:head] if x["비목"] == part and x["줄참조"] and not x["결과"]
]
value = {id(x): (x["결과"] or x["줄참조"])[-1] for x in kids}
extra = sum(value.values()) - want
odd = [x for x in kids if abs(value[id(x)] - extra) < 0.5]
colon = _COLON.search(text)
adds = [v for _, _, v in _nums(text, colon.end() if colon else 0, text.rfind("="))]
rest = [value[id(x)] for x in kids if odd and x is not odd[0]]
listed = all(any(_close(v, a) for a in adds) for v in rest)
if len(odd) != 1 or abs(extra) < 0.5 or not listed:
continue
every = sum(
(x["결과"] or x["줄참조"])[-1]
for x in lines
if x["비목"] == part and not x["합계"] and (x["결과"] or x["줄참조"])
)
block = float(saved.get(part) or 0) # 블록 금액이 그 줄을 빼야 맞을 때만
if abs(every - block) >= 1 and abs(every - extra - block) < 1:
odd[0]["비목"] = None
def unused(self) -> None:
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
for h in self.heads:
ln = h["줄"]
if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0:
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
ln |= {"규격": None, "단위": None, "_marks": []}
def done(self, ln: dict) -> dict[str, Any]:
self.lines.append(ln)
return ln
def total_line(self, ln: dict, part: str) -> dict:
"""합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과)."""
text = ln["보임"]
ln |= {"합계": True, "비목": part, "단": -1}
lead = len(text) - len(text.lstrip())
res = _result(text)
if res:
self.wrap(ln, lead, text.rfind("=") + 1, "\x1a")
self.result(ln, res)
elif part == "T":
colon = _COLON.search(text).end()
self.wrap(ln, lead, colon, "\x1a")
nums = _nums(text, colon)
if nums:
self.wrap(ln, nums[0][0], nums[0][1], "\x12")
ln["결과"] = [nums[0][2]]
else:
self.wrap(ln, lead, len(text.rstrip()), "\x1a")
return ln
def result(self, ln: dict, res: tuple) -> None:
s, e, v, letter = res
if letter is not None:
self.wrap(ln, letter, letter + 1, "\x06")
self.wrap(ln, s, e, "\x12")
ln["결과"].append(v)
def triple_line(self, ln: dict, part: str) -> bool:
"""세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값."""
text = ln["보임"]
colon = _COLON.search(text).end()
nums = _nums(text, colon)
if not nums or not self.heads:
return False
price = nums[0]
tail = text[price[1] :]
if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+":
return False
fit = [
h
for h in reversed(self.heads)
if _close(price[2], float(h["자원"]["값"].get(part) or 0))
]
if not fit:
return False
self.ctx = fit[0]
self.ctx["씀"] = True
one = self.ctx["자원"]
self.link(ln, self.ctx["종류"], one)
ln["비목"] = part
if self.ctx["종류"] == "기계":
ln["기계"] = True
self.money(ln, price)
self.triple = True
return True
def triple_total(self, ln: dict) -> dict:
one = self.ctx["자원"]
self.link(ln, self.ctx["종류"], one)
ln["비목"] = "T"
text = ln["보임"]
res = _result(text)
if res:
self.result(ln, res)
else:
m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text)
if m:
self.result(
ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None)
)
money = ln["저장"] or {}
rows = []
for one in reversed(self.lines):
if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"):
break
rows.append(one)
cols = [c for c in "LMS" if money.get(c)]
total = sum((one["결과"] or [0])[-1] for one in rows)
if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1:
for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김
if (one["결과"] or [0])[-1]:
one["비목"] = cols[0]
elif money:
for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음)
if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]:
one["비목"] = None
self.triple = False
return ln
def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict:
self.link(ln, kind, one)
self.names(ln, one)
part = self.money_part(ln)
ln["비목"] = part if part in ("L", "M", "S") else None
self.money(ln, price)
return ln
def money(self, ln: dict, price: tuple[int, int, float]) -> None:
"""금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과."""
text = ln["보임"]
s, e, v = price
self.wrap(ln, s, e, "\x03")
ln["연결단가"] = [v]
self.var(ln, e)
res = _result(text)
if res and res[0] > e:
self.result(ln, res)
def var(self, ln: dict, start: int) -> None:
"""첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) ·
칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만."""
text = ln["보임"]
eq = text.find("=", start)
order = list(self.vars.values())
best = None
for a, b, val in _nums(text, start, eq if eq >= 0 else None):
rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1)
if ln["_칸"] and not self.closed(text, b):
continue
if rank >= 0 and (best is None or rank > best[0]):
best = (rank, a, b, val)
if best:
self.wrap(ln, best[1], best[2], "")
ln["변수"].append(best[3])
def subtotal(self, ln: dict) -> bool:
"""「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음."""
text = ln["보임"]
m = _COLON.search(text)
if not m or "소계" not in sx.flat(text[: m.start()]):
return False
money = ln["저장"] or {}
parts = [c for c in "LMS" if money.get(c)]
if len(parts) != 1 or (
money.get("T") is not None and not _close(money["T"], money[parts[0]])
):
return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값)
got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :])
if not got:
return False
at = m.end()
if got.group(1):
self.wrap(ln, at + got.start(1), at + got.end(1), "\x06")
self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c")
ln["줄참조"] = [_v(got.group(2))]
ln["저장"] = {**money, "T": money[parts[0]]}
self.link(ln, "소계", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "소계"
self.var(ln, at + got.end(2))
res = _result(text)
if res and res[0] > at + got.end(2):
self.result(ln, res)
return True
def rate62(self, ln: dict) -> bool:
"""「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지)."""
text = ln["보임"]
m = _GROUP62.search(text)
if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]:
return False
self.wrap(ln, m.start(1), m.end(1), "\x07")
self.wrap(ln, m.start(2), m.end(3), "\x1a")
ln |= {"합계": True}
self.link(ln, "율62", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "율"
for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text):
self.result(
ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None)
)
return True
def rate(self, ln: dict) -> bool:
"""「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조)."""
text = ln["보임"]
res = _result(text)
if not ln["저장"] or not res:
return False
colon = _COLON.search(text)
nums = _nums(text, colon.end() if colon else 0, text.rfind("="))
seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]]
before = any(_close(nums[0][2], v) for v in seen) if nums else False
lead = text[colon.end() if colon else 0 :].lstrip(" (")
before = before and lead[:1].isdigit()
if not nums or ("%" not in text[: text.rfind("=")] and not before):
return False
self.wrap(ln, nums[0][0], nums[0][1], "\x0c")
ln["줄참조"] = [nums[0][2]]
self.link(ln, "율", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "율"
self.result(ln, res)
return True
def plain(self, ln: dict) -> None:
"""글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과."""
text = ln["보임"]
ln["연결"] = 0
part = self.money_part(ln)
ln["비목"] = part
hits: list[re.Match] = []
for m in _ASSIGN.finditer(text):
if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]):
continue # 「8 hr =」 = 단위
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 hits and (not lead or re.search(r"[::]$", lead)):
for n, m in enumerate(hits):
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
seg = text[m.end() : end]
parts = seg.split("=")
val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1])
if not val:
continue
at = m.end() + len(seg) - len(parts[-1])
if len(parts) >= 2:
res = _result(text[:end])
if res and res[0] >= at:
self.result(ln, res)
self.vars.pop(m.group(1), None)
self.vars[m.group(1)] = _v(val.group(1))
return
eq = text.rfind("=")
seg = re.split(r"[=::]", text[:eq])[-1]
if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg):
res = _result(text)
if res:
self.result(ln, res)
# ── 책 ────────────────────────────────────────────────────────────────
def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str:
"""보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저)."""
ins: list[tuple[int, int, str]] = []
for s, e, ch in marks:
ins.append((s, 1, ch))
ins.append((e, 0, ch))
out, last = [], 0
for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])):
out.append(text[last:at])
out.append(ch)
last = at
out.append(text[last:])
return "".join(out)
def index(res: dict[str, list[dict]]) -> dict[str, Any]:
"""자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적)."""
out: dict[str, Any] = dict(res)
out["_값"] = {}
for kind in ("노임", "자재"):
for one in res.get(kind) or []:
out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one))
out["_기계"] = {}
for one in res.get("기계") or []:
if sx.flat(one["이름"]):
out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one)
names = sorted(out["_기계"], key=len, reverse=True)
out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None
out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []}
return out
def read(sheets: dict[str, list]) -> dict[str, Any]:
"""EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길)."""
res = sx.resources(sheets)
look = index(res)
total = {one["번"]: one for one in res.get("산근") or []}
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
for b in blocks(sheets.get(SHEET) or []):
tags: list[str] = []
one = total.get(b["번"])
m = _HEAD.match(b["머리"])
head = {
"코드": f"단산{b['번']}",
"이름": one["이름"] if one else m.group(2).strip(),
"규격": one["규격"] if one else "",
"단위": one["단위"] if one else m.group(3).strip(),
"저장": b["저장"] or (dict(one["값"]) if one else None),
}
if not one:
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
elif one.get("칸"): # 총괄표 당초 / 적용 칸(797-1a)
tags.append(f"참고 · {one['칸']}")
work = Lines(look, tags)
for src in b["줄"]:
work.run(src)
work.unused()
work.sections(head["저장"] or {})
lines = []
for ln in work.lines:
marks = ln.pop("_marks")
ln.pop("_칸", None)
ln["글"] = _glyph(ln["보임"], marks)
lines.append(ln)
out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags})
out["자원"] = {
f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")}
| {"저장": one["값"]}
for kind, rows in res.items()
for one in rows
}
out["표지"] = [
f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})",
f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)",
]
return out