init
This commit is contained in:
@@ -21,7 +21,7 @@ async def get_current_admin(
|
||||
"""Dependency that validates JWT and returns the current admin user."""
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
if not payload or payload.get("type", "admin") != "admin":
|
||||
logger.warning("Invalid or expired token")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.database import async_session, engine, Base
|
||||
from app.models import Material, AdminUser
|
||||
from app.seed.materials import MATERIALS
|
||||
from app.services.auth import hash_password
|
||||
from app.routers import calculate, materials, orders, ai_advisor, admin
|
||||
from app.routers import calculate, materials, orders, ai_advisor, admin, clients, track
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -100,6 +100,8 @@ 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.include_router(track.router, prefix="/api")
|
||||
app.include_router(clients.router, prefix="/api/client")
|
||||
app.include_router(admin.router, prefix="/api/admin")
|
||||
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ from app.models.calculation import Calculation
|
||||
from app.models.order import Order
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.app_settings import AppSettings
|
||||
from app.models.client import Client
|
||||
|
||||
__all__ = ["Material", "Calculation", "Order", "AdminUser", "AppSettings"]
|
||||
__all__ = ["Material", "Calculation", "Order", "AdminUser", "AppSettings", "Client"]
|
||||
|
||||
18
backend/app/models/client.py
Normal file
18
backend/app/models/client.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Boolean, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Client(Base):
|
||||
__tablename__ = "clients"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(String(20))
|
||||
company: Mapped[str | None] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
@@ -13,6 +13,7 @@ class Order(Base):
|
||||
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_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("clients.id"))
|
||||
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))
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_admin
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.client import Client
|
||||
from app.models.material import Material
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.order import Order
|
||||
@@ -230,6 +231,7 @@ class DashboardStats(BaseModel):
|
||||
total_revenue: float
|
||||
total_calculations: int
|
||||
materials_count: int
|
||||
clients_count: int
|
||||
orders_today: int
|
||||
|
||||
|
||||
@@ -244,6 +246,7 @@ async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSess
|
||||
total_revenue = (await db.execute(select(func.sum(Order.total_rub)))).scalar() or 0.0
|
||||
total_calcs = (await db.execute(select(func.count(Calculation.id)))).scalar() or 0
|
||||
materials_count = (await db.execute(select(func.count(Material.id)))).scalar() or 0
|
||||
clients_count = (await db.execute(select(func.count(Client.id)))).scalar() or 0
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
orders_today = (await db.execute(
|
||||
@@ -256,6 +259,7 @@ async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSess
|
||||
total_revenue=round(total_revenue, 2),
|
||||
total_calculations=total_calcs,
|
||||
materials_count=materials_count,
|
||||
clients_count=clients_count,
|
||||
orders_today=orders_today,
|
||||
)
|
||||
|
||||
@@ -276,7 +280,7 @@ class MaterialCreate(BaseModel):
|
||||
uv_resistance: str | None = None
|
||||
food_safe: bool = False
|
||||
description: str | None = None
|
||||
color_options: list[str] = []
|
||||
color_options: list[dict] = []
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -575,3 +579,96 @@ async def update_setting(
|
||||
await db.commit()
|
||||
logger.info("Setting updated: %s = %s", key, data.value)
|
||||
return {"key": key, "value": data.value}
|
||||
|
||||
|
||||
# ─── Clients CRM ────────────────────────────────────────
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
phone: str | None
|
||||
company: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
orders_count: int = 0
|
||||
total_spent: float = 0
|
||||
|
||||
|
||||
@router.get("/clients", response_model=list[ClientOut])
|
||||
async def list_clients(
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
logger.info("Admin clients list requested")
|
||||
result = await db.execute(select(Client).order_by(desc(Client.created_at)))
|
||||
clients = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for c in clients:
|
||||
orders_result = await db.execute(
|
||||
select(func.count(Order.id), func.coalesce(func.sum(Order.total_rub), 0))
|
||||
.where(Order.client_id == c.id)
|
||||
)
|
||||
row = orders_result.one()
|
||||
out.append(ClientOut(
|
||||
id=c.id, email=c.email, name=c.name, phone=c.phone,
|
||||
company=c.company, is_active=c.is_active,
|
||||
created_at=c.created_at.isoformat() if c.created_at else "",
|
||||
orders_count=row[0], total_spent=round(row[1], 2),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/clients/{client_id}")
|
||||
async def get_client_detail(
|
||||
client_id: int,
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(404, "Клиент не найден")
|
||||
|
||||
orders_result = await db.execute(
|
||||
select(Order).where(Order.client_id == client_id).order_by(desc(Order.created_at))
|
||||
)
|
||||
orders = orders_result.scalars().all()
|
||||
|
||||
return {
|
||||
"client": {
|
||||
"id": client.id, "email": client.email, "name": client.name,
|
||||
"phone": client.phone, "company": client.company,
|
||||
"is_active": client.is_active,
|
||||
"created_at": client.created_at.isoformat() if client.created_at else "",
|
||||
},
|
||||
"orders": [
|
||||
{
|
||||
"order_id": o.order_id, "status": o.status, "total_rub": o.total_rub,
|
||||
"created_at": o.created_at.isoformat() if o.created_at else "",
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ClientToggleActive(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.patch("/clients/{client_id}/active")
|
||||
async def toggle_client_active(
|
||||
client_id: int,
|
||||
data: ClientToggleActive,
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(404, "Клиент не найден")
|
||||
client.is_active = data.is_active
|
||||
await db.commit()
|
||||
logger.info("Client id=%d is_active=%s (by %s)", client_id, data.is_active, admin.email)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -37,6 +37,8 @@ async def calculate(
|
||||
layer_height_mm: float = Form(0.2),
|
||||
quantity: int = Form(1),
|
||||
post_processing: str = Form(""),
|
||||
color: str = Form(""),
|
||||
multicolor: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
logger.info("===== /api/calculate request =====")
|
||||
@@ -51,8 +53,8 @@ async def calculate(
|
||||
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)
|
||||
logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, color='%s', multicolor=%s, post_processing='%s'",
|
||||
material_id, infill_percent, layer_height_mm, quantity, color, multicolor, post_processing)
|
||||
|
||||
if not 10 <= infill_percent <= 100:
|
||||
logger.warning("Invalid infill_percent: %d", infill_percent)
|
||||
@@ -134,6 +136,7 @@ async def calculate(
|
||||
layer_height_mm=layer_height_mm,
|
||||
quantity=quantity,
|
||||
post_processing=pp_list,
|
||||
multicolor=multicolor,
|
||||
)
|
||||
|
||||
# Save calculation to DB
|
||||
|
||||
181
backend/app/routers/clients.py
Normal file
181
backend/app/routers/clients.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.client import Client
|
||||
from app.models.order import Order
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.material import Material
|
||||
from app.services.auth import hash_password, verify_password, create_client_token, decode_access_token
|
||||
|
||||
logger = logging.getLogger("app.routers.clients")
|
||||
|
||||
router = APIRouter()
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────
|
||||
|
||||
async def get_current_client(
|
||||
creds: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Client:
|
||||
if not creds:
|
||||
raise HTTPException(401, "Требуется авторизация")
|
||||
payload = decode_access_token(creds.credentials)
|
||||
if not payload or payload.get("type") != "client":
|
||||
raise HTTPException(401, "Невалидный токен")
|
||||
result = await db.execute(select(Client).where(Client.id == int(payload["sub"])))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client or not client.is_active:
|
||||
raise HTTPException(401, "Клиент не найден или деактивирован")
|
||||
return client
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
phone: str | None = None
|
||||
company: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: str
|
||||
client: dict
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse)
|
||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client register: %s", data.email)
|
||||
existing = await db.execute(select(Client).where(Client.email == data.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, "Пользователь с таким email уже зарегистрирован")
|
||||
|
||||
client = Client(
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
name=data.name,
|
||||
phone=data.phone,
|
||||
company=data.company,
|
||||
)
|
||||
db.add(client)
|
||||
await db.commit()
|
||||
await db.refresh(client)
|
||||
logger.info("Client registered: id=%d, email=%s", client.id, client.email)
|
||||
|
||||
token = create_client_token(client.id, client.email)
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
client={"id": client.id, "email": client.email, "name": client.name, "phone": client.phone, "company": client.company},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client login: %s", data.email)
|
||||
result = await db.execute(select(Client).where(Client.email == data.email, Client.is_active == True))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client or not verify_password(data.password, client.password_hash):
|
||||
raise HTTPException(401, "Неверный email или пароль")
|
||||
|
||||
token = create_client_token(client.id, client.email)
|
||||
logger.info("Client logged in: id=%d", client.id)
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
client={"id": client.id, "email": client.email, "name": client.name, "phone": client.phone, "company": client.company},
|
||||
)
|
||||
|
||||
|
||||
# ─── Profile ────────────────────────────────────────────
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
phone: str | None
|
||||
company: str | None
|
||||
|
||||
|
||||
@router.get("/me", response_model=ProfileResponse)
|
||||
async def get_profile(client: Client = Depends(get_current_client)):
|
||||
return ProfileResponse(id=client.id, email=client.email, name=client.name, phone=client.phone, company=client.company)
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
phone: str | None = None
|
||||
company: str | None = None
|
||||
|
||||
|
||||
@router.put("/me", response_model=ProfileResponse)
|
||||
async def update_profile(data: ProfileUpdate, client: Client = Depends(get_current_client), db: AsyncSession = Depends(get_db)):
|
||||
if data.name is not None:
|
||||
client.name = data.name
|
||||
if data.phone is not None:
|
||||
client.phone = data.phone
|
||||
if data.company is not None:
|
||||
client.company = data.company
|
||||
await db.commit()
|
||||
await db.refresh(client)
|
||||
return ProfileResponse(id=client.id, email=client.email, name=client.name, phone=client.phone, company=client.company)
|
||||
|
||||
|
||||
# ─── Client Orders ───────────────────────────────────────
|
||||
|
||||
class ClientOrderOut(BaseModel):
|
||||
order_id: str
|
||||
status: str
|
||||
total_rub: float
|
||||
material_name: str | None = None
|
||||
file_name: str | None = None
|
||||
quantity: int | None = None
|
||||
created_at: str
|
||||
delivery_method: str
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[ClientOrderOut])
|
||||
async def get_my_orders(client: Client = Depends(get_current_client), db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client orders requested: client_id=%d", client.id)
|
||||
result = await db.execute(
|
||||
select(Order).where(Order.client_id == client.id).order_by(desc(Order.created_at))
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for o in orders:
|
||||
calc_result = await db.execute(select(Calculation).where(Calculation.id == o.calculation_id))
|
||||
calc = calc_result.scalar_one_or_none()
|
||||
material_name = None
|
||||
if calc:
|
||||
mat_result = await db.execute(select(Material).where(Material.id == calc.material_id))
|
||||
mat = mat_result.scalar_one_or_none()
|
||||
material_name = mat.name if mat else None
|
||||
|
||||
out.append(ClientOrderOut(
|
||||
order_id=o.order_id,
|
||||
status=o.status,
|
||||
total_rub=o.total_rub,
|
||||
material_name=material_name,
|
||||
file_name=calc.file_name if calc else None,
|
||||
quantity=calc.quantity if calc else None,
|
||||
created_at=o.created_at.isoformat() if o.created_at else "",
|
||||
delivery_method=o.delivery_method,
|
||||
comment=o.comment,
|
||||
))
|
||||
|
||||
logger.info("Returning %d orders for client_id=%d", len(out), client.id)
|
||||
return out
|
||||
@@ -8,9 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.client import Client
|
||||
from app.models.material import Material
|
||||
from app.models.order import Order
|
||||
from app.schemas.order import OrderCreate, OrderResponse
|
||||
from app.services.auth import decode_access_token
|
||||
from app.services.telegram_notify import notify_new_order
|
||||
|
||||
logger = logging.getLogger("app.routers.orders")
|
||||
@@ -61,6 +63,14 @@ async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_d
|
||||
material_name = material.name if material else "Неизвестный"
|
||||
logger.info("Material for notification: %s", material_name)
|
||||
|
||||
# Resolve client_id from token if provided
|
||||
client_id = None
|
||||
if order_data.client_token:
|
||||
payload = decode_access_token(order_data.client_token)
|
||||
if payload and payload.get("type") == "client":
|
||||
client_id = int(payload["sub"])
|
||||
logger.info("Order linked to client_id=%d", client_id)
|
||||
|
||||
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"))
|
||||
@@ -68,6 +78,7 @@ async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_d
|
||||
order = Order(
|
||||
order_id=order_id,
|
||||
calculation_id=calc.id,
|
||||
client_id=client_id,
|
||||
client_name=order_data.client_name,
|
||||
client_phone=order_data.client_phone,
|
||||
client_email=order_data.client_email,
|
||||
|
||||
35
backend/app/routers/track.py
Normal file
35
backend/app/routers/track.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Depends
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.order import Order
|
||||
|
||||
logger = logging.getLogger("app.routers.track")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TrackResponse(BaseModel):
|
||||
order_id: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/track/{order_id}", response_model=TrackResponse)
|
||||
async def track_order(order_id: str, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Track order: %s", order_id)
|
||||
result = await db.execute(select(Order).where(Order.order_id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Заказ не найден")
|
||||
|
||||
return TrackResponse(
|
||||
order_id=order.order_id,
|
||||
status=order.status,
|
||||
created_at=order.created_at.isoformat() if order.created_at else "",
|
||||
)
|
||||
@@ -20,6 +20,6 @@ class MaterialResponse(BaseModel):
|
||||
flow_rate_mm3_s: float
|
||||
properties: MaterialProperties
|
||||
description: str | None = None
|
||||
color_options: list[str] = []
|
||||
color_options: list[dict] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -9,6 +9,7 @@ class OrderCreate(BaseModel):
|
||||
client_company: str | None = None
|
||||
delivery_method: str = "pickup"
|
||||
comment: str | None = None
|
||||
client_token: str | None = None # JWT token if logged in
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@ MATERIALS = [
|
||||
"name": "PLA",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.24,
|
||||
"price_per_gram": 25.0,
|
||||
"price_per_gram": 15.0,
|
||||
"flow_rate_mm3_s": 15.0,
|
||||
"max_temp_c": 60,
|
||||
"min_temp_c": -20,
|
||||
@@ -13,13 +13,23 @@ MATERIALS = [
|
||||
"uv_resistance": "low",
|
||||
"food_safe": True,
|
||||
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.",
|
||||
"color_options": ["white", "black", "gray", "red", "blue", "green", "natural"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Красный", "hex": "#E53E3E"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
{"name": "Зелёный", "hex": "#38A169"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Жёлтый", "hex": "#ECC94B"},
|
||||
{"name": "Оранжевый", "hex": "#ED8936"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PETG",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.27,
|
||||
"price_per_gram": 28.0,
|
||||
"price_per_gram": 17.0,
|
||||
"flow_rate_mm3_s": 12.0,
|
||||
"max_temp_c": 80,
|
||||
"min_temp_c": -40,
|
||||
@@ -29,13 +39,20 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": True,
|
||||
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.",
|
||||
"color_options": ["white", "black", "gray", "natural", "blue"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
{"name": "Оранжевый", "hex": "#ED8936"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "ABS",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.04,
|
||||
"price_per_gram": 25.0,
|
||||
"price_per_gram": 16.0,
|
||||
"flow_rate_mm3_s": 12.0,
|
||||
"max_temp_c": 100,
|
||||
"min_temp_c": -30,
|
||||
@@ -45,7 +62,13 @@ MATERIALS = [
|
||||
"uv_resistance": "low",
|
||||
"food_safe": False,
|
||||
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.",
|
||||
"color_options": ["white", "black", "gray", "red", "blue"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Красный", "hex": "#E53E3E"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PA (Nylon)",
|
||||
@@ -61,7 +84,10 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": False,
|
||||
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
|
||||
"color_options": ["natural", "black"],
|
||||
"color_options": [
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PC (Поликарбонат)",
|
||||
@@ -77,7 +103,10 @@ MATERIALS = [
|
||||
"uv_resistance": "high",
|
||||
"food_safe": False,
|
||||
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
|
||||
"color_options": ["natural", "black"],
|
||||
"color_options": [
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "TPU",
|
||||
@@ -93,7 +122,11 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": False,
|
||||
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
|
||||
"color_options": ["white", "black", "natural"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PA-CF (Нейлон + углеволокно)",
|
||||
@@ -109,6 +142,8 @@ MATERIALS = [
|
||||
"uv_resistance": "high",
|
||||
"food_safe": False,
|
||||
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
|
||||
"color_options": ["black"],
|
||||
"color_options": [
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,18 +17,23 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(user_id: int, email: str) -> str:
|
||||
def create_access_token(user_id: int, email: str, token_type: str = "admin") -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"type": token_type,
|
||||
"exp": expire,
|
||||
}
|
||||
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
logger.info("Created JWT for user_id=%d, email=%s, expires=%s", user_id, email, expire)
|
||||
logger.info("Created JWT (%s) for id=%d, email=%s, expires=%s", token_type, user_id, email, expire)
|
||||
return token
|
||||
|
||||
|
||||
def create_client_token(client_id: int, email: str) -> str:
|
||||
return create_access_token(client_id, email, token_type="client")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
|
||||
@@ -15,6 +15,8 @@ POST_PROCESSING_COSTS = {
|
||||
"acetone_smoothing": 400.0,
|
||||
}
|
||||
|
||||
MULTICOLOR_SURCHARGE_PERCENT = 30 # наценка за многоцветную печать
|
||||
|
||||
QUANTITY_DISCOUNTS = [
|
||||
(1, 0),
|
||||
(2, 5),
|
||||
@@ -71,14 +73,15 @@ def calculate_price(
|
||||
layer_height_mm: float = 0.2,
|
||||
quantity: int = 1,
|
||||
post_processing: list[str] | None = None,
|
||||
multicolor: bool = False,
|
||||
) -> 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)
|
||||
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s, post_processing=%s",
|
||||
infill_percent, layer_height_mm, quantity, multicolor, 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)",
|
||||
@@ -103,9 +106,15 @@ def calculate_price(
|
||||
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)",
|
||||
logger.debug("Subtotal before multicolor (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
|
||||
subtotal, material_cost, time_cost, pp_cost)
|
||||
|
||||
if multicolor:
|
||||
multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2)
|
||||
subtotal = round(subtotal + multicolor_surcharge, 2)
|
||||
logger.debug("Multicolor surcharge: +%.2f RUB (%d%%), new subtotal: %.2f",
|
||||
multicolor_surcharge, MULTICOLOR_SURCHARGE_PERCENT, subtotal)
|
||||
|
||||
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)",
|
||||
|
||||
Reference in New Issue
Block a user