- 첫 줄이 수량 없음이어도 금액 없으면(레미콘 별도계산) 줄 — 머리는 금액 있을 때만 - 기억한 열 짝을 다른 꼴(엑셀꼴 호표 칸 · STmate 꼴)에 붙이면 서버가 한 칸 밀어 맞춤 - 다시 잇기 저절로 고름 = 후보 하나 · 붙인 단가 있음만(별도계상 0 원 · 후보 여럿은 사람이 고름) · 빈 칸 글 없앰 · 고름 칸 앞으로 · 칸 폭 1100 안 - B09 알림에서 연 다시 잇기가 붙이기 전 캐시를 읽던 흠 — 붙인 뒤 캐시를 들고 감 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
14 KiB
Python
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)}
|