Files
Aislo/B09_Estimation/B09_Estimation_Router_Import.py
T
eomsangdonandClaude Opus 5.5 a5f87812e6 feat(B09 불러오기 화면): 총괄 · 원가계산서 [엑셀에서 요율 불러오기] · 노무 · 중기 · 자재단가대비표 [엑셀에서 단가표 불러오기] · 확인 창 · [적용] = 캐시 (PLAN 707-2 · 707-3)
- 확인 창 = 짝 표 · 우리 식 대조(같음 · 식 다름 · 안 고른 쪽) · 짝 없음 n · 마지막 파일 열쇠 다시 씀 · [다른 파일]
- 단가표 이음 수요 = 화면 단가 목록(GET)과 같은 사본 전부
- test_243_1 B09 표 목록 — 702-1 펼침 표(받기 전부터 실패) · 707 확인 창(보기 전용) 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:06:42 +09:00

145 lines
6.1 KiB
Python

"""B09 경쟁사 결과물 불러오기 길 — 요율(707-2) · 기초 단가표(707-3) · 저장 안 함(제안 → 화면 캐시).
POST /api/projects/{id}/estimation/import/rates form: file | file_key · user?(화면 캐시 JSON)
원가계산서 요율 → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
POST /api/projects/{id}/estimation/import/prices form: file | file_key · user?
노임 · 자재 · 기계 단가표 → 이 공사 단가 책에 이음 →
{file_key, name, counts, linked, missing, patch{labor_cover, machine_cover, materials}}
올린 파일 = 서버 임시 폴더에 내용 지문(`file_key`)으로 한 시간 둠 — 같은 파일을 요율 · 단가표 ·
내역 줄(707-1 · sub5)이 다시 올리지 않고 `file_key` 로 씀(`load` · `remember`).
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈(5장).
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import re
import tempfile
import time
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B09_Estimation import B09_Estimation_Import_Prices as import_prices
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Rates import rate_table
from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError
router = APIRouter()
PATH = "/{project_id}/estimation/import"
KEEP_S = 3600
_KEY = re.compile(r"^[0-9a-f]{24}$")
def _dir() -> Path:
return Path(tempfile.gettempdir()) / "aislo_b09_import"
def load(file_key: str) -> tuple[str, bytes]:
"""지문 → (파일 이름, 내용) — 없거나 지난 것 = 404."""
if not _KEY.match(file_key or ""):
raise HTTPException(400, "file_key 꼴이 아님")
data, name = _dir() / f"{file_key}.bin", _dir() / f"{file_key}.name"
if not data.is_file() or time.time() - data.stat().st_mtime > KEEP_S:
raise HTTPException(404, "올린 파일이 지났음 — 다시 올림")
return name.read_text(encoding="utf-8"), data.read_bytes()
async def remember(upload: UploadFile) -> tuple[str, str, bytes]:
"""올린 파일 → (지문, 이름, 내용) · 한 시간 지난 파일 지움."""
folder = _dir()
folder.mkdir(parents=True, exist_ok=True)
for old in folder.glob("*.bin"):
if time.time() - old.stat().st_mtime > KEEP_S:
old.unlink(missing_ok=True)
old.with_suffix(".name").unlink(missing_ok=True)
temp = folder / f"up-{time.time_ns()}.part"
try:
await save_upload_stream(upload, temp)
except ValueError as error:
raise HTTPException(400, str(error)) from error
data = temp.read_bytes()
key = hashlib.sha256(data).hexdigest()[:24]
temp.replace(folder / f"{key}.bin")
name = store.safe_name(upload.filename or "") or "불러온 파일.xlsx"
(folder / f"{key}.name").write_text(name, encoding="utf-8")
return key, name, data
async def _file(file: UploadFile | None, file_key: str | None) -> tuple[str, str, bytes]:
if file is not None:
return await remember(file)
if file_key:
return (file_key, *load(file_key))
raise HTTPException(400, "file 또는 file_key 가 필요함")
def _draft(user: str | None) -> dict[str, Any] | None:
try:
raw = json.loads(user) if user else None
except ValueError as error:
raise HTTPException(400, "user 가 JSON 이 아님") from error
return store.clean_user(raw) if isinstance(raw, dict) else None
@router.post(PATH + "/rates")
async def import_rate_sheet(
project_id: UUID,
file: UploadFile | None = File(None),
file_key: str | None = Form(None),
user: str | None = Form(None),
) -> dict[str, Any]:
root = await _project_root(project_id)
key, name, data = await _file(file, file_key)
draft = _draft(user)
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
"estimate"
]
try:
read = await asyncio.to_thread(import_rates.read_rates, name, data)
except ExcelError as error:
raise HTTPException(400, str(error)) from error
check = await asyncio.to_thread(import_rates.check, read, mine, rate_table())
estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}}
keep = ("kind", "sheet", "rows", "rates", "notes")
return {"file_key": key, "name": name, **{k: read[k] for k in keep}, "check": check,
"patch": {"estimate": estimate}} # fmt: skip
@router.post(PATH + "/prices")
async def import_price_sheets(
project_id: UUID,
file: UploadFile | None = File(None),
file_key: str | None = Form(None),
user: str | None = Form(None),
) -> dict[str, Any]:
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
root = await _project_root(project_id)
key, name, data = await _file(file, file_key)
draft = _draft(user)
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
try:
read = await asyncio.to_thread(import_prices.read_prices, name, data)
except ExcelError as error:
raise HTTPException(400, str(error)) from error
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
b08 = await main._b08_for(project_id, [], True)
book = await asyncio.to_thread(store.read_book, root)
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
base = compute_base(inputs, {}) # 덮개 뺀 이 공사 단가 책
out = import_prices.propose(read, base, mine)
sheets = {k: read[f"{k}_sheet"] for k in import_prices.KINDS}
return {"file_key": key, "name": name, "sheets": sheets, **out}