init
This commit is contained in:
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql+asyncpg://print3d:print3d@db:5432/print3d
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
49
backend/alembic/env.py
Normal file
49
backend/alembic/env.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
|
||||
from app.database import Base
|
||||
from app.models import Material, Calculation, Order
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations():
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
backend/alembic/script.py.mako
Normal file
23
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
21
backend/app/config.py
Normal file
21
backend/app/config.py
Normal 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
24
backend/app/database.py
Normal 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
90
backend/app/main.py
Normal 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"}
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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"]
|
||||
34
backend/app/models/calculation.py
Normal file
34
backend/app/models/calculation.py
Normal 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())
|
||||
28
backend/app/models/material.py
Normal file
28
backend/app/models/material.py
Normal 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())
|
||||
25
backend/app/models/order.py
Normal file
25
backend/app/models/order.py
Normal 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())
|
||||
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"),
|
||||
)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
65
backend/app/schemas/calculate.py
Normal file
65
backend/app/schemas/calculate.py
Normal 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] = []
|
||||
25
backend/app/schemas/material.py
Normal file
25
backend/app/schemas/material.py
Normal 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}
|
||||
18
backend/app/schemas/order.py
Normal file
18
backend/app/schemas/order.py
Normal 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
|
||||
0
backend/app/seed/__init__.py
Normal file
0
backend/app/seed/__init__.py
Normal file
114
backend/app/seed/materials.py
Normal file
114
backend/app/seed/materials.py
Normal 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"],
|
||||
},
|
||||
]
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
104
backend/app/services/ai_advisor.py
Normal file
104
backend/app/services/ai_advisor.py
Normal 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")
|
||||
62
backend/app/services/file_parser.py
Normal file
62
backend/app/services/file_parser.py
Normal 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,
|
||||
)
|
||||
140
backend/app/services/price_engine.py
Normal file
140
backend/app/services/price_engine.py
Normal 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,
|
||||
)
|
||||
67
backend/app/services/storage.py
Normal file
67
backend/app/services/storage.py
Normal 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)
|
||||
48
backend/app/services/telegram_notify.py
Normal file
48
backend/app/services/telegram_notify.py
Normal 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")
|
||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
alembic==1.14.1
|
||||
trimesh==4.5.3
|
||||
numpy-stl==3.1.2
|
||||
numpy==2.2.1
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
minio==7.2.12
|
||||
google-genai==1.14.0
|
||||
Reference in New Issue
Block a user