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

0
backend/app/__init__.py Normal file
View File

21
backend/app/config.py Normal file
View File

@@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d"
GOOGLE_API_KEY: str = ""
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
UPLOAD_DIR: str = "/app/uploads"
MAX_FILE_SIZE_MB: int = 50
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "filam3d"
MINIO_SECURE: bool = False
model_config = {"env_file": ["../.env", ".env"]}
settings = Settings()

24
backend/app/database.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
logger = logging.getLogger("app.database")
logger.info("Initializing database engine: %s", settings.DATABASE_URL.split("@")[-1]) # log host only, not password
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
logger.debug("Opening database session")
async with async_session() as session:
yield session
logger.debug("Database session closed")

90
backend/app/main.py Normal file
View File

@@ -0,0 +1,90 @@
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.database import async_session, engine, Base
from app.models import Material
from app.seed.materials import MATERIALS
from app.routers import calculate, materials, orders, ai_advisor
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger("app.main")
# Reduce noise from third-party libs
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.INFO)
logging.getLogger("httpcore").setLevel(logging.INFO)
logging.getLogger("trimesh").setLevel(logging.WARNING)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("=== Application startup ===")
logger.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created successfully")
logger.info("Checking seed data...")
async with async_session() as session:
result = await session.execute(select(Material).limit(1))
if result.scalar_one_or_none() is None:
logger.info("No materials found, seeding %d materials...", len(MATERIALS))
for mat_data in MATERIALS:
session.add(Material(**mat_data))
logger.debug(" Seeded material: %s", mat_data["name"])
await session.commit()
logger.info("Materials seeded successfully")
else:
logger.info("Materials already exist, skipping seed")
logger.info("=== Application ready ===")
yield
logger.info("=== Application shutdown ===")
app = FastAPI(title="3D Print Calculator", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
logger.info("--> %s %s (client: %s)", request.method, request.url.path, request.client.host if request.client else "unknown")
logger.debug(" Headers: %s", dict(request.headers))
logger.debug(" Query params: %s", dict(request.query_params))
response = await call_next(request)
logger.info("<-- %s %s -> %d", request.method, request.url.path, response.status_code)
return response
app.include_router(calculate.router, prefix="/api")
app.include_router(materials.router, prefix="/api")
app.include_router(orders.router, prefix="/api")
app.include_router(ai_advisor.router, prefix="/api")
@app.get("/api/health")
async def health():
logger.debug("Health check requested")
return {"status": "ok"}

View File

@@ -0,0 +1,5 @@
from app.models.material import Material
from app.models.calculation import Calculation
from app.models.order import Order
__all__ = ["Material", "Calculation", "Order"]

View File

@@ -0,0 +1,34 @@
import uuid
from sqlalchemy import Float, Integer, String, Boolean, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Calculation(Base):
__tablename__ = "calculations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_format: Mapped[str] = mapped_column(String(10), nullable=False)
file_path: Mapped[str | None] = mapped_column(String(500))
volume_cm3: Mapped[float] = mapped_column(Float, nullable=False)
surface_area_cm2: Mapped[float | None] = mapped_column(Float)
bounding_box: Mapped[dict | None] = mapped_column(JSONB)
is_watertight: Mapped[bool | None] = mapped_column(Boolean)
triangle_count: Mapped[int | None] = mapped_column(Integer)
material_id: Mapped[int] = mapped_column(Integer, ForeignKey("materials.id"), nullable=False)
infill_percent: Mapped[int] = mapped_column(Integer, default=30)
layer_height_mm: Mapped[float] = mapped_column(Float, default=0.2)
quantity: Mapped[int] = mapped_column(Integer, default=1)
post_processing: Mapped[dict] = mapped_column(JSONB, default=list)
weight_grams: Mapped[float | None] = mapped_column(Float)
material_cost_rub: Mapped[float | None] = mapped_column(Float)
time_cost_rub: Mapped[float | None] = mapped_column(Float)
print_time_hours: Mapped[float | None] = mapped_column(Float)
post_processing_cost_rub: Mapped[float | None] = mapped_column(Float)
total_rub: Mapped[float | None] = mapped_column(Float)
estimated_days: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(default=func.now())

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Boolean, Float, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Material(Base):
__tablename__ = "materials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
density_g_cm3: Mapped[float] = mapped_column(Float, nullable=False)
price_per_gram: Mapped[float] = mapped_column(Float, nullable=False)
flow_rate_mm3_s: Mapped[float] = mapped_column(Float, nullable=False)
max_temp_c: Mapped[int | None] = mapped_column(Integer)
min_temp_c: Mapped[int | None] = mapped_column(Integer)
strength: Mapped[str | None] = mapped_column(String(20))
flexibility: Mapped[str | None] = mapped_column(String(20))
chemical_resistance: Mapped[str | None] = mapped_column(String(20))
uv_resistance: Mapped[str | None] = mapped_column(String(20))
food_safe: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str | None] = mapped_column(Text)
color_options: Mapped[dict] = mapped_column(JSONB, default=list)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())

