Files
Aislo/M02_MasterTemplete/M02_Hopyo_Paste.py
T
eomsangdonandClaude Opus 5.5 86f40dedbd fix(705-2 붙여넣기 · 다시 잇기): 화면 검증에서 나온 흠 — 첫 줄 머리 짐작 · 열 짝 한 칸 밀림 · 저절로 고름 · 칸 폭 · B09 최신 캐시
- 첫 줄이 수량 없음이어도 금액 없으면(레미콘 별도계산) 줄 — 머리는 금액 있을 때만
- 기억한 열 짝을 다른 꼴(엑셀꼴 호표 칸 · STmate 꼴)에 붙이면 서버가 한 칸 밀어 맞춤
- 다시 잇기 저절로 고름 = 후보 하나 · 붙인 단가 있음만(별도계상 0 원 · 후보 여럿은 사람이 고름) · 빈 칸 글 없앰 · 고름 칸 앞으로 · 칸 폭 1100 안
- B09 알림에서 연 다시 잇기가 붙이기 전 캐시를 읽던 흠 — 붙인 뒤 캐시를 들고 감

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:42:28 +09:00

314 lines
14 KiB
Python

"""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)}