Files
Aislo/resources/tester/test_m02_hopyo_excel.py
T
eomsangdonandClaude Opus 5.5 4ff989c125 feat(일위대가실무): 338-4 이음매 — 확인 화면 ↔ 실물 맞춤 응답 · guess 길 목록 · 끝까지 시험 · 이음매 기준 새 뜻
- 길 — guess 몸 대상 list | dict · to = 만들 층
- 줄 맞춤 — 섞인 줄 1위 대상에 몫 · 배(고임돌 GF000211 × 0.1) · 장비 품 비교 열쇠 「+」(화면과 같음) · guess 가 사용자 배 그대로
- 화면 — 품 비교 장비 표시 열쇠를 갈래 묶음 이름으로
- 시험 — client 고정물 맞춤셈 캐시 = 임시 폴더 · 뒤 셈 안 돌림(tmp/m02_hopyo 안 남음 · 결과 흔들림 없음) · 끝까지(북부청 올림 → 셈 → 보기 → 화면 몸 → 만들기 → 장부 → 다시 올림 별칭 · 몸 없음 400 · hopyo-memory 404 · 영월 STmate) · 실물 대역 화면 시험 · 이음매 268 줄 맞음 122 · 다른 점 86 · 후보 60 · 후보 1순위 근거 맞음 123 → 9
- 응답 대역 JSON 실물로 다시 뽑음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:25:51 +09:00

279 lines
13 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
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))