init
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
76
backend/app/routers/ai_advisor.py
Normal file
76
backend/app/routers/ai_advisor.py
Normal 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
|
||||
200
backend/app/routers/calculate.py
Normal file
200
backend/app/routers/calculate.py
Normal 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,
|
||||
),
|
||||
)
|
||||
52
backend/app/routers/materials.py
Normal file
52
backend/app/routers/materials.py
Normal 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
|
||||
106
backend/app/routers/orders.py
Normal file
106
backend/app/routers/orders.py
Normal 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"),
|
||||
)
|
||||
Reference in New Issue
Block a user