Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-15 23:29:39 +09:00
8 changed files with 607 additions and 16 deletions
+17
View File
@@ -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["글"])
+53 -1
View File
@@ -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))
+20
View File
@@ -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())
+185
View File
@@ -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