This commit is contained in:
xds
2026-03-22 14:26:45 +03:00
parent 33694d68db
commit 466a27907a
28 changed files with 1334 additions and 71 deletions

View File

@@ -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"}

View File

@@ -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

View 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

View File

@@ -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,

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