View File

@@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Float, Integer, String, Text, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
calculation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("calculations.id"), nullable=False)
client_name: Mapped[str] = mapped_column(String(200), nullable=False)
client_phone: Mapped[str] = mapped_column(String(20), nullable=False)
client_email: Mapped[str | None] = mapped_column(String(200))
client_company: Mapped[str | None] = mapped_column(String(200))
delivery_method: Mapped[str] = mapped_column(String(50), default="pickup")
comment: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(30), default="pending")
total_rub: Mapped[float] = mapped_column(Float, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

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"),
)

View File

View File

@@ -0,0 +1,65 @@
from pydantic import BaseModel, Field
class BoundingBox(BaseModel):
x: float
y: float
z: float
class FileInfoResponse(BaseModel):
filename: str
format: str
volume_cm3: float
surface_area_cm2: float
bounding_box_mm: BoundingBox
is_watertight: bool
triangle_count: int
class MaterialInfo(BaseModel):
id: int
name: str
density_g_cm3: float
price_per_gram: float
class CalculationResult(BaseModel):
material: MaterialInfo
weight_grams: float
material_cost_rub: float
print_time_hours: float
time_cost_rub: float
post_processing_cost_rub: float
subtotal_rub: float
quantity: int
quantity_discount_percent: int
total_rub: float
estimated_days: int
class CalculateResponse(BaseModel):
success: bool = True
calculation_id: str
file_info: FileInfoResponse
calculation: CalculationResult
class AdvisorRequest(BaseModel):
task_description: str
budget_preference: str = "optimal"
file_info: dict | None = None
class AdvisorAlternative(BaseModel):
material_id: int
name: str
why: str
class AdvisorResponse(BaseModel):
recommended_material_id: int
recommended_material_name: str
reasoning: str
alternatives: list[AdvisorAlternative] = []
questions: list[str] = []

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
class MaterialProperties(BaseModel):
max_temp_c: int | None = None
min_temp_c: int | None = None
strength: str | None = None
flexibility: str | None = None
chemical_resistance: str | None = None
uv_resistance: str | None = None
food_safe: bool = False
class MaterialResponse(BaseModel):
id: int
name: str
category: str
price_per_gram: float
density_g_cm3: float
flow_rate_mm3_s: float
properties: MaterialProperties
description: str | None = None
color_options: list[str] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
calculation_id: str
client_name: str
client_phone: str = Field(pattern=r"^\+?\d{10,15}$")
client_email: str | None = None
client_company: str | None = None
delivery_method: str = "pickup"
comment: str | None = None
class OrderResponse(BaseModel):
order_id: str
status: str
total_rub: float
estimated_ready_date: str

View File

View File

