"""일위대가 실무자료 읽기 · 저장 · 올리기 길(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 import shutil 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_Fit as fit from M02_MasterTemplete import M02_Hopyo_Ours as ours 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["검산"]["어긋남"] == [] # ── 저장 · 길 ────────────────────────────────────────────────────────── def calc_cache(tmp_path, monkeypatch) -> Path: """맞춤셈(338) — 캐시 = 임시 폴더(우리 지문 사본 · 시험 끝에 같이 지움) · 뒤 셈 안 돌림 (`ensure` = 상태만) — 숫자 판정이 필요한 시험은 `fit.run_job` 을 바로 부름.""" if not ours.ready(): ours.build() cache = tmp_path / "hopyo_cache" cache.mkdir(exist_ok=True) shutil.copy(ours.path_of(), cache / ours.path_of().name) monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache)) monkeypatch.setattr(fit, "ensure", fit.status) return cache @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")) calc_cache(tmp_path, monkeypatch) 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))