feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글) - 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0 - Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21 - Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안) - Grid STmate 시트 · 숨은 칸 글 뺌 - 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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["번"])):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
|
||||
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
|
||||
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
|
||||
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
|
||||
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
|
||||
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
|
||||
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
|
||||
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
|
||||
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
|
||||
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
|
||||
|
||||
|
||||
def is_stmate(names: Any) -> 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},
|
||||
}
|
||||
@@ -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["글"])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user