feat(M02 엑셀 붙여넣기): 붙인 글 서버 해석 · 호표마다 사전조합 · 줄만 고정 줄 (PLAN 705-2)
- M02_Hopyo_Paste — 탭 · 줄 글 → 실무자료 꼴(파일 올리기와 같은 꼴) · 머리 글 줄이면 열 짝 저절로 · 없으면 기억한 열 짝 · 둘 다 없으면 짐작 + 물음
- 「제N호표」 엑셀꼴(같은 줄 머리) · STmate 꼴(다음 줄 머리) · 첫 줄 수량 없음 = 머리 · 합계 · 계 · 소계 · 계약단가 · 근거 줄 거름 · 쉼표 · 0 빈칸
- /hopyo-paste — 물음 · 줄(열린 문서 · 있는 줄 가름 피함) · 만듦(실무자료 「붙여넣기 …」 + build_all 그대로)
- build_all 답에 만듦 [{번, 이름, 키}]
- 시험: 실무 xlsx 넷 시트 통째 붙여넣기 = 파일과 호표 · 줄 · 합계 같음 · 만듦 엑셀 합계 = 우리 합계(14 · 23 · 34 · 25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -462,7 +462,7 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]:
|
|||||||
def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]:
|
def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]:
|
||||||
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
|
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
|
||||||
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
|
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
|
||||||
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`."""
|
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`."""
|
||||||
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
|
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
|
||||||
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
|
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
|
||||||
out: dict[str, Any] = {
|
out: dict[str, Any] = {
|
||||||
@@ -472,6 +472,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
|
|||||||
"그대로": 0,
|
"그대로": 0,
|
||||||
"다름": [],
|
"다름": [],
|
||||||
"실패": [],
|
"실패": [],
|
||||||
|
"만듦": [],
|
||||||
}
|
}
|
||||||
for no in _order(units):
|
for no in _order(units):
|
||||||
out["줄"] += len(units[no].get("줄") or [])
|
out["줄"] += len(units[no].get("줄") or [])
|
||||||
@@ -487,6 +488,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
|
|||||||
for k in sums:
|
for k in sums:
|
||||||
sums[k] += made["줄수"][k]
|
sums[k] += made["줄수"][k]
|
||||||
got = compare(ref, maker.hopyo)
|
got = compare(ref, maker.hopyo)
|
||||||
|
out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]})
|
||||||
except store.StoreError as e:
|
except store.StoreError as e:
|
||||||
out["실패"].append({"번": no, "까닭": str(e.detail)})
|
out["실패"].append({"번": no, "까닭": str(e.detail)})
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""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 _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 = check_cols(cols)
|
||||||
|
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호표」 칸 없이 복사)
|
||||||
|
open_hopyo(0, row if qty is None else None)
|
||||||
|
if qty is None:
|
||||||
|
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)}
|
||||||
@@ -27,6 +27,7 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
|||||||
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
|
||||||
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
|
||||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||||
|
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
|
||||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||||
@@ -442,3 +443,27 @@ def hopyo_delete(
|
|||||||
folder = _root(session, layer, owner, write=True)
|
folder = _root(session, layer, owner, write=True)
|
||||||
_call(folder, hopyo_store.delete, folder, key)
|
_call(folder, hopyo_store.delete, folder, key)
|
||||||
return {"지움": key}
|
return {"지움": key}
|
||||||
|
|
||||||
|
|
||||||
|
class PasteBody(_Strict):
|
||||||
|
"""엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) ·
|
||||||
|
`줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함)."""
|
||||||
|
|
||||||
|
글: str = Field(max_length=hopyo_paste.TEXT_MAX)
|
||||||
|
열: dict[str, int] | None = None
|
||||||
|
줄만: bool = False
|
||||||
|
있음: list[str] = Field(default_factory=list, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/layers/{layer}/hopyo-paste")
|
||||||
|
def hopyo_paste_run(
|
||||||
|
layer: Layer,
|
||||||
|
body: PasteBody,
|
||||||
|
owner: str | None = None,
|
||||||
|
session: Session = Depends(verify_session),
|
||||||
|
) -> dict:
|
||||||
|
"""[Ctrl+V] — `갈래` 물음(열 짝) · 줄(고정 줄 · 저장은 문서 [저장]) · 만듦(실무자료 + 호표마다 사전조합 ·
|
||||||
|
층 쓰기 · `build_all` 답 + 키)."""
|
||||||
|
folder = _root(session, layer, owner, write=True)
|
||||||
|
args = (folder, body.글, body.열, _who(session), body.줄만, body.있음)
|
||||||
|
return _call(folder, hopyo_paste.paste, *args)
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""705-2 엑셀 붙여넣기 — 붙인 글(탭 · 줄)을 서버가 읽어 고정 줄 · 호표마다 사전조합(요구서 3장 10 · 5-A ⑤-2).
|
||||||
|
|
||||||
|
- 실무 xlsx 넷(거창 · 영월 · 울진 대흥 · 소광) 일위대가 시트를 엑셀 보이는 글(쉼표 · 0 빈칸)로 복사한 꼴
|
||||||
|
→ 시트 통째 = 파일 올리기와 호표 · 줄 · 합계 같음 · 만듦 = 엑셀 합계 = 우리 합계
|
||||||
|
- 호표 하나 · 머리 글 없음(열 짝 짐작 + 물음) · 기억한 열 짝 · 줄만(열린 문서) · 합계 · 근거 줄 거름 · % 줄
|
||||||
|
층은 임시 폴더(공용 storage 안 씀).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||||
|
from M02_MasterTemplete import M02_Hopyo_Paste as hp
|
||||||
|
|
||||||
|
# ruff: noqa: F811
|
||||||
|
from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401
|
||||||
|
|
||||||
|
URL = "/api/m02/layers/personal/hopyo-paste"
|
||||||
|
DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서"
|
||||||
|
FILES = { # 이름 · 엑셀 합계 = 우리 합계 수(영월 23 — 붙인 글엔 하위 호표 코드가 없어 엑셀 값 그대로 · 파일은 22)
|
||||||
|
"거창": (
|
||||||
|
"01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||||
|
14,
|
||||||
|
),
|
||||||
|
"영월": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", 23),
|
||||||
|
"대흥": (
|
||||||
|
"2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
|
||||||
|
34,
|
||||||
|
),
|
||||||
|
"소광": (
|
||||||
|
"25년 산불진화임도(기번8)(울진소광)/"
|
||||||
|
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||||
|
25,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _shown(v):
|
||||||
|
"""엑셀이 클립보드에 내는 글 — 천 단위 쉼표 · 0 = 빈칸(STmate 표시꼴) · 줄바꿈 = 띄움."""
|
||||||
|
if v is None or v == 0:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return f"{int(v):,}" if float(v).is_integer() else f"{v:,.6f}".rstrip("0").rstrip(".")
|
||||||
|
return str(v).replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(tag):
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
book = openpyxl.load_workbook(DOCS / FILES[tag][0], read_only=True, data_only=True)
|
||||||
|
sheet = "일위대가" if "일위대가" in book.sheetnames else "일위대가표"
|
||||||
|
first = 1 if sheet == "일위대가" else 0 # 엑셀꼴 = B 열(호표)부터 · STmate = A 열부터
|
||||||
|
width = 14 if first else 13
|
||||||
|
return [
|
||||||
|
[_shown(v) for v in list(r)[first : first + width]]
|
||||||
|
for r in book[sheet].iter_rows(values_only=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(rows):
|
||||||
|
return "\n".join("\t".join(r) for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _paste(client, text, **body):
|
||||||
|
res = client.post(URL, json={"글": text, **body})
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tag", list(FILES))
|
||||||
|
def test_시트_통째_붙여넣기_파일과_같음(tag):
|
||||||
|
got = hp.read_text(_text(_rows(tag)))
|
||||||
|
book = hx.read_book(FILES[tag][0], (DOCS / FILES[tag][0]).read_bytes())
|
||||||
|
assert got["물음"] is False # 머리 글이 붙어 옴 = 열 짝 저절로
|
||||||
|
key = lambda h: (h["번"], h["이름"], h["규격"], h["단위"], h["금액"].get("계"), len(h["줄"])) # noqa: E731
|
||||||
|
assert [key(h) for h in got["호표"]] == [key(h) for h in book["호표"]]
|
||||||
|
zero = lambda m: {k: round(v or 0, 4) for k, v in m.items()} # noqa: E731 · 엑셀 0 = 빈칸으로 옴
|
||||||
|
line = lambda x: (x["이름"], x["수량"] or 0, x["단위"], zero(x["단가"]), zero(x["금액"])) # noqa: E731
|
||||||
|
assert [line(x) for h in got["호표"] for x in h["줄"]] == [
|
||||||
|
line(x) for h in book["호표"] for x in h["줄"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tag", list(FILES))
|
||||||
|
def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag):
|
||||||
|
before = master_hash()
|
||||||
|
got = _paste(client, _text(_rows(tag)))
|
||||||
|
assert got["갈래"] == "만듦" and got["빠짐"] == 0 and got["실패"] == []
|
||||||
|
assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"])
|
||||||
|
assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"]
|
||||||
|
assert master_hash() == before
|
||||||
|
rows = client.get("/api/m02/layers/personal/hopyo").json()["줄"]
|
||||||
|
assert rows[0]["파일"].startswith("붙여넣기") and rows[0]["시트"] == "붙여넣기"
|
||||||
|
|
||||||
|
|
||||||
|
def _block(rows, no):
|
||||||
|
"""호표 하나 줄 — 「제N호표」 줄부터 다음 호표 앞까지(머리 글 없이)."""
|
||||||
|
at = next(i for i, r in enumerate(rows) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) and int(hx._HOPYO.fullmatch(c.replace(" ", "")).group(1)) == no for c in r)) # fmt: skip
|
||||||
|
end = next((i for i in range(at + 1, len(rows)) if any(hx._HOPYO.fullmatch(c.replace(" ", "")) for c in rows[i])), len(rows)) # fmt: skip
|
||||||
|
return rows[at:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_호표_하나_머리_없음_물음_뒤_기억한_열_짝(client):
|
||||||
|
rows = _block(_rows("대흥"), 17) # 돌기슭막이(찰쌓기) H=1.5
|
||||||
|
ask = _paste(client, _text(rows))
|
||||||
|
assert ask["갈래"] == "물음" and ask["짐작"] is True and ask["보기"]
|
||||||
|
cols = ask["열"]
|
||||||
|
assert (cols["품명"], cols["수량"], cols["단위"], cols["노"]) == (0, 2, 3, 6)
|
||||||
|
got = _paste(client, _text(rows), 열=cols) # 화면이 한 번 묻고 기억한 열 짝
|
||||||
|
assert got["갈래"] == "만듦" and got["호표"] == 1 and got["같음"] == 1
|
||||||
|
assert got["만듦"][0]["이름"].startswith("돌기슭막이")
|
||||||
|
|
||||||
|
|
||||||
|
def test_엑셀꼴_호표_여럿_머리_없음(client):
|
||||||
|
rows = _rows("거창")
|
||||||
|
some = _block(rows, 2) + _block(rows, 3)
|
||||||
|
cols = hp.read_text(_text(some))["열"]
|
||||||
|
assert cols["품명"] == 1 # 「제N호표」 칸이 따로 = 그 다음 칸이 품명
|
||||||
|
got = _paste(client, _text(some), 열=cols)
|
||||||
|
assert (got["호표"], got["같음"]) == (2, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_줄만_고정_줄_합계_근거_거름():
|
||||||
|
text = "\n".join(
|
||||||
|
[
|
||||||
|
"석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6\t\t\t\t\t",
|
||||||
|
"보통인부\t\t0.04\t인\t169,804\t6,792.1\t169,804\t6,792.1\t\t\t\t\t",
|
||||||
|
"공구손료\t노무비의\t3\t%\t28,304.7\t849.1\t\t\t\t\t849.1\t849.1\t",
|
||||||
|
"건설표준품셈\t6-7-2\t\t\t\t\t\t\t\t\t\t\t",
|
||||||
|
"합 계\t\t\t\t\t29,153\t\t28,304\t\t\t\t849\t",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
|
||||||
|
parsed = hp.read_text(text, cols)
|
||||||
|
(one,) = parsed["호표"]
|
||||||
|
assert [x["이름"] for x in one["줄"]] == ["석공", "보통인부", "공구손료"] # 근거 · 합계 줄 뺌
|
||||||
|
assert one["금액"]["계"] == 29153 and one["줄"][0]["수량"] == 0.08
|
||||||
|
out = hp.fixed_lines(parsed, {"고정|석공||인"})
|
||||||
|
names = [x["고정"]["이름"] for x in out["줄"]]
|
||||||
|
assert names == ["석공 (2)", "보통인부", "공구손료"] # 있는 줄 가름 피함
|
||||||
|
assert out["줄"][2]["고정"]["비율"] == {"기준": "노무비", "비목": "경비"}
|
||||||
|
assert out["줄"][0]["고정"]["단가"]["노무비"] == 268908 and out["엑셀"] == 29153
|
||||||
|
|
||||||
|
|
||||||
|
def test_줄만_길_저장_없음(client):
|
||||||
|
text = "석공\t\t0.08\t인\t268,908\t21,512.6\t268,908\t21,512.6"
|
||||||
|
cols = {"품명": 0, "규격": 1, "수량": 2, "단위": 3, "금액": 5, "노": 6, "재": 8, "경": 10}
|
||||||
|
got = _paste(client, text, 열=cols, 줄만=True)
|
||||||
|
assert got["갈래"] == "줄" and len(got["줄"]) == 1 and got["줄"][0]["수량"] == {"손": 0.08}
|
||||||
|
assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "cols", "word"),
|
||||||
|
[
|
||||||
|
("", None, "빔"),
|
||||||
|
("가\t나", {"품명": 0, "수량": 0, "단위": 1}, "같은 칸"),
|
||||||
|
("가\t나", {"품명": 0, "규격": 1}, "꼭"),
|
||||||
|
("합계\t\t\t\t1,000", {"품명": 0, "수량": 2, "단위": 3, "금액": 4}, "줄이 없음"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_틀린_글_400(client, text, cols, word):
|
||||||
|
res = client.post(URL, json={"글": text, **({"열": cols} if cols else {})})
|
||||||
|
assert res.status_code == 400 and word in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_쉼표_빈칸_수():
|
||||||
|
assert hx._num("1,415,169") == Decimal("1415169") and hx._num("") is None
|
||||||
Reference in New Issue
Block a user