204 lines
8.0 KiB
Python
204 lines
8.0 KiB
Python
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 10 <= 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,
|
||
),
|
||
)
|