@@ -0,0 +1,114 @@
MATERIALS = [
{
"name": "PLA",
"category": "basic",
"density_g_cm3": 1.24,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 15.0,
"max_temp_c": 60,
"min_temp_c": -20,
"strength": "medium",
"flexibility": "low",
"chemical_resistance": "low",
"uv_resistance": "low",
"food_safe": True,
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.",
"color_options": ["white", "black", "gray", "red", "blue", "green", "natural"],
},
{
"name": "PETG",
"category": "basic",
"density_g_cm3": 1.27,
"price_per_gram": 28.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 80,
"min_temp_c": -40,
"strength": "high",
"flexibility": "medium",
"chemical_resistance": "medium",
"uv_resistance": "medium",
"food_safe": True,
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.",
"color_options": ["white", "black", "gray", "natural", "blue"],
},
{
"name": "ABS",
"category": "basic",
"density_g_cm3": 1.04,
"price_per_gram": 25.0,
"flow_rate_mm3_s": 12.0,
"max_temp_c": 100,
"min_temp_c": -30,
"strength": "high",
"flexibility": "low",
"chemical_resistance": "medium",
"uv_resistance": "low",
"food_safe": False,
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.",
"color_options": ["white", "black", "gray", "red", "blue"],
},
{
"name": "PA (Nylon)",
"category": "engineering",
"density_g_cm3": 1.14,
"price_per_gram": 50.0,
"flow_rate_mm3_s": 10.0,
"max_temp_c": 120,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "medium",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
"color_options": ["natural", "black"],
},
{
"name": "PC (Поликарбонат)",
"category": "engineering",
"density_g_cm3": 1.20,
"price_per_gram": 60.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 140,
"min_temp_c": -40,
"strength": "very_high",
"flexibility": "low",
"chemical_resistance": "high",
"uv_resistance": "high",
"food_safe": False,
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
"color_options": ["natural", "black"],
},
{
"name": "TPU",
"category": "engineering",
"density_g_cm3": 1.21,
"price_per_gram": 40.0,
"flow_rate_mm3_s": 6.0,
"max_temp_c": 80,
"min_temp_c": -30,
"strength": "medium",
"flexibility": "very_high",
"chemical_resistance": "high",
"uv_resistance": "medium",
"food_safe": False,
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
"color_options": ["white", "black", "natural"],
},
{
"name": "PA-CF (Нейлон + углеволокно)",
"category": "composite",
"density_g_cm3": 1.18,
"price_per_gram": 75.0,
"flow_rate_mm3_s": 8.0,
"max_temp_c": 150,
"min_temp_c": -40,
"strength": "extreme",
"flexibility": "low",
"chemical_resistance": "very_high",
"uv_resistance": "high",
"food_safe": False,
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
"color_options": ["black"],
},
]

View File

View File

@@ -0,0 +1,104 @@
import json
import logging
import time
from google import genai
from google.genai import types
from app.config import settings
logger = logging.getLogger("app.services.ai_advisor")
SYSTEM_PROMPT = """
Ты — эксперт по 3D-печати из инженерных пластиков по технологии FDM.
Твоя задача — рекомендовать оптимальный материал для печати на основе описания задачи клиента.
Доступные материалы:
{materials_json}
Правила:
1. Всегда рекомендуй один основной материал и 1-2 альтернативы.
2. Учитывай: температурный режим, механические нагрузки, химическое воздействие, UV, влажность.
3. Если задача не подходит для FDM-печати (слишком мелкие детали, высокая точность) — честно скажи об этом.
4. Отвечай кратко, по делу, на русском языке.
5. Если клиент не указал критичные параметры — задай уточняющие вопросы.
Формат ответа — строго JSON:
{{
"recommended_material_id": <int>,
"recommended_material_name": "<str>",
"reasoning": "<обоснование на русском>",
"alternatives": [{{"material_id": <int>, "name": "<str>", "why": "<причина>"}}],
"questions": ["<вопрос, если нужна доп. информация>"]
}}
"""
async def get_material_recommendation(
task_description: str,
materials_data: list[dict],
budget_preference: str = "optimal",
file_info: dict | None = None,
) -> dict:
"""Get material recommendation from Google Gemini API."""
logger.info("=== AI Advisor request ===")
logger.info("Task: %s", task_description)
logger.info("Budget preference: %s", budget_preference)
logger.info("File info: %s", file_info)
logger.info("Materials count: %d", len(materials_data))
if not settings.GOOGLE_API_KEY:
logger.error("GOOGLE_API_KEY is not configured")
raise ValueError("GOOGLE_API_KEY not configured")
materials_json = json.dumps(materials_data, ensure_ascii=False, indent=2)
system = SYSTEM_PROMPT.format(materials_json=materials_json)
logger.debug("System prompt length: %d chars", len(system))
user_message = f"Описание задачи: {task_description}\nПредпочтение по бюджету: {budget_preference}"
if file_info:
user_message += f"\nИнформация о модели: {json.dumps(file_info, ensure_ascii=False)}"
logger.debug("User message: %s", user_message)
logger.info("Sending request to Gemini API (model: gemini-2.0-flash)...")
start_time = time.time()
client = genai.Client(api_key=settings.GOOGLE_API_KEY)
response = await client.aio.models.generate_content(
model="gemini-3-flash-preview",
contents=user_message,
config=types.GenerateContentConfig(
system_instruction=system,
max_output_tokens=1024,
temperature=0.3,
),
)
elapsed = time.time() - start_time
logger.info("Gemini API responded in %.2f seconds", elapsed)
response_text = response.text
logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500])
try:
result = json.loads(response_text)
logger.info("Response parsed as JSON successfully")
logger.info("Recommended material: id=%s, name=%s",
result.get("recommended_material_id"), result.get("recommended_material_name"))
logger.info("Alternatives: %d, Questions: %d",
len(result.get("alternatives", [])), len(result.get("questions", [])))
logger.info("=== AI Advisor complete ===")
return result
except json.JSONDecodeError:
logger.warning("Direct JSON parse failed, trying to extract JSON from response...")
start = response_text.find("{")
end = response_text.rfind("}") + 1
if start != -1 and end > start:
extracted = response_text[start:end]
logger.debug("Extracted JSON substring [%d:%d]: %s", start, end, extracted[:300])
result = json.loads(extracted)
logger.info("Extracted JSON parsed successfully")
logger.info("=== AI Advisor complete ===")
return result
logger.error("Failed to extract JSON from AI response: %s", response_text[:200])
raise ValueError("AI вернул невалидный JSON")

