"""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}