import logging import os import tempfile import uuid from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.models.material import Material from app.models.calculation import Calculation from app.schemas.calculate import ( BoundingBox, CalculateResponse, CalculationResult, FileInfoResponse, MaterialInfo, ) from app.services.file_parser import FileInfo, parse_3d_file, SUPPORTED_EXTENSIONS from app.services.price_engine import calculate_price from app.services.storage import upload_file, delete_file logger = logging.getLogger("app.routers.calculate") router = APIRouter() MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB @router.post("/calculate", response_model=CalculateResponse) async def calculate( file: UploadFile = File(...), material_id: int = Form(...), infill_percent: int = Form(30), layer_height_mm: float = Form(0.2), quantity: int = Form(1), post_processing: str = Form(""), color: str = Form(""), multicolor: bool = Form(False), db: AsyncSession = Depends(get_db), ): logger.info("===== /api/calculate request =====") # Validate extension filename = file.filename or "unknown" ext = os.path.splitext(filename)[1].lower() logger.info("File: name=%s, ext=%s, content_type=%s", filename, ext, file.content_type) if ext not in SUPPORTED_EXTENSIONS: logger.warning("Unsupported file extension: %s (allowed: %s)", ext, SUPPORTED_EXTENSIONS) raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}") # Validate params logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, color='%s', multicolor=%s, post_processing='%s'", material_id, infill_percent, layer_height_mm, quantity, color, multicolor, post_processing) if not 5 <= infill_percent <= 100: logger.warning("Invalid infill_percent: %d", infill_percent) raise HTTPException(400, "infill_percent должен быть от 10 до 100") if not 0.08 <= layer_height_mm <= 0.4: logger.warning("Invalid layer_height_mm: %.2f", layer_height_mm) raise HTTPException(400, "layer_height_mm должен быть от 0.08 до 0.4") if not 1 <= quantity <= 500: logger.warning("Invalid quantity: %d", quantity) raise HTTPException(400, "quantity должен быть от 1 до 500") # Read file logger.info("Reading uploaded file...") content = await file.read() file_size = len(content) logger.info("File read: %d bytes (%.2f MB)", file_size, file_size / 1024 / 1024) if file_size > MAX_FILE_SIZE: logger.warning("File too large: %d bytes (max %d)", file_size, MAX_FILE_SIZE) raise HTTPException(413, "Файл слишком большой (максимум 50 MB)") # Save to temp file for parsing, then upload to MinIO file_id = str(uuid.uuid4()) object_name = f"uploads/{file_id}{ext}" tmp_path = None logger.info("Generated file_id: %s, object_name: %s", file_id, object_name) try: logger.debug("Writing to temp file for parsing...") with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: tmp.write(content) tmp_path = tmp.name logger.debug("Temp file created: %s", tmp_path) logger.info("Parsing 3D file...") file_info = parse_3d_file(tmp_path, ext) logger.info("File parsed successfully: volume=%.2f cm3, triangles=%d, watertight=%s", file_info.volume_cm3, file_info.triangle_count, file_info.is_watertight) except Exception as e: logger.error("File parsing failed: %s", str(e), exc_info=True) raise HTTPException(400, f"Ошибка парсинга файла: {str(e)}") finally: if tmp_path and os.path.exists(tmp_path): os.remove(tmp_path) logger.debug("Temp file removed: %s", tmp_path) # Upload to MinIO try: logger.info("Uploading to MinIO: %s", object_name) upload_file(object_name, content) logger.info("MinIO upload complete") except Exception as e: logger.error("MinIO upload failed: %s", str(e), exc_info=True) raise HTTPException(500, f"Ошибка загрузки файла в хранилище: {str(e)}") # Get material logger.info("Looking up material id=%d...", material_id) result = await db.execute(select(Material).where(Material.id == material_id)) material = result.scalar_one_or_none() if not material: logger.warning("Material not found: id=%d", material_id) raise HTTPException(400, f"Материал с id={material_id} не найден") logger.info("Material found: id=%d, name=%s, density=%.2f, price=%.1f RUB/g", material.id, material.name, material.density_g_cm3, material.price_per_gram) # Parse post_processing pp_list = [p.strip() for p in post_processing.split(",") if p.strip()] if post_processing else [] logger.info("Post-processing options: %s", pp_list) # Calculate price logger.info("Calculating price...") price = calculate_price( file_info=file_info, density_g_cm3=material.density_g_cm3, price_per_gram=material.price_per_gram, flow_rate_mm3_s=material.flow_rate_mm3_s, infill_percent=infill_percent, layer_height_mm=layer_height_mm, quantity=quantity, post_processing=pp_list, multicolor=multicolor, ) # Save calculation to DB logger.info("Saving calculation to database...") calc = Calculation( file_name=filename, file_format=ext.lstrip("."), file_path=object_name, volume_cm3=file_info.volume_cm3, surface_area_cm2=file_info.surface_area_cm2, bounding_box=file_info.bounding_box_mm, is_watertight=file_info.is_watertight, triangle_count=file_info.triangle_count, material_id=material.id, infill_percent=infill_percent, layer_height_mm=layer_height_mm, quantity=quantity, post_processing=pp_list, weight_grams=price.weight_grams, material_cost_rub=price.material_cost_rub, time_cost_rub=price.time_cost_rub, print_time_hours=price.print_time_hours, post_processing_cost_rub=price.post_processing_cost_rub, total_rub=price.total_rub, estimated_days=price.estimated_days, ) db.add(calc) await db.commit() await db.refresh(calc) logger.info("Calculation saved: id=%s, total=%.2f RUB", calc.id, price.total_rub) logger.info("===== /api/calculate complete: %s -> %.2f RUB =====", filename, price.total_rub) return CalculateResponse( success=True, calculation_id=str(calc.id), file_info=FileInfoResponse( filename=filename, format=ext.lstrip("."), volume_cm3=round(file_info.volume_cm3, 2), surface_area_cm2=round(file_info.surface_area_cm2, 2), bounding_box_mm=BoundingBox(**file_info.bounding_box_mm), is_watertight=file_info.is_watertight, triangle_count=file_info.triangle_count, ), calculation=CalculationResult( material=MaterialInfo( id=material.id, name=material.name, density_g_cm3=material.density_g_cm3, price_per_gram=material.price_per_gram, ), weight_grams=price.weight_grams, material_cost_rub=price.material_cost_rub, print_time_hours=price.print_time_hours, time_cost_rub=price.time_cost_rub, post_processing_cost_rub=price.post_processing_cost_rub, subtotal_rub=price.subtotal_rub, quantity=price.quantity, quantity_discount_percent=price.quantity_discount_percent, total_rub=price.total_rub, estimated_days=price.estimated_days, ), )