View File

@@ -0,0 +1,62 @@
import logging
from dataclasses import dataclass
import trimesh
logger = logging.getLogger("app.services.file_parser")
@dataclass
class FileInfo:
volume_cm3: float
surface_area_cm2: float
bounding_box_mm: dict[str, float]
is_watertight: bool
triangle_count: int
SUPPORTED_EXTENSIONS = {".stl", ".3mf", ".obj"}
def parse_3d_file(file_path: str, file_extension: str) -> FileInfo:
"""Parse a 3D file and return geometric properties."""
ext = file_extension.lower().lstrip(".")
logger.info("Parsing 3D file: path=%s, extension=%s", file_path, ext)
logger.debug("Loading mesh with trimesh (file_type=%s)...", ext)
mesh = trimesh.load(file_path, file_type=ext)
logger.debug("Trimesh loaded object type: %s", type(mesh).__name__)
if isinstance(mesh, trimesh.Scene):
meshes = list(mesh.dump())
logger.info("File is a Scene with %d geometries, concatenating...", len(meshes))
if not meshes:
logger.error("Scene contains no geometries")
raise ValueError("Файл не содержит 3D-геометрии")
mesh = trimesh.util.concatenate(meshes)
logger.debug("Concatenated into single Trimesh")
if not isinstance(mesh, trimesh.Trimesh):
logger.error("Could not extract Trimesh object, got: %s", type(mesh).__name__)
raise ValueError("Не удалось извлечь 3D-геометрию из файла")
volume_cm3 = abs(mesh.volume) / 1000.0
surface_area_cm2 = mesh.area / 100.0
bbox = {
"x": round(float(mesh.bounding_box.extents[0]), 2),
"y": round(float(mesh.bounding_box.extents[1]), 2),
"z": round(float(mesh.bounding_box.extents[2]), 2),
}
is_watertight = bool(mesh.is_watertight)
triangle_count = len(mesh.faces)
logger.info("Parse result: volume=%.2f cm3, area=%.2f cm2, bbox=%s, watertight=%s, triangles=%d",
volume_cm3, surface_area_cm2, bbox, is_watertight, triangle_count)
return FileInfo(
volume_cm3=volume_cm3,
surface_area_cm2=surface_area_cm2,
bounding_box_mm=bbox,
is_watertight=is_watertight,
triangle_count=triangle_count,
)

View File

