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 @@ diff --git a/frontend/src/components/MaterialPicker.vue b/frontend/src/components/MaterialPicker.vue index 1480d8c..b03e5c4 100644 --- a/frontend/src/components/MaterialPicker.vue +++ b/frontend/src/components/MaterialPicker.vue @@ -16,7 +16,7 @@ + + +
+

Выберите цвет

+
+ +
+
diff --git a/frontend/src/views/TrackView.vue b/frontend/src/views/TrackView.vue new file mode 100644 index 0000000..d50ddf0 --- /dev/null +++ b/frontend/src/views/TrackView.vue @@ -0,0 +1,124 @@ + + + diff --git a/frontend/src/views/admin/AdminClients.vue b/frontend/src/views/admin/AdminClients.vue new file mode 100644 index 0000000..e223cff --- /dev/null +++ b/frontend/src/views/admin/AdminClients.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/src/views/admin/AdminDashboard.vue b/frontend/src/views/admin/AdminDashboard.vue index e8b0d7f..9d08517 100644 --- a/frontend/src/views/admin/AdminDashboard.vue +++ b/frontend/src/views/admin/AdminDashboard.vue @@ -25,6 +25,10 @@

Расчётов

{{ 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 @@
+ +
+ +
+
+ + {{ c.name }} + +
+
+
+ +
+ + {{ newColorHex }} +
+ +
+
+
@@ -205,6 +233,9 @@ const saving = ref(false) const editingMaterial = ref(null) const deletingMaterial = ref(null) +const newColorName = ref('') +const newColorHex = ref('#222222') + const defaultForm = () => ({ name: '', category: 'basic', @@ -220,6 +251,7 @@ const defaultForm = () => ({ food_safe: false, is_active: true, description: '', + color_options: [], }) const form = ref(defaultForm()) @@ -259,6 +291,7 @@ function openEdit(m) { food_safe: m.food_safe, is_active: m.is_active, description: m.description || '', + color_options: m.color_options ? [...m.color_options] : [], } showModal.value = true } @@ -278,6 +311,13 @@ async function saveMaterial() { } } +function addColor() { + if (!newColorName.value) return + form.value.color_options.push({ name: newColorName.value, hex: newColorHex.value }) + newColorName.value = '' + newColorHex.value = '#222222' +} + function confirmDelete(m) { deletingMaterial.value = m }