"""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"(? 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