@@ -0,0 +1,140 @@
import logging
from dataclasses import dataclass
from app.services.file_parser import FileInfo
logger = logging.getLogger("app.services.price_engine")
TIME_RATE_PER_HOUR = 200.0 # руб/час
SETUP_TIME_MIN = 15.0 # минуты
TRAVEL_TIME_PER_LAYER_MIN = 0.3
POST_PROCESSING_COSTS = {
"sanding": 300.0,
"painting": 500.0,
"threading": 200.0,
"acetone_smoothing": 400.0,
}
QUANTITY_DISCOUNTS = [
(1, 0),
(2, 5),
(6, 10),
(21, 15),
(101, 20),
]
@dataclass
class PriceResult:
weight_grams: float
material_cost_rub: float
print_time_hours: float
time_cost_rub: float
post_processing_cost_rub: float
subtotal_rub: float
quantity: int
quantity_discount_percent: int
total_rub: float
estimated_days: int
def get_quantity_discount(quantity: int) -> int:
discount = 0
for min_qty, disc in QUANTITY_DISCOUNTS:
if quantity >= min_qty:
discount = disc
logger.debug("Quantity discount for %d pcs: %d%%", quantity, discount)
return discount
def estimate_print_time(file_info: FileInfo, layer_height_mm: float, flow_rate_mm3_s: float) -> float:
"""Estimate print time in hours."""
z_height = file_info.bounding_box_mm.get("z", 10.0)
layers = max(z_height / layer_height_mm, 1)
volume_mm3 = file_info.volume_cm3 * 1000.0
volume_per_layer = volume_mm3 / layers
time_per_layer_min = volume_per_layer / flow_rate_mm3_s / 60.0
total_min = layers * (time_per_layer_min + TRAVEL_TIME_PER_LAYER_MIN) + SETUP_TIME_MIN
hours = round(total_min / 60.0, 1)
logger.debug("Print time estimate: z=%.1fmm, layers=%.0f, vol_per_layer=%.1fmm3, "
"time_per_layer=%.2fmin, total=%.1fmin (%.1fh)",
z_height, layers, volume_per_layer, time_per_layer_min, total_min, hours)
return hours
def calculate_price(
file_info: FileInfo,
density_g_cm3: float,
price_per_gram: float,
flow_rate_mm3_s: float,
infill_percent: int = 30,
layer_height_mm: float = 0.2,
quantity: int = 1,
post_processing: list[str] | None = None,
) -> PriceResult:
post_processing = post_processing or []
logger.info("=== Price calculation start ===")
logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB",
file_info.volume_cm3, density_g_cm3, price_per_gram)
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, post_processing=%s",
infill_percent, layer_height_mm, quantity, post_processing)
effective_volume = file_info.volume_cm3 * (infill_percent / 100.0) * 0.7 + file_info.volume_cm3 * 0.3
logger.debug("Effective volume: %.2f cm3 (infill-scaled: %.2f + walls: %.2f)",
effective_volume,
file_info.volume_cm3 * (infill_percent / 100.0) * 0.7,
file_info.volume_cm3 * 0.3)
weight_g = round(effective_volume * density_g_cm3, 1)
material_cost = round(weight_g * price_per_gram, 2)
logger.debug("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost)
print_time_h = estimate_print_time(file_info, layer_height_mm, flow_rate_mm3_s)
time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2)
logger.debug("Print time: %.1f h, time cost: %.2f RUB (rate: %.0f RUB/h)", print_time_h, time_cost, TIME_RATE_PER_HOUR)
pp_cost = 0.0
for pp in post_processing:
cost = POST_PROCESSING_COSTS.get(pp, 0)
logger.debug("Post-processing '%s': %.0f RUB", pp, cost)
pp_cost += cost
pp_cost = round(pp_cost, 2)
logger.debug("Total post-processing cost: %.2f RUB", pp_cost)
subtotal = round(material_cost + time_cost + pp_cost, 2)
logger.debug("Subtotal (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
subtotal, material_cost, time_cost, pp_cost)
discount_pct = get_quantity_discount(quantity)
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)",
total, quantity, discount_pct, subtotal)
if print_time_h <= 2:
estimated_days = 2
elif print_time_h <= 8:
estimated_days = 3
else:
estimated_days = 5
if quantity > 10:
estimated_days += 2
if quantity > 50:
estimated_days += 3
logger.info("Estimated days: %d", estimated_days)
logger.info("=== Price calculation complete ===")
return PriceResult(
weight_grams=weight_g,
material_cost_rub=material_cost,
print_time_hours=print_time_h,
time_cost_rub=time_cost,
post_processing_cost_rub=pp_cost,
subtotal_rub=subtotal,
quantity=quantity,
quantity_discount_percent=discount_pct,
total_rub=total,
estimated_days=estimated_days,
)

