From 137b8a3a4530002c758b66d00ab6c5c55c2c757d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 15:46:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EC=97=91=EC=85=80=20=EB=B6=99?= =?UTF-8?q?=EC=97=AC=EB=84=A3=EA=B8=B0):=20=EB=B6=99=EC=9D=B8=20=EA=B8=80?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=ED=95=B4=EC=84=9D=20=C2=B7=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=EB=A7=88=EB=8B=A4=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=C2=B7=20=EC=A4=84=EB=A7=8C=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EC=A4=84=20(PLAN=20705-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Paste — 탭 · 줄 글 → 실무자료 꼴(파일 올리기와 같은 꼴) · 머리 글 줄이면 열 짝 저절로 · 없으면 기억한 열 짝 · 둘 다 없으면 짐작 + 물음 - 「제N호표」 엑셀꼴(같은 줄 머리) · STmate 꼴(다음 줄 머리) · 첫 줄 수량 없음 = 머리 · 합계 · 계 · 소계 · 계약단가 · 근거 줄 거름 · 쉼표 · 0 빈칸 - /hopyo-paste — 물음 · 줄(열린 문서 · 있는 줄 가름 피함) · 만듦(실무자료 「붙여넣기 …」 + build_all 그대로) - build_all 답에 만듦 [{번, 이름, 키}] - 시험: 실무 xlsx 넷 시트 통째 붙여넣기 = 파일과 호표 · 줄 · 합계 같음 · 만듦 엑셀 합계 = 우리 합계(14 · 23 · 34 · 25) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Build.py | 4 +- M02_MasterTemplete/M02_Hopyo_Paste.py | 295 +++++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 25 ++ resources/tester/test_705_2_hopyo_paste.py | 172 ++++++++++++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Paste.py create mode 100644 resources/tester/test_705_2_hopyo_paste.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 9f214d653..fa83470af 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -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 diff --git a/M02_MasterTemplete/M02_Hopyo_Paste.py b/M02_MasterTemplete/M02_Hopyo_Paste.py new file mode 100644 index 000000000..ff1ed6591 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Paste.py @@ -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)} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 76a3429b2..fbc2efadb 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -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) diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py new file mode 100644 index 000000000..c28a053b6 --- /dev/null +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -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