Files
Aislo/resources/tester/test_m02_hopyo_excel.py
T
eomsangdonandClaude Opus 5.5 193e03927b 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
2026-10-15 23:28:57 +09:00

262 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""일위대가 실무자료 읽기 · 저장 · 올리기 길(PLAN 324-1 · `M02_Hopyo_Excel` · `M02_Hopyo_Store`).
견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) ·
검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 ·
양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 ·
문서 보안 400(338) ·
master_data 원본 그대로(sha256). 층은 임시 폴더.
"""
import hashlib
import io
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
SAMPLES = ROOT / "resources" / "knowledge" / "original" / "실무문서" / "2026_실무일위대가 모음"
NORTH = SAMPLES / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
SABANG = SAMPLES / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls"
NO_SHEET = SAMPLES / "2016_상반_산림청_사방사업표준품셈(안).xls"
URL = "/api/m02/layers/{}/hopyo"
USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"}
def master_hash() -> dict[str, str]:
"""master_data 원본(*.json · 일위대가조합.json) sha256 — 올리기 · 맞춤 · 만들기 뒤 그대로."""
folder = ROOT / "resources" / "master_data"
return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in folder.glob("*.json")}
@pytest.fixture(scope="module")
def north() -> dict:
return hx.read_book(NORTH.name, NORTH.read_bytes())
def _one(book: dict, no: int) -> dict:
return next(h for h in book["호표"] if h["번"] == no)
# ── 읽기 ──────────────────────────────────────────────────────────────
def test_북부청_68_호표_검산_어긋남_0(north):
assert [h["번"] for h in north["호표"]] == list(range(1, 69))
assert north["검산"] == {"어긋남": [], "총괄표": 67} # 제61호표 = 값 없음
lines = [line for h in north["호표"] for line in h["줄"]]
assert len(lines) == 415
kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "%", "호표", "기계")}
assert kinds == {"단가산출": 268, "%": 1, "호표": 1, "기계": 3}
def test_사방_27_호표_검산_어긋남_0():
got = hx.read_book(SABANG.name, SABANG.read_bytes())
assert len(got["호표"]) == 27 and got["검산"]["어긋남"] == []
def test_호표_값과_줄(north):
one = _one(north, 1)
assert (one["이름"], one["규격"], one["단위"]) == (
"배수관날개벽(유입구)",
"D=1,000mm,일반형",
"개소",
)
assert one["금액"] == {"계": 1040052, "노무비": 777224, "재료비": 239284, "경비": 23544}
assert one["줄"][0]["갈래"] == "별도계상" and one["줄"][0]["규격"] == "별도계상"
three = _one(north, 3)
assert [(x["이름"], x["수량"], x["단위"], x["갈래"]) for x in three["줄"]] == [
("임도표지석(받침,글새김포함)", 1, "㎥", "자재"),
("구조물터파기(인력100%)", 0.14, "㎥", "단가산출"),
("구조물되메우기(인력100%)", 0.05, "㎥", "단가산출"),
("잔토처리(인력100%)", 0.09, "㎥", "단가산출"),
]
assert three["줄"][1]["참조"] == {"종류": "단산", "번": 93}
assert three["금액"]["계"] == 1415169 and three["금액"]["경비"] is None
assert _one(north, 45)["줄"][0]["참조"] == {"종류": "일위", "번": 46}
pct = [x for x in _one(north, 46)["줄"] if x["갈래"] == "%"]
assert [(x["규격"], x["수량"], x["단가"]["계"]) for x in pct] == [("재료비의", 3, 42390)]
empty = _one(north, 61)
assert empty["줄"] == [] and empty["금액"]["계"] is None
def test_일위대가_시트_없음():
with pytest.raises(hx.ExcelError, match="일위대가 시트 없음"):
hx.read_book(NO_SHEET.name, NO_SHEET.read_bytes())
with pytest.raises(hx.ExcelError, match="읽지 못함"):
hx.read_book("x.xls", b"not an excel file")
def _xlsx() -> bytes:
import openpyxl
book = openpyxl.Workbook()
ws = book.active
ws.title = "일위대가"
ws.append([None, "일위대가"])
ws.append([])
ws.append([None, "호 표", "품 명", "규 격", "수 량", "단위", "합 계", None, "노무비", None])
ws.append([None, None, None, None, None, None, "단 가", "금 액", "단 가", "금 액"])
ws.append([None, "제 1 호표", "표지석 ", "A×B", None, "개소", None, 1000, None, 1000])
ws.append([None, None, "터파기", "토사", 0.5, "㎥", 2000, 1000, 2000, 1000, "비고"])
out = io.BytesIO()
book.save(out)
return out.getvalue()
def test_xlsx_길():
got = hx.read_book("a.xlsx", _xlsx())
one = got["호표"][0]
assert (one["번"], one["이름"], one["금액"]["계"]) == (1, "표지석", 1000)
assert one["줄"][0]["수량"] == 0.5 and got["검산"]["어긋남"] == []
# ── 저장 · 길 ──────────────────────────────────────────────────────────
@pytest.fixture()
def client(tmp_path, monkeypatch):
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
app = FastAPI()
app.include_router(labor_router.router)
who = dict(USER)
app.dependency_overrides[verify_session] = lambda: who
got = TestClient(app)
got.who, got.system = who, system
return got
def _post(client, layer="personal", data=None, name=NORTH.name, **params):
files = {"file": (name, data if data is not None else NORTH.read_bytes())}
return client.post(URL.format(layer), files=files, params=params)
def test_올리기_목록_읽기_지우기(client):
before = master_hash()
res = _post(client)
assert res.status_code == 200, res.text
row = res.json()
assert (row["키"], row["파일"], row["호표수"], row["어긋남"]) == (
"HP-000001",
NORTH.name,
68,
0,
)
assert row["올린이"] == {"id": 42, "이름": "갑"}
assert client.get(URL.format("personal")).json()["줄"] == [row]
doc = client.get(URL.format("personal") + "/HP-000001").json()
assert len(doc["호표"]) == 68 and doc["시트"] == "일위대가"
folder = layers.personal_dir(7, 42)
assert (folder / hs.DIRNAME / "HP-000001.json").is_file()
assert all(r["종류"] in layers.KINDS for r in layers.list_templates(folder)) # 양식에 안 섞임
assert not any("실무자료" in str(p) for _, p in layers.layer_files("precombo"))
assert _post(client).json()["키"] == "HP-000002"
assert client.delete(URL.format("personal") + "/HP-000001").status_code == 200
assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000002"]
assert client.get(URL.format("personal") + "/HP-000001").status_code == 404
assert client.get(URL.format("personal") + "/../x").status_code == 404
assert master_hash() == before
def test_엉터리_시트없음_큰_파일_400(client, monkeypatch):
bad = _post(client, data=b"garbage", name="a.xls")
assert bad.status_code == 400 and "읽지 못함" in bad.json()["detail"]
none = _post(client, data=NO_SHEET.read_bytes(), name=NO_SHEET.name)
assert none.status_code == 400 and none.json()["detail"] == "일위대가 시트 없음"
monkeypatch.setattr(labor_router, "HOPYO_MAX_BYTES", 1000)
big = _post(client)
assert big.status_code == 400 and "20 MB" in big.json()["detail"]
assert client.get(URL.format("personal")).json()["줄"] == []
@pytest.mark.parametrize(
("role", "layer", "owner", "want"),
[
({"role": "SYSTEM_ADMIN"}, "system", None, 200),
({"role": "USER"}, "system", None, 403),
({"role": "ADMIN"}, "company", None, 200),
({"role": "USER"}, "company", None, 403),
({"role": "USER"}, "personal", None, 200),
({"role": "USER"}, "personal", "7/43", 403),
({"role": "USER"}, "personal", "8/50", 403),
],
)
def test_역할_표(client, role, layer, owner, want):
client.who.update(role)
params = {"owner": owner} if owner else {}
assert _post(client, layer, **params).status_code == want
if want == 200:
assert client.get(URL.format(layer)).json()["줄"][0]["호표수"] == 68
def test_마스터_층은_마스터_폴더(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
assert (client.system / hs.DIRNAME / "HP-000001.json").is_file()
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))