View File

@@ -0,0 +1,67 @@
import io
import logging
from minio import Minio
from minio.error import S3Error
from app.config import settings
logger = logging.getLogger("app.services.storage")
_client: Minio | None = None
def get_minio_client() -> Minio:
global _client
if _client is None:
logger.info("Initializing MinIO client: endpoint=%s, secure=%s", settings.MINIO_ENDPOINT, settings.MINIO_SECURE)
_client = Minio(
endpoint=settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
logger.info("MinIO client created, checking bucket '%s'...", settings.MINIO_BUCKET)
if not _client.bucket_exists(settings.MINIO_BUCKET):
_client.make_bucket(settings.MINIO_BUCKET)
logger.info("Created MinIO bucket: %s", settings.MINIO_BUCKET)
else:
logger.info("MinIO bucket '%s' already exists", settings.MINIO_BUCKET)
return _client
def upload_file(object_name: str, data: bytes, content_type: str = "application/octet-stream") -> str:
"""Upload file to MinIO. Returns the object name (key)."""
logger.info("Uploading to MinIO: object=%s, size=%d bytes, content_type=%s", object_name, len(data), content_type)
client = get_minio_client()
client.put_object(
bucket_name=settings.MINIO_BUCKET,
object_name=object_name,
data=io.BytesIO(data),
length=len(data),
content_type=content_type,
)
logger.info("Upload complete: %s/%s", settings.MINIO_BUCKET, object_name)
return object_name
def download_file(object_name: str) -> bytes:
"""Download file from MinIO."""
logger.info("Downloading from MinIO: %s/%s", settings.MINIO_BUCKET, object_name)
client = get_minio_client()
response = client.get_object(settings.MINIO_BUCKET, object_name)
try:
data = response.read()
logger.info("Download complete: %s, size=%d bytes", object_name, len(data))
return data
finally:
response.close()
response.release_conn()
def delete_file(object_name: str) -> None:
"""Delete file from MinIO."""
logger.info("Deleting from MinIO: %s/%s", settings.MINIO_BUCKET, object_name)
client = get_minio_client()
client.remove_object(settings.MINIO_BUCKET, object_name)
logger.info("Deleted: %s", object_name)

View File

@@ -0,0 +1,48 @@
import logging
import httpx
from app.config import settings
logger = logging.getLogger("app.services.telegram")
async def notify_new_order(
order_id: str,
client_name: str,
client_phone: str,
material_name: str,
total_rub: float,
comment: str | None = None,
) -> None:
"""Send a notification about a new order to Telegram."""
logger.info("=== Telegram notification ===")
logger.info("Order: %s, client: %s, phone: %s, material: %s, total: %.2f RUB",
order_id, client_name, client_phone, material_name, total_rub)
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
logger.warning("Telegram credentials not configured (token=%s, chat_id=%s), skipping",
"set" if settings.TELEGRAM_BOT_TOKEN else "empty",
"set" if settings.TELEGRAM_CHAT_ID else "empty")
return
text = (
f"\U0001f195 Новый заказ #{order_id}\n"
f"Клиент: {client_name}\n"
f"Телефон: {client_phone}\n"
f"Материал: {material_name}\n"
f"Сумма: {total_rub} \u20bd\n"
f"Комментарий: {comment or '\u2014'}"
)
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
logger.debug("Sending to Telegram: chat_id=%s, text_length=%d", settings.TELEGRAM_CHAT_ID, len(text))
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json={"chat_id": settings.TELEGRAM_CHAT_ID, "text": text})
logger.info("Telegram response: status=%d, body=%s", resp.status_code, resp.text[:200])
if resp.status_code != 200:
logger.error("Telegram API error: %s", resp.text)
except Exception:
logger.exception("Failed to send Telegram notification")