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

This commit is contained in:
2026-09-14 16:20:52 +09:00
9 changed files with 748 additions and 3 deletions
@@ -0,0 +1,202 @@
"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정).
왜 엑셀인가 — STC 는 호표(B) 구성이 난독화된 BDQTY 에만 있어 못 읽음(7파일 전수) · 경쟁사 보호 장치는
안 풂. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6건 누락 0)가 본 그대로.
⚠ **모양이 다르면 억지로 읽지 않음** — 머리 칸·수량 칸이 어긋나면 「어느 칸이 안 맞는지」 사유로
돌려보내고 그 호표는 통째로 뺌. 추측해서 맞추면 수량이 조용히 틀린 채 라이브러리에 들어가 계속 쓰임.
⚠ 뽑는 것은 **수량만**(판정 Ⓔ) — 원문 단가·금액은 안 실음. 수량도 그 시점 품셈값이라 사유를 붙임.
⚠ 한 단만(판정 Ⓕ) — 구성 줄이 하위 호표면 그 이름·수량만. 하위 전개는 우리 일위대가 몫.
⚠ 원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, BinaryIO
SHEET = "일위대가표"
HEADER_ROW = 3
HEADER = ("명칭", "규격", "수량", "단위")
COLUMNS = "ABCD"
HOPYO = re.compile(r"^제(\d+)호표$")
ROW_CODE = re.compile(r"(?<!BD)COD\|([A-Z]\d{5})")
HOPYO_CODE = re.compile(r"BDCOD\|(B\d{5})")
#: 비고가 이것이면 별도계상 자재 — 자재총괄 자리(판정 ㉮). 그 밖의 비고로는 갈 곳을 안 가름.
SEPARATE_MATERIAL = ("별산자재", "별산M")
#: 착공·변경 내역서 판에만 섞이는 낙찰률 줄 — 설계 조합이 아니라 안 뽑음(판정 ㉰).
#: 실무 6건 전수에서 단위 없는 줄은 이 다섯 꼴뿐: 합계 · 계 · 소계 · 계약단가 · 계 x 낙찰율(88 %).
CONTRACT_ROWS = ("계약단가", "계x낙찰율")
NOTE_SOURCE_QTY = "원문 시점 수량 · 현행 품셈 대조 전"
NOTE_PERCENT = "원문에 가산 행 {count}줄 있었음({names}) — 우리 계산에는 안 듦"
NOTE_CONTRACT = "원문 계약단가·낙찰율 줄 {count}줄은 설계 조합이 아니라 안 뽑음"
def _text(value: Any) -> str:
return "" if value is None else str(value).strip()
def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]:
"""`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만."""
import openpyxl
problems: list[str] = []
try:
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄
return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]}
if SHEET not in book.sheetnames:
return {
"project": "",
"hopyo": [],
"problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"],
}
rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)]
head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14
for column, want, got in zip(COLUMNS, HEADER, head):
if _text(got).replace(" ", "") != want:
problems.append(
f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음"
)
if problems:
return {"project": "", "hopyo": [], "problems": problems}
project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else ""
hopyo: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
def close() -> None:
if current and not current["broken"]:
if current["name"]:
hopyo.append({k: v for k, v in current.items() if k != "broken"})
else:
problems.append(f"{current['no']}호표 제목 줄이 없음 — 안 읽음")
for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1):
name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3])
key = name.replace(" ", "")
found = HOPYO.match(key)
if found:
close()
code = HOPYO_CODE.search(_text(row[13]))
current = {
"no": int(found.group(1)),
"source_code": code.group(1) if code else "",
"name": "",
"spec": "",
"unit": "",
"rows": [],
"basis": [],
"contract_rows": 0,
"broken": False,
}
continue
if key.startswith("합계"):
close()
current = None
continue
if current is None or current["broken"] or not any(_text(c) for c in row[:4]):
continue
where = f"{current['no']}호표"
if not current["name"]:
if not name or qty not in (None, "") or not unit:
problems.append(
f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음"
)
current["broken"] = True
else:
current.update(name=name, spec=spec, unit=unit)
continue
if not unit and (key == "" or key.startswith("소계")):
continue # 착공·변경 판 소계 줄 — 구성 아님
if not unit and key.startswith(CONTRACT_ROWS):
current["contract_rows"] += 1
continue
if not unit and qty == 0 and not isinstance(qty, bool):
current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음
continue
if isinstance(qty, bool) or not isinstance(qty, (int, float)):
problems.append(
f"C{index}: {where}{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음"
)
current["broken"] = True
continue
if not name or not unit:
empty = "명칭" if not name else "단위"
problems.append(
f"{'A' if not name else 'D'}{index}: {where}{name}{empty}이 빔 — 이 모양은 못 읽음"
)
current["broken"] = True
continue
code = ROW_CODE.search(_text(row[13]))
current["rows"].append(
{
"name": name,
"spec": spec,
"amount": qty,
"unit": unit,
"remark": _text(row[12]),
"source_code": code.group(1) if code else "",
}
)
close()
return {"project": project, "hopyo": hopyo, "problems": problems}
def recipe_item(
hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str
) -> dict[str, Any]:
"""읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급."""
basis = " · ".join(hopyo.get("basis") or [])
rows = []
percent = []
for seq, source in enumerate(hopyo["rows"], start=1):
is_percent = source["unit"] == "%"
if is_percent:
percent.append(source["name"])
separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL)
rows.append(
{
"seq": seq,
"name": source["name"],
"spec": source["spec"],
"formula": "",
"formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part),
"amount": str(source["amount"]),
"unit": source["unit"],
# 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮)
"destination": "reference"
if is_percent
else "material"
if separate
else "unit_price",
"rounding": {"mode": "none", "digits": 0},
"source": "library",
"reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY,
}
)
notes = [NOTE_SOURCE_QTY]
if percent:
notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent)))
if hopyo.get("contract_rows"):
notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"]))
return {
"schema_version": 1,
"item_kind": "fixed",
"type_id": type_id,
"name": f"{hopyo['name']} {hopyo['spec']}".strip(),
"unit": hopyo["unit"],
"note": " · ".join(notes),
"vars": {},
"tables": {},
"rows": rows,
# 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장).
"origin": {
"kind": "stmate_xlsx",
"file": file_name,
"project": project,
"hopyo_no": hopyo["no"],
"hopyo_code": hopyo["source_code"],
},
}
@@ -127,6 +127,19 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def _personal_code(folder: Path, type_id: Any) -> str:
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
same = [item for item in _items(folder) if item.get("type_id") == type_id]
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
def save_item_personal(folder: Path, item: dict[str, Any]) -> str:
"""이미 만든 항목(STmate 에서 뽑은 고정형 등)을 개인 단에 씀. 코드."""
code = _personal_code(folder, item.get("type_id"))
_write(folder, {**item, "code": code, "library_tier": "personal"})
return code
def save_personal(
folder: Path,
template: dict[str, Any],
@@ -141,9 +154,7 @@ def save_personal(
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
type_id = template.get("type_id")
same = [item for item in _items(folder) if item.get("type_id") == type_id]
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
code = _personal_code(folder, template.get("type_id"))
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
@@ -0,0 +1,112 @@
"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정).
두 걸음:
① [읽기] 파일을 올리면 읽은 호표 수·구성 줄 수·못 읽은 사유를 돌려줌 — **아무것도 안 씀**.
사용자가 수를 보고 넣을지 정함(판정 「읽은 뒤 수를 보이고 묻는 걸음」).
② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀.
⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은
덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
"""
import asyncio
import io
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from common_util.common_util_auth import verify_session
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
BASE = "/{project_id}/quantity/structure-sheets/library/stmate"
#: 출력 엑셀은 수백 KB — 넉넉히 두되 끝없이 받지 않음.
MAX_BYTES = 20 * 1024 * 1024
def _error(status: int, message: str, **extra: Any) -> JSONResponse:
return JSONResponse(
status_code=status, content={"status": "error", "message": message, **extra}
)
async def _read(file: UploadFile) -> dict[str, Any] | JSONResponse:
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
return _error(413, "파일이 너무 큼(20MB 넘음)")
return await asyncio.to_thread(read_recipes, io.BytesIO(data))
@router.post(BASE + "/read")
async def read_stmate_recipes(
project_id: UUID,
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""① 읽기만 — 호표 목록(차례·명칭·규격·단위·구성 줄 수)과 못 읽은 사유."""
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = [
{
"no": h["no"],
"name": h["name"],
"spec": h["spec"],
"unit": h["unit"],
"rows": len(h["rows"]),
"percent_rows": sum(1 for row in h["rows"] if row["unit"] == "%"),
"contract_rows": h["contract_rows"],
}
for h in read["hopyo"]
]
return JSONResponse(
content={
"status": "success",
"file_name": file.filename or "",
"project": read["project"],
"hopyo": hopyo,
"counts": {"hopyo": len(hopyo), "rows": sum(h["rows"] for h in hopyo)},
"problems": read["problems"],
}
)
@router.post(BASE + "/save")
async def save_stmate_recipe(
project_id: UUID,
file: UploadFile = File(...),
hopyo_no: int = Form(...),
type_id: str = Form(..., min_length=1, max_length=100),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""② 넣기 — 서버가 다시 읽은 호표 하나를 고정형 항목으로 **로그인한 사람 개인 단**에 씀."""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import save_item_personal, tier_dirs
folder = tier_dirs(session.get("company_id"), session.get("user_id")).get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
if type_id not in structure_type_map():
return _error(400, f"모르는 구조물 종류: {type_id}")
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = next((h for h in read["hopyo"] if h["no"] == hopyo_no), None)
if hopyo is None:
return _error(404, f"{hopyo_no}호표를 읽지 못함", problems=read["problems"])
item = recipe_item(
hopyo, type_id=type_id, file_name=file.filename or "", project=read["project"]
)
code = await asyncio.to_thread(save_item_personal, folder, item)
return JSONResponse(
content={
"status": "success",
"code": code,
"name": item["name"],
"rows": len(item["rows"]),
"note": item["note"],
}
)
@@ -8,6 +8,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
@@ -191,5 +192,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
mine.append(save, remove);
panel.append(title, scope, load, list, take, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
projectId,
typeId,
onSaved: () => {
if (!list.hidden) load.click();
},
}),
);
return panel;
}
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Stmate.ts
* 라이브러리 칸 — [STmate 엑셀에서 뽑기] (PLAN 4장 · 2026-09-14 브레인 판정).
*
* 두 걸음: [읽기] → 호표 수·구성 줄 수·못 읽은 사유를 보임(아무것도 안 씀) → 호표를 고르고
* [내 라이브러리에 넣기] → 수를 다시 묻고 **파일을 다시 보냄**(서버가 다시 읽어 씀 — 화면은 줄을 안 보냄).
* ⚠ 종류는 지금 보고 있는 장의 종류 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
* ⚠ 모양은 옆 제원 칸(`b08-spec*`) 클래스를 그대로 씀.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
interface ReadHopyo {
no: number;
name: string;
spec: string;
unit: string;
rows: number;
percent_rows: number;
contract_rows: number;
}
interface ReadResult {
project: string;
hopyo: ReadHopyo[];
counts: { hopyo: number; rows: number };
problems: string[];
}
async function postForm<T>(url: string, form: FormData): Promise<T> {
const response = await fetch(url, { method: "POST", credentials: "include", body: form });
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function buildStmatePanel(options: {
projectId: string;
typeId: string;
/** 넣은 뒤 — 목록을 다시 받게 함. */
onSaved: () => void;
}): HTMLElement {
const { projectId, typeId, onSaved } = options;
const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`;
const box = document.createElement("div");
box.className = "b08-sheet__actions";
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.className = "b08-spec__input";
input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)";
const read = document.createElement("button");
read.type = "button";
read.className = "b08-quantity__tab";
read.textContent = "STmate 엑셀 읽기";
const list = document.createElement("select");
list.className = "b08-spec__input";
list.hidden = true;
const put = document.createElement("button");
put.type = "button";
put.className = "b08-spec__save";
put.textContent = "내 라이브러리에 넣기";
put.hidden = true;
const status = document.createElement("p");
status.className = "b08-spec__scope";
let result: ReadResult | null = null;
input.addEventListener("change", () => {
result = null;
list.hidden = put.hidden = true;
status.textContent = "";
});
read.addEventListener("click", () => {
const file = input.files?.[0];
if (!file) {
status.textContent = "엑셀 파일을 먼저 고르세요";
return;
}
void (async () => {
read.disabled = true;
status.textContent = "읽는 중…";
try {
const form = new FormData();
form.append("file", file);
result = await postForm<ReadResult>(`${base}/stmate/read`, form);
list.replaceChildren(
...result.hopyo.map((h) => {
const option = document.createElement("option");
option.value = String(h.no);
const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : "";
option.textContent = `${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}${extra}`;
return option;
}),
);
list.hidden = put.hidden = result.hopyo.length === 0;
const missed = result.problems.length
? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}`
: "";
status.textContent = `${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "읽지 못함";
} finally {
read.disabled = false;
}
})();
});
put.addEventListener("click", () => {
const file = input.files?.[0];
const picked = result?.hopyo.find((h) => String(h.no) === list.value);
if (!file || !result || !picked) return;
const question =
`읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}\n` +
`넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` +
"· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴";
if (!window.confirm(question)) return;
void (async () => {
put.disabled = true;
status.textContent = "넣는 중…";
try {
const form = new FormData();
form.append("file", file);
form.append("hopyo_no", String(picked.no));
form.append("type_id", typeId);
const saved = await postForm<{ code: string; name: string; note: string }>(
`${base}/stmate/save`,
form,
);
status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`;
onSaved();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "넣지 못함";
} finally {
put.disabled = false;
}
})();
});
const wrap = document.createElement("div");
box.append(read, put);
wrap.append(input, box, list, status);
return wrap;
}
+2
View File
@@ -64,6 +64,7 @@ from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
from B08_Quantity.B08_Quantity_Router_StmateLibrary import router as b08_stmate_library_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router
from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router
@@ -644,6 +645,7 @@ app.include_router(b08_quantity_router, dependencies=protected_with_company)
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
app.include_router(b08_structure_sheet_router, dependencies=protected_with_company)
app.include_router(b08_stmate_library_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
app.include_router(b09_contract_router, dependencies=protected_with_company)
@@ -0,0 +1,108 @@
"""라이브러리 — STmate 출력 엑셀에서 고정형 항목 뽑아 넣기 창구(PLAN 4장 · 2026-09-14 브레인 판정).
두 걸음: [읽기]는 호표 수·구성 줄 수·못 읽은 사유만 돌려주고 아무것도 안 씀 → [넣기]는 같은 파일을
서버가 **다시 읽어** 고른 호표를 개인 단에 씀(브라우저가 보낸 줄을 받아 적지 않음 · 개인 단만 · 판정 Ⓗ).
"""
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
import openpyxl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402
from common_util.common_util_auth import verify_session # noqa: E402
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
BASE = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets/library/stmate"
def _xlsx() -> bytes:
book = openpyxl.Workbook()
ws = book.active
ws.title = "일위대가표"
for row in [
["일 위 대 가 표"],
["공사명 : 시험 공사"],
["명 칭", "규 격", "수 량", "단위"],
[None],
[" 제 1 호표"],
["돌기슭막이(메쌓기)", "H=2.0", None, "m"],
["메쌓기", "L3=55cm이하", 2.09, "m2"],
["고임돌채집", "기계", 0.31, "m3"],
["합 계"],
[" 제 2 호표"],
["규준틀설치", "종단", None, "개소"],
["각재", "외송", "약간", "M3"],
["합 계"],
]:
ws.append(row)
buffer = io.BytesIO()
book.save(buffer)
return buffer.getvalue()
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
return TestClient(app)
def _files() -> dict:
return {"file": ("견본.xlsx", _xlsx(), "application/octet-stream")}
def test_읽기는_수와_사유만_주고_안_쓴다(client: TestClient, tmp_path: Path) -> None:
body = client.post(f"{BASE}/read", files=_files()).json()
assert body["counts"] == {"hopyo": 1, "rows": 2}
assert body["hopyo"][0]["name"] == "돌기슭막이(메쌓기)" and body["hopyo"][0]["rows"] == 2
assert any("C12" in p for p in body["problems"]) # 수량이 수가 아닌 2호표는 사유로
assert not (tmp_path / "storage").exists()
def test_넣기는_서버가_다시_읽어_개인_단에_고정형으로(client: TestClient, tmp_path: Path) -> None:
response = client.post(
f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"}
)
assert response.status_code == 200, response.text
code = response.json()["code"]
saved = json.loads(
(tmp_path / "storage" / "7" / "42" / "library" / f"{code}.json").read_text(encoding="utf-8")
)
assert saved["library_tier"] == "personal" and saved["origin"]["project"] == "시험 공사"
assert [r["amount"] for r in saved["rows"]] == ["2.09", "0.31"]
dirs = library_module.tier_dirs(7, 42)
assert [i["kind"] for i in library_module.list_items(dirs, "masonry_dry")] == ["fixed"]
def test_회사_없으면_403_없는_호표는_404_모르는_종류는_400(client: TestClient) -> None:
bad = client.post(
f"{BASE}/save", files=_files(), data={"hopyo_no": "2", "type_id": "masonry_dry"}
)
assert bad.status_code == 404 # 못 읽은 호표는 넣을 수 없음
unknown = client.post(f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "없음"})
assert unknown.status_code == 400
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
denied = client.post(
f"{BASE}/save", files=_files(), data={"hopyo_no": "1", "type_id": "masonry_dry"}
)
assert denied.status_code == 403
def test_앱에_창구가_걸린다() -> None:
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "B08_Quantity_Router_StmateLibrary" in main and "b08_stmate_library_router" in main
@@ -0,0 +1,26 @@
"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정).
⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 ·
넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts"
PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None:
assert "buildStmatePanel(" in PANEL
def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None:
ui = UI.read_text(encoding="utf-8")
assert "/stmate/read" in ui and "/stmate/save" in ui
assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui
assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui
assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음
assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음
@@ -0,0 +1,129 @@
"""STmate 출력 엑셀 「일위대가표」 읽개 — 고정형 라이브러리 항목의 둘째 길(PLAN 4장 · 2026-09-14 브레인 판정 ①).
엑셀인가 STC 호표(B) 구성이 난독화된 BDQTY 에만 있어 읽음(7파일 전수 확인) ·
경쟁사 보호 장치는 풀지 않음. 출력 엑셀은 평문이고 코덱스 `recipe_extract.py` 실무 6 누락 0 으로 뽑은 모양.
**모양이 다르면 억지로 읽지 않음** 어느 칸이 맞는지 사유로 돌려보냄(추측해서 맞추면
수량이 조용히 틀린 라이브러리에 들어가 계속 쓰임).
"""
from __future__ import annotations
import sys
from pathlib import Path
import openpyxl
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes # noqa: E402
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
BONGHWA = next(
(p for p in PRACTICE.rglob("*.xlsx") if "기번41" in p.name and not p.name.startswith("~$")),
None,
)
def _book(tmp_path: Path, rows: list[list[object]], sheet: str = "일위대가표") -> Path:
book = openpyxl.Workbook()
ws = book.active
ws.title = sheet
for row in rows:
ws.append(row)
path = tmp_path / "book.xlsx"
book.save(path)
return path
HEAD = [
["일 위 대 가 표"],
["공사명 : 시험"],
["명 칭", "규 격", "수 량", "단위", "합 계"],
[None],
]
@pytest.mark.skipif(BONGHWA is None, reason="실무 엑셀(지식DB 원문) 없음")
def test_봉화_호표_45_기슭막이_H2_구성_9줄() -> None:
read = read_recipes(BONGHWA)
assert read["problems"] == []
assert len(read["hopyo"]) == 45
target = next(
h
for h in read["hopyo"]
if h["name"] == "기슭막이(깬잡석,찰쌓기 L3=45m)" and h["spec"] == "H=2.0m, 채집"
)
assert target["unit"] == "M" and target["source_code"] == "B00010"
rows = target["rows"]
assert len(rows) == 9
assert (rows[0]["name"], rows[0]["spec"], rows[0]["amount"], rows[0]["unit"]) == (
"깬잡석채집",
"L=45cm 내외",
2.09,
"M2",
)
assert rows[1]["source_code"] == "B00004" # 하위 호표 참조(한 단만 — 판정 Ⓕ)
assert rows[-1]["name"] == "콘크리트믹서사용" and rows[-1]["amount"] == 0.42
def test_머리_칸이_다르면_안_읽고_칸을_짚는다(tmp_path: Path) -> None:
head = [row[:] for row in HEAD]
head[2][2] = "수량합"
read = read_recipes(_book(tmp_path, [*head, [" 제 1 호표"], ["", "H=1", None, "m"]]))
assert read["hopyo"] == []
assert any("C3" in p and "수량" in p for p in read["problems"])
def test_수량이_수가_아니면_그_호표를_안_읽는다(tmp_path: Path) -> None:
rows = [
*HEAD,
[" 제 1 호표"],
["", "H=1", None, "m"],
["", "", "약 2", ""],
["합 계"],
[" 제 2 호표"],
["", "H=2", None, "m"],
["", "", 3, ""],
["합 계"],
]
read = read_recipes(_book(tmp_path, rows))
assert [h["name"] for h in read["hopyo"]] == [""] # 틀린 호표는 통째로 뺌
assert any("C7" in p for p in read["problems"])
def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: Path) -> None:
"""판정 ㉮㉯㉰ — 별산은 자재총괄 · % 가산 행은 참고 + 항목 사유 · 계약단가(낙찰률)는 안 뽑음 · 근거 줄 보존."""
rows = [
*HEAD,
[" 제 1 호표"],
["기슭막이", "H=2.0", None, "m"],
["건설표준품셈", "7-1-1(메쌓기)", 0, None],
["파쇄암", "별도계상", 2.09, "", *[0] * 8, "별산자재 25 "],
["메쌓기", "L3=55cm이하", 2.09, "m2", *[0] * 8, "대가 11호표"],
["공구손료", "노무비의 %", 2, "%"],
[""],
["계약단가", None, 88.5],
]
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
read = read_recipes(_book(tmp_path, rows))
assert read["problems"] == [] and read["project"] == "시험"
item = recipe_item(read["hopyo"][0], type_id="masonry_dry", file_name="a.xlsx", project="시험")
assert [(r["name"], r["destination"]) for r in item["rows"]] == [
("파쇄암", "material"),
("메쌓기", "unit_price"),
("공구손료", "reference"),
]
assert all(r["formula"] == "" and "7-1-1" in r["formula_text"] for r in item["rows"])
assert item["rows"][1]["amount"] == "2.09"
assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"]
assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"]
assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험"
def test_시트가_없으면_사유(tmp_path: Path) -> None:
read = read_recipes(_book(tmp_path, HEAD, sheet="다른표"))
assert read["hopyo"] == [] and any("일위대가표" in p for p in read["problems"])