This commit is contained in:
xds
2026-03-22 12:40:33 +03:00
commit 28a5d51389
61 changed files with 6085 additions and 0 deletions

View File

View File

@@ -0,0 +1,76 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.material import Material
from app.schemas.calculate import AdvisorRequest, AdvisorResponse, AdvisorAlternative
from app.services.ai_advisor import get_material_recommendation
logger = logging.getLogger("app.routers.ai_advisor")
router = APIRouter()
@router.post("/advisor", response_model=AdvisorResponse)
async def advisor(request: AdvisorRequest, db: AsyncSession = Depends(get_db)):
logger.info("===== POST /api/advisor =====")
logger.info("Task description: %s", request.task_description)
logger.info("Budget preference: %s", request.budget_preference)
logger.info("File info: %s", request.file_info)
logger.info("Fetching materials from database...")
result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id))
materials = result.scalars().all()
logger.info("Found %d active materials", len(materials))
materials_data = [
{
"id": m.id,
"name": m.name,
"category": m.category,
"density_g_cm3": m.density_g_cm3,
"price_per_gram": m.price_per_gram,
"max_temp_c": m.max_temp_c,
"min_temp_c": m.min_temp_c,
"strength": m.strength,
"flexibility": m.flexibility,
"chemical_resistance": m.chemical_resistance,
"uv_resistance": m.uv_resistance,
"food_safe": m.food_safe,
"description": m.description,
}
for m in materials
]
logger.info("Calling AI advisor service...")
try:
rec = await get_material_recommendation(
task_description=request.task_description,
materials_data=materials_data,
budget_preference=request.budget_preference,
file_info=request.file_info,
)
logger.info("AI advisor returned recommendation: material_id=%s, name=%s",
rec.get("recommended_material_id"), rec.get("recommended_material_name"))
except ValueError as e:
logger.error("AI advisor ValueError: %s", str(e))
raise HTTPException(400, str(e))
except Exception as e:
logger.error("AI advisor unexpected error: %s", str(e), exc_info=True)
raise HTTPException(500, f"Ошибка AI-сервиса: {str(e)}")
response = AdvisorResponse(
recommended_material_id=rec.get("recommended_material_id", 1),
recommended_material_name=rec.get("recommended_material_name", ""),
reasoning=rec.get("reasoning", ""),
alternatives=[
AdvisorAlternative(**alt) for alt in rec.get("alternatives", [])
],
questions=rec.get("questions", []),
)
logger.info("===== /api/advisor complete =====")
return response

View File

@@ -0,0 +1,200 @@
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(""),
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, post_processing='%s'",
material_id, infill_percent, layer_height_mm, quantity, 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,
)
# 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,
),
)

View File

@@ -0,0 +1,52 @@
import logging
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.material import Material
from app.schemas.material import MaterialResponse, MaterialProperties
logger = logging.getLogger("app.routers.materials")
router = APIRouter()
@router.get("/materials", response_model=list[MaterialResponse])
async def get_materials(db: AsyncSession = Depends(get_db)):
logger.info("GET /api/materials")
result = await db.execute(select(Material).where(Material.is_active == True).order_by(Material.id))
materials = result.scalars().all()
logger.info("Found %d active materials", len(materials))
response = [
MaterialResponse(
id=m.id,
name=m.name,
category=m.category,
price_per_gram=m.price_per_gram,
density_g_cm3=m.density_g_cm3,
flow_rate_mm3_s=m.flow_rate_mm3_s,
properties=MaterialProperties(
max_temp_c=m.max_temp_c,
min_temp_c=m.min_temp_c,
strength=m.strength,
flexibility=m.flexibility,
chemical_resistance=m.chemical_resistance,
uv_resistance=m.uv_resistance,
food_safe=m.food_safe,
),
description=m.description,
color_options=m.color_options or [],
)
for m in materials
]
for m in materials:
logger.debug(" Material: id=%d, name=%s, category=%s, price=%.1f RUB/g",
m.id, m.name, m.category, m.price_per_gram)
logger.info("Returning %d materials", len(response))
return response

View File

@@ -0,0 +1,106 @@
import logging
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.calculation import Calculation
from app.models.material import Material
from app.models.order import Order
from app.schemas.order import OrderCreate, OrderResponse
from app.services.telegram_notify import notify_new_order
logger = logging.getLogger("app.routers.orders")
router = APIRouter()
async def generate_order_id(db: AsyncSession) -> str:
year = datetime.now().year
result = await db.execute(
select(func.count(Order.id)).where(Order.order_id.like(f"ORD-{year}-%"))
)
count = result.scalar() or 0
order_id = f"ORD-{year}-{count + 1:04d}"
logger.debug("Generated order_id: %s (existing count: %d)", order_id, count)
return order_id
@router.post("/orders", response_model=OrderResponse)
async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_db)):
logger.info("===== POST /api/orders =====")
logger.info("Client: name=%s, phone=%s, email=%s, company=%s",
order_data.client_name, order_data.client_phone,
order_data.client_email, order_data.client_company)
logger.info("Calculation ID: %s", order_data.calculation_id)
logger.info("Delivery: %s, comment: %s", order_data.delivery_method, order_data.comment)
# Get calculation
try:
calc_uuid = uuid.UUID(order_data.calculation_id)
except ValueError:
logger.warning("Invalid calculation_id format: %s", order_data.calculation_id)
raise HTTPException(400, "Некорректный calculation_id")
logger.info("Looking up calculation: %s", calc_uuid)
result = await db.execute(select(Calculation).where(Calculation.id == calc_uuid))
calc = result.scalar_one_or_none()
if not calc:
logger.warning("Calculation not found: %s", calc_uuid)
raise HTTPException(404, "Расчёт не найден")
logger.info("Calculation found: total=%.2f RUB, material_id=%d, estimated_days=%s",
calc.total_rub, calc.material_id, calc.estimated_days)
# Get material name for notification
logger.debug("Looking up material id=%d for notification...", calc.material_id)
mat_result = await db.execute(select(Material).where(Material.id == calc.material_id))
material = mat_result.scalar_one_or_none()
material_name = material.name if material else "Неизвестный"
logger.info("Material for notification: %s", material_name)
order_id = await generate_order_id(db)
estimated_ready = datetime.now() + timedelta(days=calc.estimated_days or 3)
logger.info("Order ID: %s, estimated ready: %s", order_id, estimated_ready.strftime("%Y-%m-%d"))
order = Order(
order_id=order_id,
calculation_id=calc.id,
client_name=order_data.client_name,
client_phone=order_data.client_phone,
client_email=order_data.client_email,
client_company=order_data.client_company,
delivery_method=order_data.delivery_method,
comment=order_data.comment,
total_rub=calc.total_rub,
)
db.add(order)
await db.commit()
await db.refresh(order)
logger.info("Order saved to database: id=%d, order_id=%s", order.id, order_id)
# Send Telegram notification
logger.info("Sending Telegram notification...")
try:
await notify_new_order(
order_id=order_id,
client_name=order_data.client_name,
client_phone=order_data.client_phone,
material_name=material_name,
total_rub=calc.total_rub,
comment=order_data.comment,
)
logger.info("Telegram notification sent successfully")
except Exception:
logger.exception("Telegram notification failed (order still created)")
logger.info("===== Order created: %s -> %.2f RUB =====", order_id, calc.total_rub)
return OrderResponse(
order_id=order_id,
status="pending",
total_rub=calc.total_rub,
estimated_ready_date=estimated_ready.strftime("%Y-%m-%d"),
)