Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user