- M02_Hopyo_Excel: xls(xlrd · logfile 막음) · xlsx(openpyxl) · 머리 글로 시트 · 열 찾기 · 제N호표 · 구성 줄 · 갈래(단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타) · 단산N/일위N 참조 · 검산(줄 금액 floor · 합계 단가 · % 줄 · 호표 비목 · 총괄표) · 규격 원문 보관 · 시트 없음 = 「일위대가 시트 없음」
- M02_Hopyo_Store: 층 폴더 실무자료/HP-######.json · 맞춤 기억 실무자료/맞춤.json · 잠금 + 원자 쓰기 · 양식 종류 밖(목록 · 복사 · 프로젝트 씨앗에 안 섞임)
- M02_Labor_Router: GET/POST /layers/{층}/hopyo · GET/DELETE /layers/{층}/hopyo/{키} · 20 MB · 엉터리 · 시트 없음 400 · 권한 = 층 읽기 · 쓰기 규칙
- requirements: xlrd==2.0.2
- 시험 test_m02_hopyo_excel 15 통과(북부청 68 · 사방 27 · 검산 0 · 역할 표 · sha256)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
210 lines
9.4 KiB
Python
210 lines
9.4 KiB
Python
"""일위대가 실무자료 읽기 · 저장 · 올리기 길(PLAN 324-1 · `M02_Hopyo_Excel` · `M02_Hopyo_Store`).
|
||
|
||
견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) ·
|
||
검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 ·
|
||
양식 목록에 안 섞임 ·
|
||
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
|