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]:
|
||||
"""[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) ·
|
||||
이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보,
|
||||
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`."""
|
||||
같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`."""
|
||||
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
|
||||
sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0)
|
||||
out: dict[str, Any] = {
|
||||
@@ -472,6 +472,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str
|
||||
"그대로": 0,
|
||||
"다름": [],
|
||||
"실패": [],
|
||||
"만듦": [],
|
||||
}
|
||||
for no in _order(units):
|
||||
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:
|
||||
sums[k] += made["줄수"][k]
|
||||
got = compare(ref, maker.hopyo)
|
||||
out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]})
|
||||
except store.StoreError as e:
|
||||
out["실패"].append({"번": no, "까닭": str(e.detail)})
|
||||
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_Ledger as hopyo_ledger
|
||||
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_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -442,3 +443,27 @@ def hopyo_delete(
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
_call(folder, hopyo_store.delete, folder, 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