diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index 7a7705054..2dd647063 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -6,7 +6,8 @@ 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · -글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료). +글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은 +그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · @@ -34,7 +35,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 -GLINES, GCHARS = 200, 300 +GLINES, GCHARS, WIDE = 200, 300, 140 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -45,6 +46,12 @@ def _flat(value: Any) -> str: # ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +def _cell_text(value: Any) -> str: + if isinstance(value, float) and value == int(value): + value = int(value) + return str(value).strip() if value is not None else "" + + @lru_cache(maxsize=2) def _trade_names(whole_id: int) -> dict[str, str]: """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" @@ -78,11 +85,15 @@ def sources(text: str) -> list[list[str]]: def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: - """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}).""" + """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) · + 돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽).""" for at, row in enumerate(rows[:10]): flat = [_flat(v) for v in row] if "산출근거" in flat: - money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat} + last = len(flat) - 1 + money = { + key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat + } return at + 1, flat.index("산출근거"), money return 0, 1, {} @@ -93,14 +104,19 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: if not rows: return {} start, col, money = _columns(rows) + end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE names = trade_names() blocks: dict[int, dict[str, Any]] = {} block: dict[str, Any] | None = None for at, row in enumerate(rows[start:], start + 1): text = row[col] if col < len(row) else None - if not isinstance(text, str) or not text.strip(): - continue - head = None if text[:1].isspace() else _HEAD.match(text) + head = None + if isinstance(text, str) and text.strip(): + head = None if text[:1].isspace() else _HEAD.match(text) + else: + text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v)) + if not text: + continue if head: block = blocks.setdefault( int(head.group(1)), diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index c55a1b89c..e103d1d07 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -7,7 +7,12 @@ 호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 · 끝 = 다음 호표. 갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) · -자재(재료비만) · 기타 — 규격은 원문 그대로 둠. +자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠. +이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 → +호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면 +이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음. +STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` · +문서 보안(DRM) 파일 = 400 글. 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). @@ -25,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate SHEET = "일위대가" SUMMARY = "일위대가총괄표" @@ -32,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비") HEADS = ("호표", "품명", "규격", "수량", "단위", "비고") NO_SHEET = "일위대가 시트 없음" _HOPYO = re.compile(r"제\s*(\d+)\s*호표") -_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$") +_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$") +DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림" +_OLE = b"\xd0\xcf\x11\xe0" _MACHINE_UNITS = ("hr", "시간", "h") @@ -77,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") + if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): + raise ExcelError(DRM) try: if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): import openpyxl book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]): + wanted = hopyo_stmate.SHEETS out = { ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] for ws in book.worksheets @@ -143,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str: return "단가산출" if unit in _MACHINE_UNITS: return "기계" + if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names(): + return "노임" if not any(price.get(k) for k in ("계", *COSTS)): return "별도계상" if price.get("재료비") and not price.get("노무비") and not price.get("경비"): @@ -208,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]: return out +def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None: + """비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음.""" + index: dict[tuple[str, str], list[int]] = {} + for no, block in blocks.items(): + index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no) + for line in (one for lines in raw for one in lines): + if line["참조"] or line["비고"] or line["갈래"] == "%": + continue + if not (line["단가"].get("노무비") or line["단가"].get("경비")): + continue + hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or [] + if len(hit) == 1: + line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름" + line["갈래"] = kind_of(line) + + # ── 읽기 ────────────────────────────────────────────────────────────── def read_book(name: str, data: bytes) -> dict[str, Any]: """엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수.""" sheets = _sheets(name, data) + if hopyo_stmate.is_stmate(sheets): + return hopyo_stmate.read(sheets) rows = sheets.get(SHEET) if rows is None: raise ExcelError(NO_SHEET) @@ -238,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if head or not name_ or not hopyos: continue note = _text(_cell(row, cols.get("비고"))) - ref = _REF.match(note.replace(" ", "")) + ref = _REF.match(_flat(note)) line = { "차례": len(raw[-1]), "이름": name_, @@ -248,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "단가": _money(row, cols, 0), "금액": _money(row, cols, 1), "비고": note, - "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, + "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} + if ref + else None, } line["갈래"] = kind_of(line) line["행"] = hopyos[-1]["행"][1] = at @@ -256,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + _by_name(raw, blocks) for line in (one for lines in raw for one in lines): ref = line["참조"] or {} if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 39af897fa..b41f477f2 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -1,11 +1,12 @@ -"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 +(STmate = 「일위대가표」 · 「단가산출근거」 · 338) → A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · 읽기 전용 보기). xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). -xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. 결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. """ @@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate -SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC) HEAD_ROWS = 4 _HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} _VALIGN = {0: "top", 1: "center"} @@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: } for r, row in enumerate(rows) for c, v in enumerate(row[:width]) - if v not in (None, "") + if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글 } sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) return {"서식": [{}], "시트": sheets} diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py new file mode 100644 index 000000000..5d3a6cdd8 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -0,0 +1,264 @@ +"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 + +목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴 +(`M02_Hopyo_Excel.read_book`). + +일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 · +D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 · +다음 열 금액 · M 비고) → 「합계」 줄에서 끝. +줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) · +코드 없는 줄 = 호표 `근거글`. +갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 · +X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 · +PR · 단위 % = % · 그 밖 기타. +단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 · +소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 · +비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) · +머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` · +직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름). +검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀 + +SHEET = "일위대가표" +CALC = "단가산출근거" +LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표") +SHEETS = (SHEET, CALC, *LISTS) +_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$") +_META = re.compile(r"(? bool: + return SHEET in names and CALC in names + + +def _cell(row: list[Any], col: int) -> Any: + return row[col] if col < len(row) else None + + +def _s(value: Any) -> str: + return hopyo_excel._text(value) + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None: + for col in cols: + hit = _CODE.match(_s(_cell(row, col))) + if hit: + return hit.group(1) + return None + + +def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: + """목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸 + 「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열).""" + out: dict[str, dict[str, str]] = {} + for title in LISTS: + for row in sheets.get(title) or []: + meta = _META.search(" ".join(_s(v) for v in row)) + code = meta.group(1) if meta else _code(row, range(len(row))) + if code and _s(_cell(row, 1)): + out[code] = { + "이름": _s(row[1]), + "규격": _s(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + } + return out + + +# ── 단가산출근거 블록 ────────────────────────────────────────────────── +def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]: + """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}.""" + names = hopyo_basis.trade_names() + out: dict[str, dict[str, Any]] = {} + block: dict[str, Any] | None = None + title = "" + for at, row in enumerate(rows, 1): + head = next( + (hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None + ) # 머리 = G 열(판마다 B 열 글이 다름) + if head: + code = head.group(1) + info = table.get(code) or {} + block = out.setdefault( + code, + { + "번": int(code[1:]), + "코드": code, + "이름": info.get("이름", ""), + "규격": info.get("규격", ""), + "단위": info.get("단위", ""), + "행": [at, at], + "글": [], + "자원": [], + }, + ) + title = "" + continue + if block is None: + continue + first = _flat(_cell(row, 0)) + if first.startswith("총계"): + block["행"][1] = at + block = None + continue + text = _s(_cell(row, 1)) + res = _RES.match(_s(_cell(row, RES_COL))) + code = res.group(1) + res.group(2) if res else None + if not code: + alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6))) + code = alt.group(1) if alt else None + if not text and not code: + continue + block["행"][1] = at + if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」 + if not block["이름"]: + name, _, unit = text.rpartition("/") + block["이름"], block["단위"] = name.strip() or text, unit.strip() + continue + if text and _flat(text) not in ("소계", "계"): + if len(block["글"]) < hopyo_basis.GLINES: + block["글"].append(text[: hopyo_basis.GCHARS]) + if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text): + title = sec.group(1).strip() + if not code: + continue + last = block["자원"][-1] if block["자원"] else None + if code[0] == "X" and last and last["코드"] == code and last["절"] == title: + continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + name, _, spec = _s(_cell(row, NAME_COL)).partition("/") + info = table.get(code) or {} + qty = hopyo_excel._num(_cell(row, QTY_COL)) + block["자원"].append( + { + "갈래": code[0], + "코드": code, + "이름": name.strip() or info.get("이름", ""), + "규격": spec.strip() if name.strip() else info.get("규격", ""), + "품": float(qty) if qty is not None else None, + "절": title, + } + ) + for block in out.values(): + block["출처"] = hopyo_basis.sources(" ".join(block["글"])) + trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"] + block["직종"] = list(dict.fromkeys(t for t in trades if t)) + return out + + +# ── 일위대가표 ──────────────────────────────────────────────────────── +def _kind(code: str, line: dict[str, Any]) -> str: + if code.startswith("PR") or _flat(line["단위"]) == "%": + return "%" + if code[0] == "M": + apart = line["비고"].startswith("별산") or line["규격"] == "별도계상" + return "별도계상" if apart or not any(line["단가"].values()) else "자재" + return _KIND.get(code[0], "기타") + + +def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]: + hopyos: list[dict[str, Any]] = [] + raw: list[list[dict[str, Any]]] = [] + want_head = done = False + for at, row in enumerate(rows, 1): + first = _s(_cell(row, 0)) + hit = hopyo_excel._HOPYO.fullmatch(_flat(first)) + if hit: + code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None) + hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []}) + raw.append([]) + want_head, done = True, False + continue + if not hopyos or done: + continue + hopyo = hopyos[-1] + if want_head: + hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3))) + hopyo["금액"] = {} + want_head = False + continue + if _flat(first).startswith("합계") or _flat(first) == "계": + hopyo["금액"] = hopyo_excel._money(row, _COLS, 1) + done = True # 끝 — 다음 호표까지 줄 안 받음 + continue + if not first: + continue + code = _code(row) + hopyo["행"][1] = at + if not code: + if re.search(r"[0-9A-Za-z가-힣]", first): + hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip()) + continue + if code[0] in "TC" and code[1].isdigit(): + continue # 계 · 소계 · 계약단가 + line = { + "차례": len(raw[-1]), + "이름": first, + "규격": _s(_cell(row, 1)), + "수량": hopyo_excel._num(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + "단가": hopyo_excel._money(row, _COLS, 0), + "금액": hopyo_excel._money(row, _COLS, 1), + "비고": _s(_cell(row, NOTE)), + "코드": code, + "행": at, + } + line["갈래"] = _kind(code, line) + line["참조"] = ( + {"종류": "단산", "번": int(code[1:]), "코드": code} + if code[0] == "D" + else {"종류": "일위", "번": None, "코드": code} + if code[0] == "B" + else None + ) + raw[-1].append(line) + return hopyos, raw + + +def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]: + """STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수).""" + hopyos, raw = _hopyos(sheets.get(SHEET) or []) + if not hopyos: + raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + table = _lists(sheets) + blocks = _blocks(sheets.get(CALC) or [], table) + by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]} + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "일위": + ref["번"] = by_code.get(ref["코드"]) + elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} + out = [] + for hopyo, lines in zip(hopyos, raw, strict=True): + for key in ("이름", "규격", "단위"): + hopyo.setdefault(key, "") + if not hopyo["근거글"]: + del hopyo["근거글"] + out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]}) + keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원") + return { + "꼴": "STmate", + "시트": SHEET, + "호표": out, + "단산": [{k: b[k] for k in keep} for b in blocks.values()], + "검산": {"어긋남": [], "총괄표": 0}, + } diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 8bc0a880b..1adb7f3be 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -4,6 +4,7 @@ 후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · 블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 · +거창 꼴 흩어진 칸 이어 붙임(단산 줄 21 · 출처 17 · 직종 21 · 338-2b) · master_data 그대로. """ @@ -152,3 +153,19 @@ def test_조항_윗마디_오타(north): assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] stone = next(b for b in north["단산"] if b["번"] == 63) assert stone["출처"] == [["산림품셈", "13-11-4"]] + + +def test_거창_흩어진_칸(): + path = ( + base.ROOT + / "resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서" + / "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" + ) + book = hx.read_book(path.name, path.read_bytes()) + lines = [x for h in book["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert len(lines) == 21 and all(x.get("근거") for x in lines) + assert sum(1 for x in lines if x["근거"]["출처"]) == 17 + assert sum(1 for x in lines if x["근거"]["직종"]) == 21 + first = book["단산"][0] + assert first["이름"] == "측구터파기(토사)" and first["직종"] == ["보통인부"] + assert any(t.startswith("보통인부 : 172,068") for t in first["글"]) diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py index 6e6b14fdf..7129e28de 100644 --- a/resources/tester/test_m02_hopyo_excel.py +++ b/resources/tester/test_m02_hopyo_excel.py @@ -2,7 +2,8 @@ 견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) · 검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 · -양식 목록에 안 섞임 · +양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 · +문서 보안 400(338) · master_data 원본 그대로(sha256). 층은 임시 폴더. """ @@ -207,3 +208,54 @@ def test_마스터_층은_마스터_폴더(client): client.who.update(role="USER") assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나 assert client.delete(URL.format("system") + "/HP-000001").status_code == 403 + + +def _linked_xlsx() -> bytes: + """338 — 비고 「산근 2 호표」 · 「대가1」 · 빈 비고 + 이름 같은 블록 · 노임 줄.""" + import openpyxl + + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가" + ws.append([None, "호표", "품명", "규격", "수량", "단위", "합계", None, "노무비", None, "비고"]) + ws.append([None, None, None, None, None, None, "단가", "금액", "단가", "금액"]) + ws.append([None, "제1호표", "표지석", "", None, "개소", None, 1000, None, 1000]) + ws.append([None, None, "터파기", "토사", 1, "㎥", 100, 100, 100, 100, "산근 2 호표"]) + ws.append([None, None, "되메우기", "", 1, "㎥", 100, 100, 100, 100, None]) + ws.append([None, None, "보통인부", "", 1, "인", 100, 100, 100, 100, None]) + ws.append([None, None, "호표 줄", "", 1, "개소", 100, 100, 100, 100, "대가1"]) + calc = book.create_sheet("단가산출") + calc.append([None, "산출근거", "합계", "노무비"]) + calc.append([None, "2.터파기[㎥]"]) + calc.append([None, " [건설공사표준품셈 3-2-1]"]) + calc.append([None, "3.되메우기[㎥]"]) + calc.append([None, " 보통인부 : 100 × 1 = 100"]) + out = io.BytesIO() + book.save(out) + return out.getvalue() + + +def test_이음_넓힘_노임(): + got = hx.read_book("a.xlsx", _linked_xlsx()) + lines = {x["이름"]: x for x in got["호표"][0]["줄"]} + assert lines["터파기"]["참조"] == {"종류": "단산", "번": 2} and "이음" not in lines["터파기"] + assert lines["터파기"]["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert lines["되메우기"]["참조"] == {"종류": "단산", "번": 3} # 빈 비고 · 이름 같은 블록 하나 + assert lines["되메우기"]["이음"] == "이름" and lines["되메우기"]["갈래"] == "단가산출" + assert lines["되메우기"]["근거"]["직종"] == ["보통인부"] + assert lines["보통인부"]["갈래"] == "노임" and "이음" not in lines["보통인부"] + assert lines["호표 줄"]["참조"] == {"종류": "일위", "번": 1} + for note, want in ( + ("단산12참조", ("단산", 12)), + ("단가산출 3", ("단산", 3)), + ("일위대가4호표", ("일위", 4)), + ("일위 5 참조", ("일위", 5)), + ): + hit = hx._REF.match(hx._flat(note)) + assert ("단산" if hit.group(1) else "일위", int(hit.group(3))) == want + assert hx._REF.match("단산참조") is None and hx._REF.match("D96") is None + + +def test_문서_보안_400(): + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book("a.xlsx", bytes([0x9B]) + b" DRMONE This Document is secured" + bytes(64)) diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 58c6cac90..4db5efaa0 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -2,6 +2,7 @@ 북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · 제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +STmate 격자(일위대가표 · 단가산출근거 · 숨은 칸 글 뺌 · 338) · master_data 그대로. """ @@ -131,3 +132,22 @@ def test_xlsx_값만(): assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] + + +def test_STmate_격자(): + from resources.tester.test_m02_hopyo_stmate import _path + + path = _path("영월") + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + grid = hg.read(path.name, data, parsed) + assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"] + cells = _sheet(grid, "일위대가표")["칸"] + one = grid["호표"][0] + assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]} + assert cells[f"A{one['행'][0]}"]["값"].strip().startswith("제") + assert cells["A10"]["값"] == "보통인부" + calc = _sheet(grid, "단가산출근거")["칸"] + block = next(b for b in grid["단산"] if b["번"] == 2) + assert calc[f"B{block['행'][0] + 1}"]["값"].startswith("구조물터파기(인력)") + assert not any(str(c.get("값")).startswith("_x000") for c in {**cells, **calc}.values()) diff --git a/resources/tester/test_m02_hopyo_stmate.py b/resources/tester/test_m02_hopyo_stmate.py new file mode 100644 index 000000000..82ad43569 --- /dev/null +++ b/resources/tester/test_m02_hopyo_stmate.py @@ -0,0 +1,185 @@ +"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`). + +여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) · +봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 · +노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) · +문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로. +""" + +import hashlib +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Stmate as st +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ROOT, _post, master_hash + +client = base.client # 공용 고정물(임시 층) +DOCS = ROOT / "resources" / "knowledge" / "original" / "실무문서" +FILES = { + "봉화": "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", + "영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "대흥설계": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/" + "2024년 간선임도사업(기번3).xlsx", + "대흥착공": "2024년 간선임도(기번3-울진.대흥)(공통)/" + "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx", + "영덕": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/" + "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx", + "소광": "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", +} +HOPYOS = {"봉화": 45, "영월": 23, "대흥설계": 34, "대흥착공": 34, "영덕": 9, "소광": 25} +LOCKED = { + "장수": "02.산불 장수/01.내역서/" + "00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1)내역+수량.xlsx", + "진안": "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2)내역+수량.xlsx", +} + + +def _path(tag: str) -> Path: + return DOCS / {**FILES, **LOCKED}[tag] + + +@pytest.fixture(scope="module") +def books() -> dict[str, dict]: + return {tag: hx.read_book(_path(tag).name, _path(tag).read_bytes()) for tag in FILES} + + +def _lines(book: dict) -> list[dict]: + return [x for h in book["호표"] for x in h["줄"]] + + +def test_여섯_파일_호표_수(books): + assert {tag: len(b["호표"]) for tag, b in books.items()} == HOPYOS + for book in books.values(): + assert (book["꼴"], book["시트"]) == ("STmate", "일위대가표") + assert book["검산"] == {"어긋남": [], "총괄표": 0} + assert len(_lines(books["대흥설계"])) == len(_lines(books["대흥착공"])) == 171 # 같은 설계 + + +def test_봉화_줄_갈래(books): + bong = books["봉화"] + lines = _lines(bong) + kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "노임", "호표", "%")} + assert kinds == {"단가산출": 103, "노임": 30, "호표": 22, "%": 7} + assert len(lines) + sum(len(h.get("근거글") or []) for h in bong["호표"]) == 216 + assert all(x["코드"] for x in lines) + for book in books.values(): + for x in _lines(book): + if x["갈래"] == "호표": + assert x["참조"]["번"] in {h["번"] for h in book["호표"]} + if x["갈래"] == "단가산출": + assert x["참조"] == {"종류": "단산", "번": int(x["코드"][1:]), "코드": x["코드"]} + assert "근거" in x # 블록 모두 이어짐 + by = {h["번"]: h for h in bong["호표"]} + assert by[13]["근거글"] == ["건설표준품셈 7-2-2(찰붙임)"] # 코드 없는 줄 = 호표 근거글 + assert hb.sources(" ".join(by[13]["근거글"])) == [["건설품셈", "7-2-2"]] + + +def test_영월_줄과_블록(books): + yw = books["영월"] + one = yw["호표"][0] + assert (one["번"], one["코드"], one["이름"], one["단위"]) == ( + 1, + "B00260", + "녹생토(종자배합)", + "M3", + ) + assert one["행"] == [5, 10] and one["금액"]["계"] == 155184 + assert [(x["이름"], x["코드"], x["갈래"], x["수량"]) for x in one["줄"]] == [ + ("산림종자", "M00024", "자재", 0.25), + ("산림종자", "M00211", "자재", 0.25), + ("인공토양(녹생토)", "M00832", "자재", 1.1), + ("보통인부", "L00002", "노임", 0.43), + ] + two = {x["이름"]: x for x in yw["호표"][1]["줄"]} + assert two["국가지점번호판 검증수수료"]["갈래"] == "별도계상" # 비고 「별산」 + assert two["구조물터파기(인력)"]["근거"] == { + "출처": [["건설품셈", "3-1-3"]], + "직종": ["보통인부"], + "행": [5, 54], + } + block = next(b for b in yw["단산"] if b["코드"] == "D00002") + assert (block["번"], block["이름"], block["규격"], block["단위"]) == ( + 2, + "구조물터파기(인력)", + "경질토사", + "M3", + ) + assert block["자원"] == [ + {"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.26, "절": ""} + ] + assert any("건설표준품셈3-1-3" in t for t in block["글"]) + + +def test_기계_자원_하나로(books): + block = next(b for b in books["봉화"]["단산"] if b["코드"] == "D00002") + machines = [r for r in block["자원"] if r["갈래"] == "X"] + assert len(machines) == 1 # 노무비 · 재료비 · 경비 셋 줄 + assert machines[0]["이름"] == "굴삭기(무한궤도)" and machines[0]["규격"] == "0.2㎥" + assert machines[0]["품"] == pytest.approx(0.0636537237529) + + +def test_노임_이름_어긋남_0(books): + names = hb.trade_names() + for tag in FILES: + sheets = hx._sheets(_path(tag).name, _path(tag).read_bytes()) + table = st._lists(sheets) + res = [r for b in books[tag]["단산"] for r in b["자원"] if r["갈래"] == "L" and r["이름"]] + assert res + assert sum(1 for r in res if r["코드"] in table) >= len(res) // 2 + for r in res: + if r["코드"] in table: # 목록표에 있는 직종 — X 열 이름과 같음 + assert st._flat(r["이름"]) == st._flat(table[r["코드"]]["이름"]) + if table.get(r["코드"], {}).get("단위") == "인": # L 코드 % 줄(기구손료) 뺌 + assert st._flat(r["이름"]) in names, (tag, r) + labor = [x for x in _lines(books[tag]) if x["갈래"] == "노임"] + assert all(st._flat(x["이름"]) in names for x in labor) + + +def test_이음_열쇠_코드_칸(): + row = [None] * 26 + row[12], row[14], row[15] = "별산M00985", "M00985_1", "T7_1" + assert st._code(row) == "M00985" + row = [None] * 26 + row[25] = "D01352 →" # O 열 없는 판 + assert st._code(row) == "D01352" + row = [None] * 47 + row[14], row[13] = "PRA01_1", "_x0007_`COD|PR_x0005_`EXI" + assert st._code(row) == "PRA01" + assert ( + st._code(["계", None, None, None, None, None, None, None] + [None] * 6 + ["T7_1"]) == "T7" + ) + assert st._code([None] * 12 + ["D96"]) is None # 비고 글은 코드 아님 + + +def test_문서_보안_400(client): + for tag in LOCKED: + data = _path(tag).read_bytes() + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book(_path(tag).name, data) + res = _post(client, data=data, name=_path(tag).name) + assert res.status_code == 400 and res.json()["detail"] == hx.DRM + + +def test_여섯_파일_올라감(client): + before = master_hash() + sources = {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} + for at, tag in enumerate(FILES, 1): + res = _post(client, data=_path(tag).read_bytes(), name=_path(tag).name) + assert res.status_code == 200, res.text + assert res.json()["호표수"] == HOPYOS[tag] + doc = hs.read(layers.personal_dir(7, 42), f"HP-{at:06d}") + assert doc["꼴"] == "STmate" + assert sum(1 for b in doc["단산"] if not b["글"]) <= 3 # 봉화 무대처리 · 흙 쌓 기 = 값 0 + assert any(b["자원"] for b in doc["단산"]) + first = doc["호표"][0]["번"] + view = client.get(f"{base.URL.format('personal')}/HP-{at:06d}/{first}") + assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음 + assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources + assert master_hash() == before