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:
2026-10-31 15:46:32 +09:00
co-authored by Claude Opus 5.5
parent bbbf963d05
commit 137b8a3a45
4 changed files with 495 additions and 1 deletions
+3 -1
View File
@@ -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
+295
View File
@@ -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)}
+25
View File
@@ -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)
+172
View File
@@ -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