diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py
index 6a8bf1d..4742c9c 100644
--- a/backend/app/dependencies.py
+++ b/backend/app/dependencies.py
@@ -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="Невалидный или просроченный токен")
diff --git a/backend/app/main.py b/backend/app/main.py
index 7d41e72..bbb1b6c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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")
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 8ec4548..02558c5 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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"]
diff --git a/backend/app/models/client.py b/backend/app/models/client.py
new file mode 100644
index 0000000..dd6e113
--- /dev/null
+++ b/backend/app/models/client.py
@@ -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())
diff --git a/backend/app/models/order.py b/backend/app/models/order.py
index 1852612..7ad1e13 100644
--- a/backend/app/models/order.py
+++ b/backend/app/models/order.py
@@ -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))
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index 4ca4235..44333f0 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -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"}
diff --git a/backend/app/routers/calculate.py b/backend/app/routers/calculate.py
index ab734cf..32fe11e 100644
--- a/backend/app/routers/calculate.py
+++ b/backend/app/routers/calculate.py
@@ -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
diff --git a/backend/app/routers/clients.py b/backend/app/routers/clients.py
new file mode 100644
index 0000000..ee754cd
--- /dev/null
+++ b/backend/app/routers/clients.py
@@ -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
diff --git a/backend/app/routers/orders.py b/backend/app/routers/orders.py
index d8afba2..6f971f2 100644
--- a/backend/app/routers/orders.py
+++ b/backend/app/routers/orders.py
@@ -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,
diff --git a/backend/app/routers/track.py b/backend/app/routers/track.py
new file mode 100644
index 0000000..13bffd6
--- /dev/null
+++ b/backend/app/routers/track.py
@@ -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 "",
+ )
diff --git a/backend/app/schemas/material.py b/backend/app/schemas/material.py
index 6399f95..a9c1ec0 100644
--- a/backend/app/schemas/material.py
+++ b/backend/app/schemas/material.py
@@ -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}
diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py
index 68830c8..e4b9540 100644
--- a/backend/app/schemas/order.py
+++ b/backend/app/schemas/order.py
@@ -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):
diff --git a/backend/app/seed/materials.py b/backend/app/seed/materials.py
index f7ee3a5..8e0de27 100644
--- a/backend/app/seed/materials.py
+++ b/backend/app/seed/materials.py
@@ -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"},
+ ],
},
]
diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py
index cc694e7..ac0202c 100644
--- a/backend/app/services/auth.py
+++ b/backend/app/services/auth.py
@@ -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])
diff --git a/backend/app/services/price_engine.py b/backend/app/services/price_engine.py
index 32480c8..14f8d34 100644
--- a/backend/app/services/price_engine.py
+++ b/backend/app/services/price_engine.py
@@ -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)",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 535d7b1..9a630ec 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,6 +1,6 @@
{{ mat.description }} Введите номер заказа, чтобы узнать его статус {{ result.order_id }} Дата оформления: {{ formatDate(result.created_at) }} {{ error }} Email: {{ selectedClient.client.email }} Телефон: {{ selectedClient.client.phone }} Компания: {{ selectedClient.client.company }} Регистрация: {{ formatDate(selectedClient.client.created_at) }}Выберите цвет
+ Проверка заказа
+ Клиенты
+
+
+
+
+
+
+
+ Клиент
+ Контакты
+ Заказов
+ Потрачено
+ Активен
+ Дата
+ Действия
+
+
+
+
+
+
+
+ {{ c.orders_count }}
+ {{ fmt(c.total_spent) }} ₽
+
+ ✓
+ ✕
+
+ {{ formatDate(c.created_at) }}
+
+
+
+ {{ selectedClient.client.name }}
+
+ Заказы ({{ selectedClient.orders.length }})
+
Расчётов
{{ stats.total_calculations }}
+Клиентов
+{{ stats.clients_count }}
+Материалов
{{ stats.materials_count }}
diff --git a/frontend/src/views/admin/AdminLayout.vue b/frontend/src/views/admin/AdminLayout.vue index 19d595e..3f8cbb2 100644 --- a/frontend/src/views/admin/AdminLayout.vue +++ b/frontend/src/views/admin/AdminLayout.vue @@ -74,10 +74,12 @@ const IconOrders = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24' const IconMaterials = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9' })])} const IconSettings = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z' }), h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })])} const IconUsers = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })])} +const IconClients = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z' })])} const nav = [ { to: '/admin', label: 'Дашборд', icon: IconDashboard }, - { to: '/admin/orders', label: 'Заказы (CRM)', icon: IconOrders }, + { to: '/admin/orders', label: 'Заказы', icon: IconOrders }, + { to: '/admin/clients', label: 'Клиенты', icon: IconClients }, { to: '/admin/materials', label: 'Материалы', icon: IconMaterials }, { to: '/admin/users', label: 'Администраторы', icon: IconUsers }, { to: '/admin/settings', label: 'Настройки', icon: IconSettings }, diff --git a/frontend/src/views/admin/AdminMaterials.vue b/frontend/src/views/admin/AdminMaterials.vue index 1c5a57f..be5d90d 100644 --- a/frontend/src/views/admin/AdminMaterials.vue +++ b/frontend/src/views/admin/AdminMaterials.vue @@ -154,6 +154,34 @@