"""M02 일위대가 엑셀 붙여넣기(PLAN 705-2 · `_분석_내역5-A` ⑤-2) — 붙인 글(탭 · 줄 나뉨)을 서버가 읽어 실무자료 꼴(`M02_Hopyo_Excel.read_book` 과 같은 `{꼴, 시트, 호표, 단산, 검산}`)로 · 브라우저 숫자 안 받음. 열 짝 `열` = {품명, 규격, 수량, 단위, 금액, 노, 재, 경} → 칸 차례(0부터) — 노 · 재 · 경 = 단가 칸(다음 칸 = 금액) · 금액 = 합계 금액 칸(호표 1 단위 값 · 금액만 줄) · 합계 단가 = 금액 앞 칸 · 비고 = 경 + 2. 머리 글 줄(「품명|명칭」 · 「수량」 · 「단위」)이 붙어 오면 그 줄로(물음 없음) · 없으면 받은 `열` · 둘 다 없으면 짐작(엑셀꼴 · STmate 꼴 차례) + `물음`(화면이 한 번 묻고 기억). 호표 = 「제N호표」 칸이 있는 줄 — 같은 줄 품명 = 머리(엑셀꼴) · 품명 칸이 그 글이면 다음 줄이 머리(STmate 꼴) · 「제N호표」 없는 글 = 이름 없는 호표 하나(번 0 · 줄만). 거름 = 머리 글 줄 · 「합계 · 계 · 소계 · 계약단가」 줄(STmate 합계 줄 금액 = 호표 1 단위 값) · 근거 줄(단위 · 단가 · 금액 빔 — 「건설표준품셈 | 6-7-2 | 0」) · 빈 줄. 수 = 쉼표 · 빈칸 · 「-」(`M02_Hopyo_Excel._num`) · 갈래 = `M02_Hopyo_Excel.kind_of`(비고 「단산N참조」 · 「일위N참조」) · 단위 섞임(㎥ · M3)은 이음 쪽 `factor` 가 맞춤 · % 줄 = 단가 계 = 밑수(705-3 `M02_Hopyo_Fixed._ratio`). """ from __future__ import annotations import re from collections import Counter from datetime import datetime from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Excel as hx from M02_MasterTemplete import M02_Hopyo_Fixed as hfix from M02_MasterTemplete import M02_Hopyo_Store as hstore from M02_MasterTemplete import M02_MasterTemplete_Store as store FIELDS = ("품명", "규격", "수량", "단위", "금액", "노", "재", "경") TEXT_MAX = 400_000 ROWS_MAX = 5000 SHEET = "붙여넣기" _SKIP = ("합계", "계", "소계", "계약단가") _HEAD_NAMES = {"품명": ("품명", "명칭"), "규격": ("규격",), "수량": ("수량",), "단위": ("단위",)} _HEAD_COST = {"노": "노무비", "재": "재료비", "경": "경비"} class PasteError(ValueError): """붙인 글이 비거나 꼴이 틀림 — 길이 400.""" def cells(text: str) -> list[list[str]]: """붙인 글 → 줄마다 칸(탭) — 엑셀 따옴표 칸(줄바꿈 든 칸)은 따옴표만 벗김 · 끝 빈 줄 뺌.""" if not isinstance(text, str) or not text.strip(): raise PasteError("붙인 글이 빔") if len(text) > TEXT_MAX: raise PasteError("붙인 글이 너무 김") rows = [ [c.strip().strip('"').strip() for c in line.split("\t")] for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n") ] while rows and not any(rows[-1]): rows.pop() if len(rows) > ROWS_MAX: raise PasteError(f"줄은 {ROWS_MAX} 개 안") return rows def _flat(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")) def _header(rows: list[list[str]]) -> tuple[int, dict[str, int]] | None: """머리 글 줄 → (줄 차례, 열 짝) — 품명 · 수량 · 단위 다 있는 줄 · 비목 = 그 줄(또는 다음 줄) 글.""" for at, row in enumerate(rows[:20]): flat = [_flat(v) for v in row] cols = {k: next((i for i, v in enumerate(flat) if v in names), -1) for k, names in _HEAD_NAMES.items()} # fmt: skip if min(cols["품명"], cols["수량"], cols["단위"]) < 0: continue for k, word in _HEAD_COST.items(): cols[k] = next((i for i, v in enumerate(flat) if v.startswith(word)), -1) total = next((i for i, v in enumerate(flat) if v == "합계"), -1) cols["금액"] = total + 1 if total >= 0 else -1 return at, {k: v for k, v in cols.items() if v >= 0} return None def guess(rows: list[list[str]]) -> dict[str, int]: """머리 없는 글 열 짐작 — 「제N호표」 칸이 따로면(엑셀꼴 B 열부터) 품명 = 그 다음 칸 · 아니면 첫 칸 · 차례 = 품명 · 규격 · 수량 · 단위 · 합계 단가 / 금액 · 노 · 재 · 경(단가 / 금액 쌍).""" marks = Counter(i for row in rows for i, v in enumerate(row) if hx._HOPYO.fullmatch(_flat(v))) first = marks.most_common(1)[0][0] if marks else 0 beside = any( hx._HOPYO.fullmatch(_flat(r[first])) and len(r) > first + 1 and r[first + 1].strip() for r in rows if len(r) > first ) name = first + 1 if beside else first if not marks and rows and not any(r[0].strip() for r in rows if r) and len(rows[0]) > 1: name = 1 # 첫 칸이 모두 빔(호표 칸만 끼어 옴) return {"품명": name, "규격": name + 1, "수량": name + 2, "단위": name + 3, "금액": name + 5, "노": name + 6, "재": name + 8, "경": name + 10} # fmt: skip def check_cols(cols: Any) -> dict[str, int]: """받은 열 짝 정리 — 품명 · 수량 · 단위 있어야 · 칸 차례 0~60 · 같은 칸 둘 = 400.""" if not isinstance(cols, dict): raise PasteError("열 짝은 {품명, 규격, …: 칸 차례}") out = {} for k in FIELDS: v = cols.get(k) if v is None or v == "": continue if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= 60: raise PasteError(f"열 짝 「{k}」 칸 차례가 틀림") out[k] = v if any(k not in out for k in ("품명", "수량", "단위")): raise PasteError("열 짝에 품명 · 수량 · 단위는 꼭") if len(set(out.values())) != len(out): raise PasteError("열 짝에 같은 칸이 둘") return out def fit(cols: dict[str, int], rows: list[list[str]]) -> dict[str, int]: """기억한 열 짝이 다른 꼴에 붙을 때 — 칸 전체를 -1 · 0 · +1 밀어 보고 품명 글 · 수량 수가 가장 많이 맞는 쪽(엑셀꼴 「호표」 칸 있음 · STmate 꼴 없음 · 같으면 그대로).""" def score(d: int) -> int: got = 0 for r in rows: name, qty = _cell(r, cols["품명"] + d), _cell(r, cols["수량"] + d) got += bool(name and hx._num(name) is None) + (hx._num(qty) is not None) return got best = max((0, 1, -1), key=lambda d: (score(d), d == 0)) if best == 0 or min(cols.values()) + best < 0: return cols return {k: v + best for k, v in cols.items()} def _money_cols(cols: dict[str, int]) -> dict[str, int]: out = {"노무비": cols.get("노"), "재료비": cols.get("재"), "경비": cols.get("경")} if cols.get("금액") is not None: out["합계"] = cols["금액"] - 1 return {k: v for k, v in out.items() if v is not None and v >= 0} def _cell(row: list[str], col: int | None) -> str: return row[col] if col is not None and 0 <= col < len(row) else "" def _hopyo_at(row: list[str]) -> tuple[int, int] | None: """(번, 칸) — 줄에 「제N호표」 칸.""" for i, v in enumerate(row): hit = hx._HOPYO.fullmatch(_flat(v)) if hit: return int(hit.group(1)), i return None def _money(row: list[str], money: dict[str, int], offset: int) -> dict[str, Any]: return hx._money(row, money, offset) def read_text(text: str, cols: Any = None) -> dict[str, Any]: """붙인 글 → `{꼴, 시트, 호표, 단산, 검산, 열, 짐작, 물음}` — `물음` = 머리 없음 · 열 짝 안 받음 (짐작으로 읽은 결과 · 화면이 열 짝을 묻고 다시 부름).""" rows = cells(text) head = _header(rows) start, asked = 0, False if head is not None: start, used = head[0] + 1, head[1] below = [_flat(v) for v in rows[start]] if start < len(rows) else [] start += "단가" in below or "금액" in below elif cols is not None: used = fit(check_cols(cols), rows) else: used, asked = guess(rows), True money = _money_cols(used) note = (used["경"] + 2) if used.get("경") is not None else None hopyos: list[dict[str, Any]] = [] lines: list[list[dict[str, Any]]] = [] want_head = False def open_hopyo(no: int, row: list[str] | None) -> None: hopyos.append({"번": no, "이름": "", "규격": "", "단위": "", "금액": {}, "행": [0, 0]}) lines.append([]) if row is not None: fill(row) def fill(row: list[str]) -> None: hopyos[-1].update( 이름=_cell(row, used["품명"]), 규격=_cell(row, used.get("규격")), 단위=_cell(row, used["단위"]), 금액=_money(row, money, 1), ) for at, row in enumerate(rows[start:], start + 1): if not any(row): continue hit = _hopyo_at(row) name = _cell(row, used["품명"]) if hit: same = hit[1] != used["품명"] and name open_hopyo(hit[0], row if same else None) hopyos[-1]["행"] = [at, at] want_head = not same continue if want_head: if name: fill(row) want_head = False continue flat = _flat(name) if ( not flat or flat in ("품명", "명칭") or any(flat == w for w in _SKIP) or flat.startswith("합계") ): if ( hopyos and (flat.startswith("합계") or flat == "계") and not hopyos[-1]["금액"].get("계") ): hopyos[-1]["금액"] = _money(row, money, 1) # STmate 합계 줄 = 1 단위 값 continue price, amount = _money(row, money, 0), _money(row, money, 1) unit = _cell(row, used["단위"]) qty = hx._num(_cell(row, used["수량"])) if not unit and not any(v for v in (*price.values(), *amount.values())): continue # 근거 줄 「건설표준품셈 | 6-7-2 | 0」 if not hopyos: # 첫 줄 수량 없음 + 금액 있음 = 엑셀꼴 호표 머리(「제N호표」 칸 없이 복사) head_row = qty is None and any(amount.values()) open_hopyo(0, row if head_row else None) if head_row: continue remark = _cell(row, note) ref = hx._REF.match(_flat(remark)) line = { "차례": len(lines[-1]), "이름": name, "규격": _cell(row, used.get("규격")), "수량": qty, "단위": unit, "단가": price, "금액": amount, "비고": remark, "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} if ref else None, "행": at, } line["갈래"] = hx.kind_of(line) hopyos[-1]["행"][1] = at lines[-1].append(line) if not any(lines): raise PasteError("붙인 글에 일위대가 줄이 없음 — 품명 · 수량 · 단위 열을 확인") for hopyo, mine in zip(hopyos, lines, strict=True): if not hopyo["이름"]: hopyo["이름"] = "붙여넣기" if hopyo["번"] == 0 else f"제{hopyo['번']}호표" if not hopyo["금액"].get( "계" ): # 머리 · 합계 줄 금액 없음 = 줄 금액 합(엑셀 끝수 · 원 내림) sums = { k: sum((x["금액"].get(k) or Decimal(0) for x in mine), Decimal(0)) for k in hx.COSTS } hopyo["금액"] = {**{k: hx._floor(v, 0) for k, v in sums.items()}, "계": sum(hx._floor(v, 0) for v in sums.values())} # fmt: skip out = [ {**hx._json(h), "줄": [hx._json(x) for x in mine]} for h, mine in zip(hopyos, lines, strict=True) if mine ] return { "꼴": SHEET, "시트": SHEET, "호표": out, "단산": [], "검산": {"어긋남": [], "총괄표": 0}, "열": used, "짐작": head is None and cols is None, "물음": asked, } def fixed_lines(parsed: dict[str, Any], taken: Any = ()) -> dict[str, Any]: """줄만(M02 열린 문서 · B09 지금 호표) — 호표 모두의 줄 → 고정 줄(705-3 `line_of` · 하위 호표 줄도 금액 그대로) · `taken` = 문서에 이미 있는 고정 줄 가름(이름 끝 「 (2)」) → `{줄, 엑셀, 이음, 이음후보}`(엑셀 = 붙인 호표 1 단위 값 합).""" linker, have, out = hfix.Linker(), {str(x) for x in taken}, [] for h in parsed["호표"]: where = {"파일": SHEET, "호표": h["번"]} for line in h["줄"]: others = [x for x in h["줄"] if x is not line] out.append(hfix.line_of(line, others, where, have, linker)) excel = sum((h["금액"].get("계") or 0) for h in parsed["호표"]) links = sum(1 for x in out if any(x["고정"]["이음"].values())) return {"줄": out, "엑셀": excel, "이음": links, "이음후보": linker.near} def paste( folder: Any, text: str, cols: Any, who: Any, lines_only: bool = False, taken: Any = () ) -> dict[str, Any]: """[Ctrl+V] — `갈래` 「물음」(열 짝 짐작 + 보기 · 안 씀) · 「줄」(호표 머리 없음 · `lines_only`) · 「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로).""" try: parsed = read_text(text, cols) except PasteError as error: raise store.StoreError(400, str(error)) from error head = {"열": parsed["열"], "짐작": parsed["짐작"]} if parsed["물음"]: look = [[c[:24] for c in row[:16]] for row in cells(text)[:8]] return {**head, "갈래": "물음", "보기": look} named = any(h["번"] or h["이름"] != SHEET for h in parsed["호표"]) if lines_only or not named: return {**head, "갈래": "줄", **fixed_lines(parsed, taken)} keep = {k: parsed[k] for k in ("꼴", "시트", "호표", "단산", "검산")} row = hstore.save(folder, f"{SHEET} {datetime.now():%m-%d %H:%M}", keep, who) return {**head, "갈래": "만듦", "키": row["키"], **hb.build_all(folder, folder, row["키"], who)}