diff --git a/.DS_Store b/.DS_Store
index 5ba35b8..b207f46 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.cursor/rules/fastapinextjs.mdc b/.cursor/rules/fastapinextjs.mdc
index a520a8e..c178b5b 100644
--- a/.cursor/rules/fastapinextjs.mdc
+++ b/.cursor/rules/fastapinextjs.mdc
@@ -3,6 +3,8 @@ description:
globs:
alwaysApply: true
---
+use context7 always for docs frameworks
+
Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд).
Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c3629e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules
diff --git a/backend/.DS_Store b/backend/.DS_Store
index 3c06a03..c5f9229 100644
Binary files a/backend/.DS_Store and b/backend/.DS_Store differ
diff --git a/backend/.env b/backend/.env
index b7329b9..9ed5524 100644
--- a/backend/.env
+++ b/backend/.env
@@ -1,5 +1,5 @@
# Настройки базы данных
-DATABASE_URL=postgresql://postgres:postgres@localhost:5434/shop_db
+DATABASE_URL=postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
# Настройки безопасности
SECRET_KEY=supersecretkey
diff --git a/backend/.env.docker b/backend/.env.docker
index e8c8b23..f7aea9f 100644
--- a/backend/.env.docker
+++ b/backend/.env.docker
@@ -10,5 +10,5 @@ SECRET_KEY=supersecretkey
FRONTEND_URL=http://frontend:3000
# Настройки Meilisearch
-MEILISEARCH_URL=http://meilisearch:7700
+MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..c0a05eb
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.10-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Для разработки код монтируется через volumes, а в продакшн-билде можно добавить COPY . /app
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # hot-reload
\ No newline at end of file
diff --git a/backend/alembic.ini b/backend/alembic.ini
index 1766512..645e0a3 100644
--- a/backend/alembic.ini
+++ b/backend/alembic.ini
@@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
-sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db
+sqlalchemy.url = postgresql://gen_user:F%%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
[post_write_hooks]
diff --git a/backend/alembic/versions/7192b0707277_add_new_order.py b/backend/alembic/versions/7192b0707277_add_new_order.py
new file mode 100644
index 0000000..cbb9a59
--- /dev/null
+++ b/backend/alembic/versions/7192b0707277_add_new_order.py
@@ -0,0 +1,42 @@
+"""add_new_order
+
+Revision ID: 7192b0707277
+Revises: 9773b0186faa
+Create Date: 2025-04-27 01:56:31.170476
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '7192b0707277'
+down_revision: Union[str, None] = '9773b0186faa'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('orders', sa.Column('user_info_json', sa.JSON(), nullable=True))
+ op.add_column('orders', sa.Column('delivery_method', sa.String(), nullable=True))
+ op.add_column('orders', sa.Column('city', sa.String(), nullable=True))
+ op.add_column('orders', sa.Column('delivery_address', sa.String(), nullable=True))
+ op.add_column('orders', sa.Column('cdek_info', sa.JSON(), nullable=True))
+ op.add_column('orders', sa.Column('courier_info', sa.JSON(), nullable=True))
+ op.add_column('orders', sa.Column('items_json', sa.JSON(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('orders', 'items_json')
+ op.drop_column('orders', 'courier_info')
+ op.drop_column('orders', 'cdek_info')
+ op.drop_column('orders', 'delivery_address')
+ op.drop_column('orders', 'city')
+ op.drop_column('orders', 'delivery_method')
+ op.drop_column('orders', 'user_info_json')
+ # ### end Alembic commands ###
diff --git a/backend/alembic/versions/__pycache__/7192b0707277_add_new_order.cpython-310.pyc b/backend/alembic/versions/__pycache__/7192b0707277_add_new_order.cpython-310.pyc
new file mode 100644
index 0000000..141c202
Binary files /dev/null and b/backend/alembic/versions/__pycache__/7192b0707277_add_new_order.cpython-310.pyc differ
diff --git a/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc b/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc
index 806c2b1..e53d319 100644
Binary files a/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc and b/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc differ
diff --git a/backend/alembic/versions/__pycache__/a81393a28fee_add_new_order_.cpython-310.pyc b/backend/alembic/versions/__pycache__/a81393a28fee_add_new_order_.cpython-310.pyc
new file mode 100644
index 0000000..2f9cf77
Binary files /dev/null and b/backend/alembic/versions/__pycache__/a81393a28fee_add_new_order_.cpython-310.pyc differ
diff --git a/backend/alembic/versions/__pycache__/f89a59b0e814_add_new_order_.cpython-310.pyc b/backend/alembic/versions/__pycache__/f89a59b0e814_add_new_order_.cpython-310.pyc
new file mode 100644
index 0000000..9547a3e
Binary files /dev/null and b/backend/alembic/versions/__pycache__/f89a59b0e814_add_new_order_.cpython-310.pyc differ
diff --git a/backend/alembic/versions/f89a59b0e814_add_new_order_.py b/backend/alembic/versions/f89a59b0e814_add_new_order_.py
new file mode 100644
index 0000000..0b2fd1c
--- /dev/null
+++ b/backend/alembic/versions/f89a59b0e814_add_new_order_.py
@@ -0,0 +1,30 @@
+"""add_new_order_
+
+Revision ID: f89a59b0e814
+Revises: 7192b0707277
+Create Date: 2025-04-27 02:49:23.818786
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'f89a59b0e814'
+down_revision: Union[str, None] = '7192b0707277'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('orders', sa.Column('payment_method', sa.Enum('CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'CASH_ON_DELIVERY', 'SBP', 'CARD', name='paymentmethod'), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('orders', 'payment_method')
+ # ### end Alembic commands ###
diff --git a/backend/app/.DS_Store b/backend/app/.DS_Store
index 55d04c0..8e8e357 100644
Binary files a/backend/app/.DS_Store and b/backend/app/.DS_Store differ
diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc
index 0200aea..6f3e7f4 100644
Binary files a/backend/app/__pycache__/config.cpython-310.pyc and b/backend/app/__pycache__/config.cpython-310.pyc differ
diff --git a/backend/app/config.py b/backend/app/config.py
index 5bde74e..19c8641 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -100,7 +100,7 @@ class Settings(BaseSettings):
EMAIL_FROM: str = MAIL_FROM
# Настройки Meilisearch
- MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://localhost:7700")
+ MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://0.0.0.0:7700")
MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey")
diff --git a/backend/app/models/__pycache__/order_models.cpython-310.pyc b/backend/app/models/__pycache__/order_models.cpython-310.pyc
index 38ae743..a74e636 100644
Binary files a/backend/app/models/__pycache__/order_models.cpython-310.pyc and b/backend/app/models/__pycache__/order_models.cpython-310.pyc differ
diff --git a/backend/app/models/order_models.py b/backend/app/models/order_models.py
index 1d5dbc7..8feeee4 100644
--- a/backend/app/models/order_models.py
+++ b/backend/app/models/order_models.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum
+from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
@@ -20,6 +20,8 @@ class PaymentMethod(str, enum.Enum):
PAYPAL = "paypal"
BANK_TRANSFER = "bank_transfer"
CASH_ON_DELIVERY = "cash_on_delivery"
+ SBP = "sbp"
+ CARD = "card"
class CartItem(Base):
@@ -44,9 +46,28 @@ class Order(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
total_amount = Column(Float, nullable=False)
+
+ # JSON-поле с информацией о пользователе (для резервного копирования)
+ user_info_json = Column(JSON, nullable=True)
+
+ # Информация о доставке
+ delivery_method = Column(String, nullable=True) # cdek, courier
+ city = Column(String, nullable=True)
+ delivery_address = Column(String, nullable=True) # Форматированный адрес
+ cdek_info = Column(JSON, nullable=True) # Информация о доставке CDEK
+ courier_info = Column(JSON, nullable=True) # Информация о курьерской доставке
+
+ # Старые поля (для обратной совместимости)
shipping_address_id = Column(Integer, ForeignKey("user_addresses.id"), nullable=True)
+
+ # Информация об оплате
payment_method = Column(Enum(PaymentMethod), nullable=True)
payment_details = Column(Text, nullable=True)
+
+ # JSON-поле со списком заказанных товаров (для резервного копирования)
+ items_json = Column(JSON, nullable=True)
+
+ # Дополнительная информация
tracking_number = Column(String, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -70,4 +91,4 @@ class OrderItem(Base):
# Отношения
order = relationship("Order", back_populates="items")
- variant = relationship("ProductVariant")
\ No newline at end of file
+ variant = relationship("ProductVariant")
\ No newline at end of file
diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc
index 6b2be9e..7cabf2a 100644
Binary files a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ
diff --git a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc
index 9d81f9f..403b9bc 100644
Binary files a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc differ
diff --git a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc
index e475609..b77d46a 100644
Binary files a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc differ
diff --git a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc
index 8296209..ff7d1a8 100644
Binary files a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc differ
diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py
index aa35244..8005e0f 100644
--- a/backend/app/repositories/catalog_repo.py
+++ b/backend/app/repositories/catalog_repo.py
@@ -511,15 +511,35 @@ def delete_product(db: Session, product_id: int) -> bool:
detail="Продукт не найден"
)
+ # Получаем ID всех вариантов продукта
+ variant_ids = [variant.id for variant in db_product.variants]
+
+ # Проверяем, используются ли варианты в корзинах
+ from app.models.order_models import CartItem
+ cart_items_count = db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).count()
+ if cart_items_count > 0:
+ # Удаляем все элементы корзины, связанные с вариантами продукта
+ db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).delete(synchronize_session=False)
+
+ # Проверяем, используются ли варианты в заказах
+ from app.models.order_models import OrderItem
+ order_items_count = db.query(OrderItem).filter(OrderItem.variant_id.in_(variant_ids)).count()
+ if order_items_count > 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Невозможно удалить продукт, так как его варианты используются в {order_items_count} заказах"
+ )
+
try:
+ # Удаляем продукт (варианты и изображения удалятся автоматически благодаря cascade)
db.delete(db_product)
db.commit()
return True
- except Exception:
+ except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="Ошибка при удалении продукта"
+ detail=f"Ошибка при удалении продукта: {str(e)}"
)
diff --git a/backend/app/repositories/content_repo.py b/backend/app/repositories/content_repo.py
index 7465731..22ba624 100644
--- a/backend/app/repositories/content_repo.py
+++ b/backend/app/repositories/content_repo.py
@@ -21,7 +21,7 @@ def generate_slug(title: str) -> str:
slug = re.sub(r'-+', '-', slug)
# Удаляем дефисы в начале и конце
slug = slug.strip('-')
-
+
return slug
@@ -35,16 +35,16 @@ def get_page_by_slug(db: Session, slug: str) -> Optional[Page]:
def get_pages(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
published_only: bool = True
) -> List[Page]:
query = db.query(Page)
-
+
if published_only:
query = query.filter(Page.is_published == True)
-
+
return query.order_by(Page.title).offset(skip).limit(limit).all()
@@ -52,14 +52,14 @@ def create_page(db: Session, page: PageCreate) -> Page:
# Если slug не предоставлен, генерируем его из заголовка
if not page.slug:
page.slug = generate_slug(page.title)
-
+
# Проверяем, что страница с таким slug не существует
if get_page_by_slug(db, page.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
-
+
# Создаем новую страницу
db_page = Page(
title=page.title,
@@ -69,7 +69,7 @@ def create_page(db: Session, page: PageCreate) -> Page:
meta_description=page.meta_description,
is_published=page.is_published
)
-
+
try:
db.add(db_page)
db.commit()
@@ -90,10 +90,10 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_404_NOT_FOUND,
detail="Страница не найдена"
)
-
+
# Обновляем только предоставленные поля
- update_data = page.dict(exclude_unset=True)
-
+ update_data = page.model_dump(exclude_unset=True)
+
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_page.slug:
if get_page_by_slug(db, update_data["slug"]):
@@ -101,7 +101,7 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
-
+
# Если заголовок изменяется и slug не предоставлен, генерируем новый slug
if "title" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["title"])
@@ -111,11 +111,11 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Страница с таким slug уже существует"
)
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_page, key, value)
-
+
try:
db.commit()
db.refresh(db_page)
@@ -135,7 +135,7 @@ def delete_page(db: Session, page_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Страница не найдена"
)
-
+
try:
db.delete(db_page)
db.commit()
@@ -152,7 +152,7 @@ def delete_page(db: Session, page_id: int) -> bool:
def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
# Создаем новую запись аналитики
db_log = AnalyticsLog(
- user_id=log.user_id,
+ user_id=log.user_id, # Может быть None для неавторизованных пользователей
event_type=log.event_type,
page_url=log.page_url,
product_id=log.product_id,
@@ -162,7 +162,7 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
referrer=log.referrer,
additional_data=log.additional_data
)
-
+
try:
db.add(db_log)
db.commit()
@@ -177,9 +177,9 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
def get_analytics_logs(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
event_type: Optional[str] = None,
user_id: Optional[int] = None,
product_id: Optional[int] = None,
@@ -188,31 +188,31 @@ def get_analytics_logs(
end_date: Optional[datetime] = None
) -> List[AnalyticsLog]:
query = db.query(AnalyticsLog)
-
+
# Применяем фильтры
if event_type:
query = query.filter(AnalyticsLog.event_type == event_type)
-
+
if user_id:
query = query.filter(AnalyticsLog.user_id == user_id)
-
+
if product_id:
query = query.filter(AnalyticsLog.product_id == product_id)
-
+
if category_id:
query = query.filter(AnalyticsLog.category_id == category_id)
-
+
if start_date:
query = query.filter(AnalyticsLog.created_at >= start_date)
-
+
if end_date:
query = query.filter(AnalyticsLog.created_at <= end_date)
-
+
return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all()
def get_analytics_report(
- db: Session,
+ db: Session,
period: str = "day",
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
@@ -229,44 +229,44 @@ def get_analytics_report(
start_date = datetime.utcnow() - timedelta(days=365)
else:
start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней
-
+
if not end_date:
end_date = datetime.utcnow()
-
+
# Получаем все события за указанный период
logs = db.query(AnalyticsLog).filter(
AnalyticsLog.created_at >= start_date,
AnalyticsLog.created_at <= end_date
).all()
-
+
# Подсчитываем статистику
total_visits = len(logs)
unique_visitors = len(set([log.ip_address for log in logs if log.ip_address]))
-
+
# Подсчитываем просмотры страниц
page_views = {}
for log in logs:
if log.event_type == "page_view" and log.page_url:
page_views[log.page_url] = page_views.get(log.page_url, 0) + 1
-
+
# Подсчитываем просмотры продуктов
product_views = {}
for log in logs:
if log.event_type == "product_view" and log.product_id:
product_id = str(log.product_id)
product_views[product_id] = product_views.get(product_id, 0) + 1
-
+
# Подсчитываем добавления в корзину
cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart")
-
+
# Подсчитываем заказы и выручку
orders_count = sum(1 for log in logs if log.event_type == "order_created")
-
+
# Для расчета выручки и среднего чека нам нужны данные о заказах
# В данном примере мы просто используем заглушки
revenue = 0
average_order_value = 0
-
+
# Формируем отчет
report = {
"period": period,
@@ -281,5 +281,5 @@ def get_analytics_report(
"revenue": revenue,
"average_order_value": average_order_value
}
-
- return report
\ No newline at end of file
+
+ return report
\ No newline at end of file
diff --git a/backend/app/repositories/order_repo.py b/backend/app/repositories/order_repo.py
index 90cb5bb..9d03778 100644
--- a/backend/app/repositories/order_repo.py
+++ b/backend/app/repositories/order_repo.py
@@ -1,13 +1,15 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status
-from typing import List, Optional, Dict, Any
+from typing import List, Optional, Dict, Any, Union, Tuple
from datetime import datetime
+import json
from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod
from app.models.catalog_models import Product, ProductImage, ProductVariant, Size
from app.models.user_models import User, UserAddress
-from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
+from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, OrderCreateNew
+from app.repositories import user_repo
# Функции для работы с корзиной
@@ -34,7 +36,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_404_NOT_FOUND,
detail="Вариант продукта не найден"
)
-
+
# Проверяем, что продукт активен
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product or not product.is_active:
@@ -42,7 +44,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_400_BAD_REQUEST,
detail="Продукт не активен или не найден"
)
-
+
# Проверяем, есть ли уже такой товар в корзине
existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id)
if existing_item:
@@ -58,14 +60,14 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении элемента корзины"
)
-
+
# Создаем новый элемент корзины
db_cart_item = CartItem(
user_id=user_id,
variant_id=cart_item.variant_id,
quantity=cart_item.quantity
)
-
+
try:
db.add(db_cart_item)
db.commit()
@@ -84,20 +86,20 @@ def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate,
CartItem.id == cart_item_id,
CartItem.user_id == user_id
).first()
-
+
if not db_cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Элемент корзины не найден или не принадлежит пользователю"
)
-
+
# Обновляем только предоставленные поля
- update_data = cart_item.dict(exclude_unset=True)
-
+ update_data = cart_item.model_dump(exclude_unset=True)
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_cart_item, key, value)
-
+
try:
db.commit()
db.refresh(db_cart_item)
@@ -115,13 +117,13 @@ def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool:
CartItem.id == cart_item_id,
CartItem.user_id == user_id
).first()
-
+
if not db_cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Элемент корзины не найден или не принадлежит пользователю"
)
-
+
try:
db.delete(db_cart_item)
db.commit()
@@ -152,38 +154,211 @@ def get_order(db: Session, order_id: int) -> Optional[Order]:
return db.query(Order).filter(Order.id == order_id).first()
+def create_order_new(db: Session, order: OrderCreateNew, user_id: Optional[int] = None) -> Order:
+ """
+ Создает новый заказ на основе новой структуры данных.
+ Если пользователь не авторизован (user_id=None), создает нового пользователя.
+
+ Args:
+ db: Сессия базы данных
+ order: Данные для создания заказа
+ user_id: ID пользователя (None, если пользователь не авторизован)
+
+ Returns:
+ Созданный заказ
+
+ Raises:
+ HTTPException: Если произошла ошибка при создании заказа
+ """
+ # Подготовим информацию о пользователе для сохранения в JSON
+ user_info_dict = {
+ "first_name": order.user_info.first_name,
+ "last_name": order.user_info.last_name,
+ "email": order.user_info.email,
+ "phone": order.user_info.phone
+ }
+
+ # Если пользователь не авторизован, создаем нового пользователя или находим существующего
+ user_created_message = None
+ if user_id is None:
+ try:
+ user, is_new, message = user_repo.find_or_create_user_from_order(db, user_info_dict)
+ user_id = user.id
+ user_created_message = message
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Ошибка при создании пользователя: {str(e)}"
+ )
+
+ # Подготовим информацию о товарах для сохранения в JSON
+ items_json = []
+ for item in order.items:
+ # Получаем информацию о варианте и продукте
+ variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
+ if not variant:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Вариант товара с ID {item.variant_id} не найден"
+ )
+
+ product = db.query(Product).filter(Product.id == variant.product_id).first()
+ if not product:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Продукт для варианта с ID {item.variant_id} не найден"
+ )
+
+ # Получаем размер для варианта
+ size = db.query(Size).filter(Size.id == variant.size_id).first()
+ size_name = size.name if size else "Unknown"
+
+ # Получаем основное изображение продукта
+ image = db.query(ProductImage).filter(
+ ProductImage.product_id == product.id,
+ ProductImage.is_primary == True
+ ).first()
+
+ if not image:
+ # Если нет основного изображения, берем первое доступное
+ image = db.query(ProductImage).filter(
+ ProductImage.product_id == product.id
+ ).first()
+
+ image_url = image.image_url if image else None
+
+ # Добавляем информацию о товаре в JSON
+ items_json.append({
+ "product_id": product.id,
+ "variant_id": variant.id,
+ "quantity": item.quantity,
+ "price": item.price,
+ "product_name": product.name,
+ "variant_name": size_name,
+ "product_image": image_url,
+ "slug": product.slug
+ })
+
+ # Преобразуем строковое значение payment_method в значение перечисления PaymentMethod
+ payment_method_str = order.payment_method
+
+ # Прямое сопоставление с известными значениями
+ if payment_method_str == "sbp":
+ payment_method = PaymentMethod.SBP
+ elif payment_method_str == "card":
+ payment_method = PaymentMethod.CARD
+ else:
+ # Если не нашли соответствия, используем значение по умолчанию
+ payment_method = PaymentMethod.CARD
+ # Логируем ошибку
+ print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: {payment_method}")
+
+ print(f"Метод оплаты: {payment_method} {type(payment_method)}")
+ print(f"Тип payment_method_str: {type(payment_method_str)}")
+ print(f"payment_method_str: {payment_method.value}")
+ payment_method_str = payment_method.value
+ print(f"payment_method_str: {payment_method_str} {type(payment_method_str)}")
+ payment_method = payment_method_str
+
+ # Создаем новый заказ
+ new_order = Order(
+ user_id=user_id,
+ status=OrderStatus.PENDING,
+
+ # JSON с информацией о пользователе
+ user_info_json=user_info_dict,
+
+ # Информация о доставке
+ delivery_method=order.delivery.method,
+ city=order.delivery.address.city,
+ delivery_address=order.delivery.address.formatted_address or f"{order.delivery.address.city}, {order.delivery.address.street} {order.delivery.address.house}",
+
+ # Информация о CDEK
+ cdek_info=order.delivery.cdek_info.model_dump() if order.delivery.cdek_info else None,
+
+ # JSON с информацией о товарах
+ items_json=items_json,
+
+ # Информация об оплате
+ payment_method=payment_method,
+ notes=order.comment,
+
+ # Общая сумма заказа (будет обновлена после добавления товаров)
+ total_amount=0
+ )
+
+ db.add(new_order)
+ db.flush() # Получаем ID заказа
+
+ # Добавляем товары в заказ через разводную таблицу
+ for item in order.items:
+ # Создаем элемент заказа
+ order_item = OrderItem(
+ order_id=new_order.id,
+ variant_id=item.variant_id,
+ quantity=item.quantity,
+ price=item.price
+ )
+ db.add(order_item)
+ new_order.total_amount += item.price * item.quantity
+
+ # Проверяем, были ли добавлены элементы заказа
+ if new_order.total_amount == 0:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Не удалось создать заказ: корзина пуста или товары недоступны"
+ )
+
+ try:
+ db.commit()
+ db.refresh(new_order)
+
+ # Добавляем информацию о создании пользователя в заказ
+ if user_created_message:
+ new_order.user_created_message = user_created_message
+
+ return new_order
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Ошибка при создании заказа: {str(e)}"
+ )
+
+
def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
def get_all_orders(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
status: Optional[OrderStatus] = None
) -> List[Order]:
query = db.query(Order)
-
+
if status:
query = query.filter(Order.status == status)
-
+
return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
-def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
+def create_order(db: Session, order: OrderCreate, user_id: Optional[int] = None) -> Order:
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
if order.shipping_address_id:
address = db.query(UserAddress).filter(
UserAddress.id == order.shipping_address_id,
UserAddress.user_id == user_id
).first()
-
+
if not address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Указанный адрес доставки не найден"
)
-
+
# Создаем новый заказ
new_order = Order(
user_id=user_id,
@@ -193,13 +368,13 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
notes=order.notes,
total_amount=0 # Будет обновлено после добавления товаров
)
-
+
db.add(new_order)
db.flush() # Получаем ID заказа
-
+
# Получаем элементы корзины пользователя
cart_items = []
-
+
# Если указаны конкретные элементы корзины
if order.cart_items:
cart_items = db.query(CartItem).filter(
@@ -218,7 +393,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
)
-
+
# Получаем продукт для варианта
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
@@ -227,10 +402,10 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт для варианта с ID {item_data.variant_id} не найден"
)
-
+
# Определяем цену для товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
-
+
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
@@ -243,7 +418,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
# Иначе используем все элементы корзины пользователя
else:
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
-
+
# Если используем элементы корзины
if cart_items:
# Создаем элементы заказа из элементов корзины
@@ -252,15 +427,15 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
if not variant:
continue
-
+
# Получаем продукт
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
continue
-
+
# Определяем цену для товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
-
+
# Создаем элемент заказа
order_item = OrderItem(
order_id=new_order.id,
@@ -270,7 +445,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
)
db.add(order_item)
new_order.total_amount += price * cart_item.quantity
-
+
# Очищаем корзину пользователя после создания заказа
if cart_items and not order.cart_items:
# Если используем все элементы корзины, очищаем всю корзину
@@ -281,7 +456,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
CartItem.id.in_([item.id for item in cart_items]),
CartItem.user_id == user_id
).delete(synchronize_session=False)
-
+
# Проверяем, были ли добавлены элементы заказа
if new_order.total_amount == 0:
db.rollback()
@@ -289,7 +464,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
)
-
+
try:
db.commit()
db.refresh(new_order)
@@ -305,16 +480,16 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order:
"""
Обновляет информацию о заказе.
-
+
Args:
db: Сессия базы данных
order_id: ID заказа
order_update: Данные для обновления
is_admin: Флаг, указывающий, является ли пользователь администратором
-
+
Returns:
Обновленный заказ
-
+
Raises:
HTTPException: Если заказ не найден или нет прав на обновление
"""
@@ -325,7 +500,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
-
+
# Проверяем возможность обновления статуса
if order_update.status:
# Если заказ уже отменен или доставлен, нельзя менять его статус
@@ -334,14 +509,14 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно изменить статус заказа из {order.status}"
)
-
+
# Если пользователь не админ, он может только отменить заказ
if not is_admin and order_update.status != OrderStatus.CANCELLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Пользователи могут только отменить заказ"
)
-
+
# Если пользователь хочет отменить заказ, проверяем возможность отмены
if order_update.status == OrderStatus.CANCELLED:
if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]:
@@ -349,7 +524,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отменить заказ, который уже отправлен или доставлен"
)
-
+
# Проверяем другие поля для обновления
if not is_admin:
# Обычные пользователи могут обновлять только статус (отмена)
@@ -359,10 +534,10 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Пользователи не могут изменять поле {field}"
)
-
+
# Обновляем поля заказа
- update_data = order_update.dict(exclude_unset=True)
-
+ update_data = order_update.model_dump(exclude_unset=True)
+
# Если указан адрес доставки, проверяем его существование
if "shipping_address_id" in update_data:
address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first()
@@ -371,11 +546,11 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
status_code=status.HTTP_404_NOT_FOUND,
detail="Указанный адрес доставки не найден"
)
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(order, key, value)
-
+
try:
db.commit()
db.refresh(order)
@@ -395,14 +570,14 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
-
+
# Только администраторы могут удалять заказы
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для удаления заказа"
)
-
+
try:
db.delete(db_order)
db.commit()
@@ -423,29 +598,29 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
# Получаем элементы корзины пользователя
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
result = []
-
+
for cart_item in cart_items:
# Получаем вариант продукта
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
if not variant:
continue
-
+
# Получаем продукт
product = db.query(Product).filter(Product.id == variant.product_id).first()
if not product:
continue
-
+
# Получаем размер варианта
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
size_name = size.name if size else ''
-
+
# Получаем основное изображение продукта
product_image = None
primary_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id,
ProductImage.is_primary == True
).first()
-
+
if primary_image:
product_image = primary_image.image_url
else:
@@ -453,10 +628,10 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first()
if any_image:
product_image = any_image.image_url
-
+
# Определяем цену товара (используем discount_price если есть, иначе price)
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
-
+
# Формируем элемент корзины с деталями
cart_item_details = {
"id": cart_item.id,
@@ -473,9 +648,9 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
"variant_name": size_name,
"total_price": price * cart_item.quantity
}
-
+
result.append(cart_item_details)
-
+
return result
@@ -487,12 +662,12 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
return None
-
+
# Получаем информацию о пользователе
user = db.query(User).filter(User.id == order.user_id).first()
user_email = user.email if user else None
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None
-
+
# Получаем адрес доставки
shipping_address = None
if order.shipping_address_id:
@@ -508,41 +683,41 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
"country": address.country,
"is_default": address.is_default
}
-
+
# Получаем элементы заказа с информацией о продуктах
items = []
order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
-
+
for item in order_items:
# Получаем вариант продукта
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
variant_name = None
size_name = None
-
+
if variant:
# Получаем размер варианта
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
if size:
size_name = size.name
variant_name = f"{size.name}"
-
+
# Получаем информацию о продукте
product = None
product_name = "Удаленный продукт"
product_image = None
-
+
if variant:
product = db.query(Product).filter(Product.id == variant.product_id).first()
-
+
if product:
product_name = product.name
-
+
# Получаем основное изображение продукта
primary_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id,
ProductImage.is_primary == True
).first()
-
+
if primary_image:
product_image = primary_image.image_url
else:
@@ -550,10 +725,10 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
any_image = db.query(ProductImage).filter(
ProductImage.product_id == product.id
).first()
-
+
if any_image:
product_image = any_image.image_url
-
+
# Добавляем информацию об элементе заказа
items.append({
"id": item.id,
@@ -568,17 +743,43 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
"size": size_name,
"total_price": item.price * item.quantity
})
-
+
+ # Получаем информацию о пользователе из JSON
+ user_info = order.user_info_json or {}
+
# Формируем результат
- return {
+ result = {
"id": order.id,
"user_id": order.user_id,
- "user_email": user_email,
- "user_name": user_name,
"status": order.status,
"total_amount": order.total_amount,
+
+ # Информация о пользователе из JSON
+ "user_info_json": order.user_info_json,
+
+ # Извлекаем информацию о пользователе для отображения
+ "first_name": user_info.get("first_name", ""),
+ "last_name": user_info.get("last_name", ""),
+ "email": user_info.get("email", user_email),
+ "phone": user_info.get("phone", ""),
+ "user_email": user_email, # Для обратной совместимости
+ "user_name": user_name, # Для обратной совместимости
+
+ # Информация о доставке
+ "delivery_method": order.delivery_method,
+ "city": order.city,
+ "delivery_address": order.delivery_address,
+ "cdek_info": order.cdek_info,
+ "courier_info": order.courier_info,
+
+ # Информация о товарах из JSON
+ "items_json": order.items_json,
+
+ # Старые поля (для обратной совместимости)
"shipping_address_id": order.shipping_address_id,
"shipping_address": shipping_address,
+
+ # Дополнительная информация
"payment_method": order.payment_method,
"payment_details": order.payment_details,
"tracking_number": order.tracking_number,
@@ -586,4 +787,6 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
"created_at": order.created_at,
"updated_at": order.updated_at,
"items": items
- }
\ No newline at end of file
+ }
+
+ return result
\ No newline at end of file
diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py
index 3c67287..c96b4ad 100644
--- a/backend/app/repositories/user_repo.py
+++ b/backend/app/repositories/user_repo.py
@@ -1,7 +1,9 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException, status
-from typing import List, Optional
+from typing import List, Optional, Dict, Any, Tuple
+import secrets
+import string
from app.models.user_models import User, UserAddress
from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate
@@ -33,7 +35,7 @@ def create_user(db: Session, user: UserCreate) -> User:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким email уже существует"
)
-
+
# Создаем нового пользователя
hashed_password = get_password_hash(user.password)
db_user = User(
@@ -45,7 +47,7 @@ def create_user(db: Session, user: UserCreate) -> User:
is_active=user.is_active,
is_admin=user.is_admin
)
-
+
try:
db.add(db_user)
db.commit()
@@ -66,17 +68,17 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
-
+
# Обновляем только предоставленные поля
update_data = user.dict(exclude_unset=True)
-
+
# Если предоставлен новый пароль, хешируем его
if "password" in update_data and update_data["password"]:
update_data["password"] = get_password_hash(update_data.pop("password"))
-
+
# Удаляем поле password_confirm, если оно есть
update_data.pop("password_confirm", None)
-
+
# Проверяем уникальность email, если он изменяется
if "email" in update_data and update_data["email"] != db_user.email:
if get_user_by_email(db, update_data["email"]):
@@ -84,11 +86,11 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким email уже существует"
)
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_user, key, value)
-
+
try:
db.commit()
db.refresh(db_user)
@@ -108,7 +110,7 @@ def delete_user(db: Session, user_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
-
+
try:
db.delete(db_user)
db.commit()
@@ -130,6 +132,57 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
return user
+def find_or_create_user_from_order(db: Session, user_info: Dict[str, Any]) -> Tuple[User, bool, str]:
+ """
+ Ищет пользователя по email или создает нового на основе данных заказа.
+
+ Args:
+ db: Сессия базы данных
+ user_info: Информация о пользователе из заказа
+
+ Returns:
+ Кортеж (пользователь, создан_новый, сообщение)
+ """
+ email = user_info.get("email")
+ if not email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email обязателен для создания пользователя"
+ )
+
+ # Ищем пользователя по email
+ existing_user = get_user_by_email(db, email)
+ if existing_user:
+ return existing_user, False, "Пользователь с таким email уже существует"
+
+ # Генерируем случайный пароль
+ password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
+ hashed_password = get_password_hash(password)
+
+ # Создаем нового пользователя
+ new_user = User(
+ email=email,
+ password=hashed_password,
+ first_name=user_info.get("first_name", ""),
+ last_name=user_info.get("last_name", ""),
+ phone=user_info.get("phone", ""),
+ is_active=True,
+ is_admin=False
+ )
+
+ try:
+ db.add(new_user)
+ db.commit()
+ db.refresh(new_user)
+ return new_user, True, f"Создан новый пользователь с email {email}"
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Ошибка при создании пользователя"
+ )
+
+
# Функции для работы с адресами пользователей
def get_address(db: Session, address_id: int) -> Optional[UserAddress]:
return db.query(UserAddress).filter(UserAddress.id == address_id).first()
@@ -146,7 +199,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
UserAddress.user_id == user_id,
UserAddress.is_default == True
).update({"is_default": False})
-
+
db_address = UserAddress(
user_id=user_id,
address_line1=address.address_line1,
@@ -157,7 +210,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
country=address.country,
is_default=address.is_default
)
-
+
try:
db.add(db_address)
db.commit()
@@ -178,16 +231,16 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
UserAddress.id == address_id,
UserAddress.user_id == user_id
).first()
-
+
if not db_address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Адрес не найден или не принадлежит пользователю"
)
-
+
# Обновляем только предоставленные поля
update_data = address.dict(exclude_unset=True)
-
+
# Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя
if "is_default" in update_data and update_data["is_default"]:
db.query(UserAddress).filter(
@@ -195,11 +248,11 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
UserAddress.id != address_id,
UserAddress.is_default == True
).update({"is_default": False})
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_address, key, value)
-
+
try:
db.commit()
db.refresh(db_address)
@@ -217,13 +270,13 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
UserAddress.id == address_id,
UserAddress.user_id == user_id
).first()
-
+
if not db_address:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Адрес не найден или не принадлежит пользователю"
)
-
+
try:
db.delete(db_address)
db.commit()
@@ -245,10 +298,10 @@ def update_password(db: Session, user_id: int, new_password: str) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
-
+
hashed_password = get_password_hash(new_password)
db_user.password = hashed_password
-
+
try:
db.commit()
return True
@@ -264,14 +317,14 @@ def create_password_reset_token(db: Session, user_id: int) -> str:
"""Создает токен для сброса пароля"""
import secrets
import datetime
-
+
# В реальном приложении здесь должна быть модель для токенов сброса пароля
# Для примера просто генерируем случайный токен
token = secrets.token_urlsafe(32)
-
+
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
# и временем истечения срока действия
-
+
return token
@@ -279,7 +332,7 @@ def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
# В реальном приложении проверяем токен в базе данных
# и его срок действия
-
+
# Для примера просто возвращаем фиктивный ID пользователя
# В реальном приложении это должна быть проверка в базе данных
- return 1 # Фиктивный ID пользователя
\ No newline at end of file
+ return 1 # Фиктивный ID пользователя
\ No newline at end of file
diff --git a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc
index 4de452d..291f164 100644
Binary files a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc and b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc differ
diff --git a/backend/app/routers/__pycache__/order_router.cpython-310.pyc b/backend/app/routers/__pycache__/order_router.cpython-310.pyc
index 6507f2d..83202f2 100644
Binary files a/backend/app/routers/__pycache__/order_router.cpython-310.pyc and b/backend/app/routers/__pycache__/order_router.cpython-310.pyc differ
diff --git a/backend/app/routers/catalog_router.py b/backend/app/routers/catalog_router.py
index 9b2090b..0308933 100644
--- a/backend/app/routers/catalog_router.py
+++ b/backend/app/routers/catalog_router.py
@@ -209,14 +209,59 @@ async def update_product_endpoint(product_id: int, product: ProductUpdate, curre
@catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any])
async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
# Используем синхронную версию для удаления продукта
- return services.delete_product(db, product_id)
+ logging.warning(f"Удаление продукта с ID {product_id} в маршруте")
+ result = services.delete_product(db, product_id)
+
+ # Если удаление не удалось и есть сообщение об ошибке, возвращаем соответствующий статус ошибки
+ if not result.get("success") and "error" in result:
+ if "заказах" in result.get("error", ""):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"])
+ elif "не найден" in result.get("error", ""):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=result["error"])
+ else:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
+
+ return result
@catalog_router.get("/products/{product_id}", response_model=Dict[str, Any])
async def get_product_details_endpoint(*, product_id: int, db: Session = Depends(get_db)):
- # Используем синхронную версию для получения деталей продукта
- product = services.get_product_details(db, product_id)
- return {"success": True, "product": product}
+ # Сначала пробуем найти продукт в Meilisearch
+ from app.services import meilisearch_service
+
+ # Используем поиск по ID
+ result = meilisearch_service.get_product(product_id)
+
+ # Если продукт найден в Meilisearch, возвращаем его
+ if result["success"]:
+ return {
+ "success": True,
+ "product": result["product"]
+ }
+
+ # Если продукт не найден в Meilisearch, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+
+ # Проверяем существование продукта
+ from app.models.catalog_models import Product
+ product = db.query(Product).filter(Product.id == product_id).first()
+ if not product:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Продукт с ID {product_id} не найден"
+ )
+
+ # Получаем детали продукта
+ product_details = services.get_product_details(db, product_id)
+
+ # Синхронизируем продукт с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_products
+ sync_products(db)
+ except Exception as e:
+ logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
+
+ return {"success": True, "product": product_details}
@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any])
diff --git a/backend/app/routers/order_router.py b/backend/app/routers/order_router.py
index 6d58e12..d4287d7 100644
--- a/backend/app/routers/order_router.py
+++ b/backend/app/routers/order_router.py
@@ -1,10 +1,10 @@
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
-from typing import List, Optional, Dict, Any
+from typing import List, Optional, Dict, Any, Union
from app.core import get_db, get_current_active_user
from app import services
-from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order
+from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order, OrderCreateNew
from app.models.user_models import User as UserModel
from app.models.order_models import OrderStatus
@@ -13,31 +13,61 @@ order_router = APIRouter(prefix="/orders", tags=["Заказы"])
@order_router.post("/", response_model=Dict[str, Any])
async def create_order_endpoint(
- order: OrderCreate,
- current_user: UserModel = Depends(get_current_active_user),
+ order: OrderCreate,
+ # current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
- Создает новый заказ.
-
+ Создает новый заказ (старый формат).
+
- **shipping_address_id**: ID адреса доставки
- **payment_method**: Способ оплаты
- **notes**: Примечания к заказу (опционально)
- **cart_items**: Список ID элементов корзины (опционально)
- **items**: Прямые элементы заказа (опционально)
"""
- return services.create_order(db, current_user.id, order)
+ return services.create_order(db, None, order)
+
+
+@order_router.post("/new", response_model=Dict[str, Any])
+async def create_order_new_endpoint(
+ order: OrderCreateNew,
+ db: Session = Depends(get_db),
+ # current_user: Optional[UserModel] = Depends(get_current_active_user)
+ ):
+ """
+ Создает новый заказ с новой структурой данных.
+ Если пользователь не авторизован, создает нового пользователя на основе данных заказа.
+
+ - **user_info**: Информация о пользователе (имя, фамилия, email, телефон)
+ - **delivery**: Информация о доставке (метод, адрес, информация о CDEK)
+ - **items**: Товары в заказе
+ - **payment_method**: Способ оплаты
+ - **comment**: Комментарий к заказу (опционально)
+ """
+ # Если пользователь авторизован, используем его ID, иначе передаем None
+ user_id = None # current_user.id if current_user else None
+
+ # Проверяем наличие email в данных заказа
+ if not order.user_info.email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email обязателен для оформления заказа"
+ )
+
+ # Создаем заказ (если пользователь не авторизован, будет создан новый пользователь)
+ return services.create_order_new(db, user_id, order)
@order_router.get("/{order_id}", response_model=Dict[str, Any])
async def get_order_endpoint(
- order_id: int,
- current_user: UserModel = Depends(get_current_active_user),
+ order_id: int,
+ current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Получает информацию о заказе по ID.
-
+
- **order_id**: ID заказа
"""
return services.get_order(db, current_user.id, order_id, current_user.is_admin)
@@ -45,14 +75,14 @@ async def get_order_endpoint(
@order_router.put("/{order_id}", response_model=Dict[str, Any])
async def update_order_endpoint(
- order_id: int,
- order: OrderUpdate,
- current_user: UserModel = Depends(get_current_active_user),
+ order_id: int,
+ order: OrderUpdate,
+ current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Обновляет информацию о заказе.
-
+
- **order_id**: ID заказа
- **status**: Новый статус заказа (опционально, только для админов)
- **shipping_address_id**: ID нового адреса доставки (опционально, только для админов)
@@ -66,13 +96,13 @@ async def update_order_endpoint(
@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any])
async def cancel_order_endpoint(
- order_id: int,
- current_user: UserModel = Depends(get_current_active_user),
+ order_id: int,
+ current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Отменяет заказ.
-
+
- **order_id**: ID заказа
"""
return services.cancel_order(db, current_user.id, order_id)
@@ -80,15 +110,15 @@ async def cancel_order_endpoint(
@order_router.get("/", response_model=List[Dict[str, Any]])
async def get_orders_endpoint(
- skip: int = 0,
- limit: int = 100,
+ skip: int = 0,
+ limit: int = 100,
status: Optional[OrderStatus] = None,
- current_user: UserModel = Depends(get_current_active_user),
+ current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Получает список заказов пользователя.
-
+
- **skip**: Количество пропускаемых записей
- **limit**: Максимальное количество записей
- **status**: Фильтр по статусу заказа (опционально)
@@ -98,6 +128,6 @@ async def get_orders_endpoint(
orders = services.order_repo.get_all_orders(db, skip, limit, status)
else:
orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit)
-
+
# Получаем полную информацию о каждом заказе
- return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
\ No newline at end of file
+ return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
\ No newline at end of file
diff --git a/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc
index 266b4a2..650fc5c 100644
Binary files a/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc differ
diff --git a/backend/app/schemas/order_schemas.py b/backend/app/schemas/order_schemas.py
index d9939a7..9a1b1b3 100644
--- a/backend/app/schemas/order_schemas.py
+++ b/backend/app/schemas/order_schemas.py
@@ -1,4 +1,4 @@
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
@@ -62,7 +62,107 @@ class OrderItemWithProduct(OrderItem):
variant_name: Optional[str] = None
-# Схемы для заказов
+# Схемы для информации о пользователе
+class UserInfoBase(BaseModel):
+ first_name: str
+ last_name: Optional[str] = ""
+ email: str
+ phone: str
+
+
+# Схемы для адреса доставки
+class AddressBase(BaseModel):
+ city: str
+ street: Optional[str] = ""
+ house: Optional[str] = ""
+ apartment: Optional[str] = ""
+ postal_code: Optional[str] = ""
+ formatted_address: Optional[str] = ""
+
+
+# Схемы для информации о доставке CDEK
+class CDEKPvzInfo(BaseModel):
+ city_code: Optional[int] = None
+ city: Optional[str] = None
+ type: Optional[str] = None
+ postal_code: Optional[str] = None
+ country_code: Optional[str] = None
+ region: Optional[str] = None
+ have_cashless: Optional[bool] = None
+ have_cash: Optional[bool] = None
+ allowed_cod: Optional[bool] = None
+ is_dressing_room: Optional[bool] = None
+ code: Optional[str] = None
+ name: Optional[str] = None
+ address: Optional[str] = None
+ work_time: Optional[str] = None
+ location: Optional[List[float]] = None
+ weight_min: Optional[int] = None
+ weight_max: Optional[int] = None
+ dimensions: Optional[Any] = None
+
+
+class CDEKTariffInfo(BaseModel):
+ tariff_code: Optional[int] = None
+ tariff_name: Optional[str] = None
+ tariff_description: Optional[str] = None
+ delivery_mode: Optional[int] = None
+ delivery_sum: Optional[int] = None
+ period_min: Optional[int] = None
+ period_max: Optional[int] = None
+ calendar_min: Optional[int] = None
+ calendar_max: Optional[int] = None
+ delivery_date_range: Optional[Dict[str, str]] = None
+
+
+class CDEKInfo(BaseModel):
+ pvz: Optional[CDEKPvzInfo] = None
+ tariff: Optional[CDEKTariffInfo] = None
+ delivery_type: Optional[str] = None
+
+
+# Схемы для информации о доставке курьером
+class CourierInfo(BaseModel):
+ geo_lat: Optional[str] = None
+ geo_lon: Optional[str] = None
+ fias_id: Optional[str] = None
+ kladr_id: Optional[str] = None
+
+
+# Схемы для информации о доставке
+class DeliveryInfo(BaseModel):
+ method: str # cdek или courier
+ address: AddressBase
+ cdek_info: Optional[CDEKInfo] = None
+
+
+# Схемы для элементов заказа в новом формате
+class OrderItemNew(BaseModel):
+ product_id: int
+ variant_id: int
+ quantity: int
+ price: float
+
+
+# Схемы для заказов в новом формате
+class OrderCreateNew(BaseModel):
+ user_info: UserInfoBase
+ delivery: DeliveryInfo
+ items: List[OrderItemNew]
+ payment_method: str
+ comment: Optional[str] = ""
+
+ @field_validator('payment_method')
+ def validate_payment_method(cls, v):
+ # Проверяем, что значение payment_method соответствует одному из допустимых значений
+ valid_methods = ["sbp", "card"]
+ if v.lower() not in valid_methods:
+ # Если не соответствует, преобразуем к значению по умолчанию
+ return "card"
+ return v.lower() # Возвращаем значение в нижнем регистре
+
+
+# Старые схемы для заказов (для обратной совместимости)
class OrderBase(BaseModel):
shipping_address_id: Optional[int] = None
payment_method: Optional[PaymentMethod] = None
@@ -82,12 +182,35 @@ class OrderUpdate(BaseModel):
tracking_number: Optional[str] = None
notes: Optional[str] = None
+ # Новые поля
+ delivery_method: Optional[str] = None
+ city: Optional[str] = None
+ delivery_address: Optional[str] = None
+ cdek_info: Optional[Dict[str, Any]] = None
+ courier_info: Optional[Dict[str, Any]] = None
+ user_info_json: Optional[Dict[str, Any]] = None
+
class Order(OrderBase):
id: int
user_id: int
status: OrderStatus
total_amount: float
+
+ # Информация о пользователе (из JSON)
+ user_info_json: Optional[Dict[str, Any]] = None
+
+ # Информация о доставке
+ delivery_method: Optional[str] = None
+ city: Optional[str] = None
+ delivery_address: Optional[str] = None
+ cdek_info: Optional[Dict[str, Any]] = None
+ courier_info: Optional[Dict[str, Any]] = None
+
+ # Информация о товарах (из JSON)
+ items_json: Optional[List[Dict[str, Any]]] = None
+
+ # Дополнительная информация
payment_details: Optional[str] = None
tracking_number: Optional[str] = None
created_at: datetime
@@ -119,7 +242,25 @@ class OrderWithDetails(BaseModel):
user_id: int
status: OrderStatus
total_amount: float
+
+ # Информация о пользователе (из JSON)
+ user_info_json: Optional[Dict[str, Any]] = None
+
+ # Информация о доставке
+ delivery_method: Optional[str] = None
+ city: Optional[str] = None
+ delivery_address: Optional[str] = None
+ cdek_info: Optional[Dict[str, Any]] = None
+ courier_info: Optional[Dict[str, Any]] = None
+
+ # Информация о товарах (из JSON)
+ items_json: Optional[List[Dict[str, Any]]] = None
+
+ # Старые поля (для обратной совместимости)
shipping_address_id: Optional[int] = None
+ shipping_address: Optional[Dict[str, Any]] = None
+
+ # Дополнительная информация
payment_method: Optional[PaymentMethod] = None
payment_details: Optional[str] = None
tracking_number: Optional[str] = None
@@ -127,5 +268,4 @@ class OrderWithDetails(BaseModel):
created_at: datetime
updated_at: Optional[datetime] = None
user_email: Optional[str] = None
- shipping_address: Optional[Dict[str, Any]] = None
- items: List[Dict[str, Any]] = []
\ No newline at end of file
+ items: List[Dict[str, Any]] = []
\ No newline at end of file
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index 6d2ce0a..080e0fe 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -16,7 +16,7 @@ from app.services.catalog_service import (
from app.services.order_service import (
add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart,
- create_order, get_order, update_order, cancel_order,
+ create_order, create_order_new, get_order, update_order, cancel_order,
)
from app.services.review_service import (
diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc
index 841da53..e0cc695 100644
Binary files a/backend/app/services/__pycache__/__init__.cpython-310.pyc and b/backend/app/services/__pycache__/__init__.cpython-310.pyc differ
diff --git a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc
index 3118b6a..181addb 100644
Binary files a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc and b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc differ
diff --git a/backend/app/services/__pycache__/order_service.cpython-310.pyc b/backend/app/services/__pycache__/order_service.cpython-310.pyc
index 5119498..7e44677 100644
Binary files a/backend/app/services/__pycache__/order_service.cpython-310.pyc and b/backend/app/services/__pycache__/order_service.cpython-310.pyc differ
diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py
index c3f6360..38222e8 100644
--- a/backend/app/services/catalog_service.py
+++ b/backend/app/services/catalog_service.py
@@ -308,8 +308,10 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
"""Удалить продукт"""
try:
+ logging.warning(f"Удаление продукта с ID {product_id}")
# Проверяем, что продукт существует
db_product = catalog_repo.get_product(db, product_id)
+ logging.warning(f"Продукт: {db_product}, {db_product.id if db_product else 'не найден'}")
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -318,7 +320,7 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
# Удаляем продукт
success = catalog_repo.delete_product(db, product_id)
-
+ logging.warning(f"Удаление продукта с ID {product_id} успешно: {success}")
# Удаляем продукт из Meilisearch
if success:
meilisearch_service.delete_product(product_id)
@@ -327,11 +329,14 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
"success": success
}
except HTTPException as e:
+ logging.error(f"HTTP ошибка при удалении продукта с ID {product_id}: {e.detail}")
return {
"success": False,
"error": e.detail
}
except Exception as e:
+ logging.error(f"Неожиданная ошибка при удалении продукта с ID {product_id}: {str(e)}")
+ logging.error(traceback.format_exc())
return {
"success": False,
"error": str(e)
@@ -990,6 +995,10 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"updated_at": image.updated_at
})
+ # Индексируем продукт в Meilisearch
+ product_data = format_product_for_meilisearch(db_product, variants, images)
+ meilisearch_service.index_product(product_data)
+
return {
"success": True,
"id": db_product.id,
@@ -1150,20 +1159,27 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
# Коммитим транзакцию
db.commit()
+ # Обновляем продукт в Meilisearch
+ # Получаем все варианты и изображения продукта после обновления
+ updated_variants = db_product.variants
+ updated_images = db_product.images
+ product_data = format_product_for_meilisearch(db_product, updated_variants, updated_images)
+ meilisearch_service.index_product(product_data)
+
# Собираем полный ответ
- product_schema = Product.from_orm(db_product)
+ product_schema = Product.model_validate(db_product)
return {
"success": True,
"product": product_schema,
"variants": {
- "updated": [ProductVariant.from_orm(variant) for variant in variants_updated],
- "created": [ProductVariant.from_orm(variant) for variant in variants_created],
+ "updated": [ProductVariant.model_validate(variant) for variant in variants_updated],
+ "created": [ProductVariant.model_validate(variant) for variant in variants_created],
"removed": variants_removed
},
"images": {
- "updated": [ProductImage.from_orm(image) for image in images_updated],
- "created": [ProductImage.from_orm(image) for image in images_created],
+ "updated": [ProductImage.model_validate(image) for image in images_updated],
+ "created": [ProductImage.model_validate(image) for image in images_created],
"removed": images_removed
}
}
diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py
index 6b3ccd4..9bcb770 100644
--- a/backend/app/services/order_service.py
+++ b/backend/app/services/order_service.py
@@ -1,9 +1,9 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Optional
from app.repositories import order_repo, content_repo
-from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order
+from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order, OrderCreateNew
from app.schemas.content_schemas import AnalyticsLogCreate
from app.models.order_models import OrderStatus
@@ -14,7 +14,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
Добавляет товар в корзину пользователя.
"""
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
-
+
# Логируем событие добавления в корзину
log_data = AnalyticsLogCreate(
user_id=user_id,
@@ -25,7 +25,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
}
)
content_repo.log_analytics_event(db, log_data)
-
+
return {
"success": True,
"message": "Товар успешно добавлен в корзину",
@@ -43,7 +43,7 @@ def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: Ca
Обновляет количество товара в корзине пользователя.
"""
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
-
+
return {
"success": True,
"message": "Товар в корзине успешно обновлен",
@@ -61,7 +61,7 @@ def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str,
Удаляет товар из корзины пользователя.
"""
success = order_repo.delete_cart_item(db, cart_item_id, user_id)
-
+
return {
"success": success,
"message": "Товар успешно удален из корзины" if success else "Не удалось удалить товар из корзины"
@@ -73,7 +73,7 @@ def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
Очищает корзину пользователя.
"""
success = order_repo.clear_cart(db, user_id)
-
+
return {
"success": success,
"message": "Корзина успешно очищена" if success else "Не удалось очистить корзину"
@@ -86,10 +86,10 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
"""
# Получаем элементы корзины с деталями продуктов
cart_items = order_repo.get_cart_with_product_details(db, user_id)
-
+
# Рассчитываем общую сумму корзины
total_amount = sum(item["total_price"] for item in cart_items)
-
+
return {
"items": cart_items,
"total_amount": total_amount,
@@ -98,13 +98,13 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
# Сервисы заказов
-def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
+def create_order(db: Session, user_id: Optional[int], order: OrderCreate) -> Dict[str, Any]:
"""
Создает новый заказ на основе корзины или переданных элементов.
"""
try:
new_order = order_repo.create_order(db, order, user_id)
-
+
# Логируем событие создания заказа
log_data = AnalyticsLogCreate(
user_id=user_id,
@@ -115,10 +115,10 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
}
)
content_repo.log_analytics_event(db, log_data)
-
+
# Получаем заказ с деталями
order_details = order_repo.get_order_with_details(db, new_order.id)
-
+
return {
"success": True,
"message": "Заказ успешно создан",
@@ -131,26 +131,103 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
)
+def create_order_new(db: Session, user_id: Optional[int], order: OrderCreateNew) -> Dict[str, Any]:
+ """
+ Создает новый заказ на основе новой структуры данных.
+ Если пользователь не авторизован (user_id=None), создает нового пользователя.
+
+ Args:
+ db: Сессия базы данных
+ user_id: ID пользователя (None, если пользователь не авторизован)
+ order: Данные для создания заказа
+
+ Returns:
+ Словарь с информацией о созданном заказе
+
+ Raises:
+ HTTPException: Если произошла ошибка при создании заказа
+ """
+ try:
+
+ print(f"Метод оплаты: {order.payment_method} {type(order.payment_method)} {order.payment_method.strip().lower()}")
+ # Проверяем метод оплаты
+ if order.payment_method.strip().lower() not in ["sbp", "card"]:
+ # Если метод оплаты не поддерживается, используем значение по умолчанию
+ order.payment_method = "card"
+ print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: card")
+
+ # Создаем заказ с новой структурой данных
+ new_order = order_repo.create_order_new(db, order, user_id)
+
+ # Получаем сообщение о создании пользователя, если оно есть
+ user_created_message = getattr(new_order, "user_created_message", None)
+
+ # Логируем событие создания заказа
+ log_data = AnalyticsLogCreate(
+ user_id=new_order.user_id, # Используем ID пользователя из заказа
+ event_type="order_created",
+ additional_data={
+ "order_id": new_order.id,
+ "total_amount": new_order.total_amount,
+ "delivery_method": new_order.delivery_method
+ }
+ )
+ content_repo.log_analytics_event(db, log_data)
+
+ # Получаем заказ с деталями
+ order_details = order_repo.get_order_with_details(db, new_order.id)
+
+ # Формируем ответ для фронтенда
+ response = {
+ "success": True,
+ "message": "Заказ успешно создан",
+ "order": order_details,
+ "order_id": new_order.id # Добавляем ID заказа для удобства
+ }
+
+ # Если был создан новый пользователь, добавляем сообщение об этом
+ if user_created_message:
+ response["user_message"] = user_created_message
+
+ return response
+ except Exception as e:
+ # Логируем ошибку для отладки
+ print(f"Ошибка при создании заказа: {str(e)}")
+
+ # Проверяем, содержит ли ошибка информацию о проблеме с методом оплаты
+ error_message = str(e)
+ if "invalid input value for enum paymentmethod" in error_message.lower():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Неверный метод оплаты. Допустимые значения: 'sbp', 'card'"
+ )
+
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Ошибка при создании заказа: {str(e)}"
+ )
+
+
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
"""
Получает информацию о заказе по ID.
"""
# Получаем заказ с деталями
order_details = order_repo.get_order_with_details(db, order_id)
-
+
if not order_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
-
+
# Проверяем права доступа
if not is_admin and order_details["user_id"] != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для просмотра этого заказа"
)
-
+
return {
"success": True,
"order": order_details
@@ -168,27 +245,27 @@ def update_order(db: Session, user_id: int, order_id: int, order_update: OrderUp
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
-
+
# Проверяем права доступа
if not is_admin and order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для обновления этого заказа"
)
-
+
# Обычные пользователи могут только отменить заказ
if not is_admin and (order_update.status and order_update.status != OrderStatus.CANCELLED):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Пользователи могут только отменить заказ"
)
-
+
# Обновляем заказ
- updated_order = order_repo.update_order(db, order_id, order_update, is_admin)
-
+ order_repo.update_order(db, order_id, order_update, is_admin)
+
# Получаем обновленный заказ с деталями
order_details = order_repo.get_order_with_details(db, order_id)
-
+
return {
"success": True,
"message": "Заказ успешно обновлен",
@@ -207,21 +284,21 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
status_code=status.HTTP_404_NOT_FOUND,
detail="Заказ не найден"
)
-
+
# Проверяем права доступа
if order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для отмены этого заказа"
)
-
+
# Отменяем заказ
order_update = OrderUpdate(status=OrderStatus.CANCELLED)
order_repo.update_order(db, order_id, order_update, False)
-
+
# Получаем обновленный заказ с деталями
order_details = order_repo.get_order_with_details(db, order_id)
-
+
# Логируем событие отмены заказа
log_data = AnalyticsLogCreate(
user_id=user_id,
@@ -231,9 +308,9 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
}
)
content_repo.log_analytics_event(db, log_data)
-
+
return {
"success": True,
"message": "Заказ успешно отменен",
"order": order_details
- }
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/backend/docs/api_documentation.md b/backend/docs/api_documentation.md
index a6fd9bd..e841fd2 100644
--- a/backend/docs/api_documentation.md
+++ b/backend/docs/api_documentation.md
@@ -9,31 +9,32 @@
6. [Отзывы](#отзывы)
7. [Контент](#контент)
8. [Аналитика](#аналитика)
+9. [Доставка](#доставка)
## Аутентификация
Базовый URL: `/auth`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет |
-| POST | `/login` | Вход в систему | `username` (email), `password` | Нет |
-| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет |
-| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет |
-| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет | `{"success": true, "user": {...}}` |
+| POST | `/login` | Вход в систему | `username` (email), `password` | Нет | `{"access_token": "...", "token_type": "bearer"}` |
+| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет | `{"success": true, "message": "..."}` |
+| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет | `{"success": true, "message": "..."}` |
+| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да | `{"success": true, "message": "..."}` |
## Пользователи
Базовый URL: `/users`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/me` | Получение профиля текущего пользователя | - | Да |
-| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да |
-| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да |
-| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да |
-| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да |
-| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/me` | Получение профиля текущего пользователя | - | Да | `{"success": true, "user": {...}}` |
+| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да | `{"success": true, "user": {...}}` |
+| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
+| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
+| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да | `{"success": true, "message": "..."}` |
+| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) | `{"success": true, "user": {...}}` |
## Каталог
@@ -41,117 +42,127 @@
### Коллекции
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/collections` | Получение списка коллекций | `skip`, `limit` | Нет |
-| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) |
-| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) |
-| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/collections` | Получение списка коллекций | `skip`, `limit`, `search`, `is_active` | Нет | `{"success": true, "collections": [...], "total": number}` |
+| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
+| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
+| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) | `{"success": true, "message": "..."}` |
### Категории
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/categories` | Получение дерева категорий | - | Нет |
-| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) |
-| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) |
-| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/categories` | Получение списка категорий | `skip`, `limit`, `search`, `parent_id`, `is_active` | Нет | `{"success": true, "categories": [...], "total": number}` |
+| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
+| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
+| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) | `{"success": true, "message": "..."}` |
### Размеры
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/sizes` | Получение списка размеров | `skip`, `limit` | Нет |
-| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет |
-| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) |
-| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) |
-| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/sizes` | Получение списка размеров | `skip`, `limit`, `search` | Нет | `{"success": true, "sizes": [...], "total": number}` |
+| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет | `{"success": true, "size": {...}}` |
+| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
+| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
+| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) | `{"success": true}` |
### Продукты
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `include_variants` | Нет |
-| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет |
-| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет |
-| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
-| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
-| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `sort_by`, `sort_order` | Нет | `{"success": true, "products": [...], "total": number, "skip": number, "limit": number}` |
+| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет | `{"success": true, "product": {...}}` |
+| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет | `{"success": true, "product": {...}}` |
+| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
+| POST | `/products/complete` | Создание продукта с вариантами и изображениями | `ProductCreateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}}` |
+| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
+| PUT | `/products/{product_id}/complete` | Обновление продукта с вариантами и изображениями | `ProductUpdateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}, "variants": {...}, "images": {...}}` |
+| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
### Варианты продуктов
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
-| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
-| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
+| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
+| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
### Изображения продуктов
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file`, `is_primary` | Да (админ) |
-| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) |
-| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file` (multipart/form-data), `is_primary` (form field) | Да (админ) | `{"success": true, "image": {...}}` |
+| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) | `{"success": true, "image": {...}}` |
+| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
## Корзина
Базовый URL: `/cart`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/` | Получение корзины пользователя | - | Да |
-| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (product_variant_id, quantity) | Да |
-| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да |
-| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да |
-| DELETE | `/clear` | Очистка корзины | - | Да |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/` | Получение корзины пользователя | - | Да | `{"success": true, "cart": {"items": [...], "items_count": number, "total_amount": number}}` |
+| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (variant_id, quantity) | Да | `{"success": true, "cart_item": {...}}` |
+| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да | `{"success": true, "cart_item": {...}}` |
+| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да | `{"success": true, "message": "..."}` |
+| DELETE | `/clear` | Очистка корзины | - | Да | `{"success": true, "message": "..."}` |
## Заказы
Базовый URL: `/orders`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да |
-| GET | `/{order_id}` | Получение информации о заказе | - | Да |
-| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method) | Да |
-| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, tracking_number) | Да (админ) |
-| POST | `/{order_id}/cancel` | Отмена заказа | - | Да |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да | `[{"id": number, "status": string, "total_amount": number, ...}]` |
+| GET | `/{order_id}` | Получение информации о заказе | - | Да | `{"success": true, "order": {...}}` |
+| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method, notes, cart_items, items) | Да | `{"success": true, "order": {...}}` |
+| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, shipping_address_id, payment_method, payment_details, tracking_number, notes) | Да (админ) | `{"success": true, "order": {...}}` |
+| POST | `/{order_id}/cancel` | Отмена заказа | - | Да | `{"success": true, "message": "..."}` |
## Отзывы
Базовый URL: `/reviews`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет |
-| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да |
-| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да |
-| DELETE | `/{review_id}` | Удаление отзыва | - | Да |
-| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет | `{"success": true, "reviews": [...], "total": number}` |
+| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да | `{"success": true, "review": {...}}` |
+| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да | `{"success": true, "review": {...}}` |
+| DELETE | `/{review_id}` | Удаление отзыва | - | Да | `{"success": true, "message": "..."}` |
+| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) | `{"success": true, "review": {...}}` |
## Контент
Базовый URL: `/content`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет |
-| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет |
-| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет |
-| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) |
-| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) |
-| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет | `{"success": true, "pages": [...], "total": number}` |
+| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет | `{"success": true, "page": {...}}` |
+| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет | `{"success": true, "page": {...}}` |
+| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
+| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
+| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) | `{"success": true, "message": "..."}` |
## Аналитика
Базовый URL: `/analytics`
-| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
-|-------|----------|----------|-------------------|------------------------|
-| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет |
-| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) |
-| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) |
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет | `{"success": true, "log": {...}}` |
+| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) | `{"success": true, "logs": [...], "total": number}` |
+| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) | `{"success": true, "report": {...}}` |
+
+## Доставка
+
+Базовый URL: `/delivery`
+
+| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
+|-------|----------|----------|-------------------|------------------------|------------------|
+| POST | `/cdek` | Обработка запросов виджета CDEK | `action` (offices, calculate) и другие параметры в зависимости от действия | Нет | Ответ от API CDEK |
## Модели данных
@@ -174,12 +185,14 @@
- `ProductVariantUpdate`: product_id, size_id, sku, stock, is_active
- `ProductImageCreate`: product_id, image_url, alt_text, is_primary
- `ProductImageUpdate`: alt_text, is_primary
+- `ProductCreateComplete`: включает данные продукта, список вариантов и изображений
+- `ProductUpdateComplete`: включает данные продукта, список вариантов и изображений для обновления
### Корзина и заказы
-- `CartItemCreate`: product_variant_id, quantity
+- `CartItemCreate`: variant_id, quantity
- `CartItemUpdate`: quantity
-- `OrderCreate`: shipping_address_id, payment_method
-- `OrderUpdate`: status, tracking_number
+- `OrderCreate`: shipping_address_id, payment_method, notes, cart_items, items
+- `OrderUpdate`: status, shipping_address_id, payment_method, payment_details, tracking_number, notes
### Отзывы
- `ReviewCreate`: product_id, rating, text
@@ -188,4 +201,4 @@
### Контент
- `PageCreate`: title, slug, content, is_published
- `PageUpdate`: title, slug, content, is_published
-- `AnalyticsLogCreate`: event_type, event_data, user_id
\ No newline at end of file
+- `AnalyticsLogCreate`: event_type, event_data, user_id
\ No newline at end of file
diff --git a/backend/requirements-new.txt b/backend/requirements-new.txt
new file mode 100644
index 0000000..777e2ae
--- /dev/null
+++ b/backend/requirements-new.txt
@@ -0,0 +1,169 @@
+# Основные зависимости
+fastapi==0.109.0
+uvicorn==0.34.0
+pydantic==2.5.3
+pydantic-settings==2.1.0
+pydantic_core==2.14.6
+starlette==0.35.1
+typing_extensions==4.12.2
+
+# Валидация данных
+marshmallow==3.21.1
+cerberus==1.3.5
+jsonschema==4.21.1
+
+# База данных
+sqlalchemy==2.0.25
+asyncpg==0.30.0
+psycopg2-binary==2.9.9
+alembic==1.13.1
+greenlet==3.0.3 # Требуется для SQLAlchemy с asyncpg
+
+# Аутентификация и безопасность
+python-jose==3.3.0
+passlib==1.7.4
+bcrypt<4.0.0
+python-multipart==0.0.6
+email-validator==2.1.0
+pycryptodome==3.21.0
+pycryptodomex==3.21.0
+PyJWT==2.8.0
+
+# HTTP и файлы
+httpx==0.28.1
+aiofiles==24.1.0
+anyio==4.2.0 # Требуется для FastAPI и httpx
+
+# CORS
+django-cors-headers==4.3.1 # Для Django проектов
+flask-cors==4.0.0 # Для Flask проектов
+
+# WebSocket
+websocket-client==1.8.0
+trio==0.28.0
+trio-websocket==0.11.1
+wsproto==1.2.0
+python-socketio==5.11.1
+websockets==12.0
+
+# Поиск и индексация
+meilisearch==0.28.0
+
+# Облачное хранилище
+boto3==1.34.69
+
+# Кэширование
+redis==5.0.3
+aioredis==2.0.1
+
+# Очереди сообщений
+amqp==5.3.1
+kombu==5.4.2
+aiokafka==0.10.0
+
+# Асинхронные задачи
+taskiq==0.11.10
+taskiq-dependencies==1.5.6
+
+# Многопоточность и параллелизм
+futures==3.1.1
+concurrent-log-handler==0.9.24
+
+# API документация
+openapi-spec-validator==0.7.1
+swagger-ui-bundle==0.0.9
+pydantic-openapi-schema==1.0.1
+
+# GraphQL
+strawberry-graphql==0.219.2
+graphene==3.3
+graphene-sqlalchemy==2.3.0
+
+# Электронная почта
+fastapi-mail==1.4.1
+
+# Работа с датами и временем
+python-dateutil==2.9.0.post0
+pytz==2025.1
+
+# Интернационализация и локализация
+babel==2.14.0
+python-gettext==5.0
+polib==1.2.0
+
+# Анализ данных
+pandas==2.2.3
+numpy==2.2.2
+
+# Машинное обучение
+scikit-learn==1.4.1
+joblib==1.3.2
+
+# Обработка естественного языка (NLP)
+nltk==3.8.1
+spacy==3.7.4
+
+# Визуализация данных
+matplotlib==3.8.4
+seaborn==0.13.2
+plotly==5.22.0
+
+# Работа с Excel и другими форматами файлов
+openpyxl==3.1.5
+xlrd==1.2.0
+XlsxWriter==3.2.2
+
+# Работа с PDF и документами
+pdfminer.six==20191110
+python-pptx==0.6.23
+PyPDF2==3.0.1
+reportlab==4.1.0
+python-docx==1.1.0
+docx2txt==0.8
+
+# Работа с архивами
+py7zr==0.22.0
+pyzstd==0.16.2
+rarfile==4.2
+
+# Работа с XML и HTML
+lxml==5.3.0
+beautifulsoup4==4.12.3
+soupsieve==2.6
+
+# Веб-скрапинг
+scrapy==2.11.1
+requests-html==0.10.0
+
+# Работа с форматами данными
+pyyaml==6.0.1
+toml==0.10.2
+
+# Работа с геоданными
+geopy==2.4.1
+
+# Интеграция с мессенджерами
+aiogram==3.17.0
+aiohttp==3.11.11
+
+# Планировщик задач
+pycron==3.1.2
+
+# Мониторинг и логирование
+prometheus-client==0.20.0
+sentry-sdk==2.12.1
+loguru==0.7.2
+structlog==24.1.0
+python-json-logger==2.0.7
+
+# Тестирование
+pytest==8.0.2
+pytest-asyncio==0.23.5
+pytest-cov==5.0.0
+httpx==0.28.1 # Для тестирования FastAPI
+
+# Утилиты
+python-dotenv==1.0.0
+setuptools==75.8.0
+jinja2==3.1.3 # Для шаблонов (если используется)
+pillow==11.1.0 # Для обработки изображений
diff --git a/backend/requirements.txt b/backend/requirements.txt
index d8a6dca..45aaebb 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -5,7 +5,6 @@ aiohttp==3.11.11
aiosignal==1.3.2
amqp==5.3.1
annotated-types==0.6.0
-anthropic==0.45.2
anyio==4.2.0
argcomplete==1.10.3
asyncpg==0.30.0
@@ -27,7 +26,6 @@ extract-msg==0.28.7
fastapi==0.109.0
frozenlist==1.5.0
greenlet==3.0.3
-groq==0.16.0
h11==0.14.0
httpcore==1.0.7
httpx==0.28.1
@@ -43,7 +41,6 @@ multidict==6.1.0
multivolumefile==0.2.3
numpy==2.2.2
olefile==0.47
-openai==1.61.0
openpyxl==3.1.5
outcome==1.3.0.post0
packaging==24.2
diff --git a/docker-compose.yml b/docker-compose.yml
index d53d20e..0310f35 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,101 +1,37 @@
-version: '3.8'
+version: '3.8' # Compose Specification v3.8
+
services:
- backend:
+ fastapi:
build:
- context: .
- dockerfile: Dockerfile.backend
- container_name: dressed-for-success-backend
- hostname: backend
+ context: ./backend # директория с Dockerfile FastAPI
+ volumes:
+ - ./backend:/app # монтируем код для live-reload
+ command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # hot-reload
ports:
- "8000:8000"
- environment:
- - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
- - DEBUG=0
- - SECRET_KEY=supersecretkey
- - UPLOAD_DIRECTORY=/app/uploads
- - MEILISEARCH_URL=http://meilisearch:7700
- - MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
- depends_on:
- postgres:
- condition: service_healthy
- meilisearch:
- condition: service_healthy
- volumes:
- - ./backend/uploads:/app/uploads
networks:
- app_network:
- aliases:
- - backend
- dns_search: .
- restart: always
- healthcheck:
- test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
- interval: 10s
- timeout: 5s
- retries: 3
- start_period: 15s
+ - app-network
- frontend:
- build:
- context: .
- dockerfile: Dockerfile.frontend
- container_name: dressed-for-success-frontend
- hostname: frontend
- expose:
- - "3000"
- environment:
- - NEXT_PUBLIC_API_URL=http://0.0.0.0:8000/api
- - NEXT_PUBLIC_BASE_URL=http://0.0.0.0:8000
- - NODE_ENV=production
- depends_on:
- backend:
- condition: service_healthy
+ php:
+ image: php:8.2-apache # официальный PHP Apache образ
+ volumes:
+ - ./php:/var/www/html
+ ports:
+ - "8081:80"
networks:
- app_network:
- aliases:
- - frontend
- dns_search: .
- restart: always
+ - app-network
nginx:
- image: nginx:alpine
- container_name: dressed-for-success-nginx
+ image: nginx:stable
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # монтируем конфиг NGINX
ports:
- "80:80"
- # - "443:443" # Раскомментируйте для HTTPS
- volumes:
- - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- - ./backend/uploads:/app/uploads:ro
depends_on:
- - frontend
- - backend
+ - fastapi
+ - php
networks:
- - app_network
- restart: always
-
- postgres:
- image: postgres:15
- container_name: dressed-for-success-db
- hostname: postgres
- environment:
- POSTGRES_DB: shop_db
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- ports:
- - "5434:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
- interval: 5s
- timeout: 5s
- retries: 5
- networks:
- app_network:
- aliases:
- - postgres
- dns_search: .
- restart: always
+ - app-network
meilisearch:
image: getmeili/meilisearch:latest
@@ -115,22 +51,16 @@ services:
timeout: 5s
retries: 3
start_period: 15s
+ restart: always
networks:
- app_network:
+ app-network:
aliases:
- meilisearch
dns_search: .
- restart: always
-
-
-networks:
- app_network:
- driver: bridge
volumes:
- postgres_data:
- driver: local
- backend_uploads:
- driver: local
meilisearch_data:
- driver: local
\ No newline at end of file
+
+networks:
+ app-network: # общая сеть для контейнеров
+ driver: bridge
diff --git a/frontend/.DS_Store b/frontend/.DS_Store
index b10d16a..e424019 100644
Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ
diff --git a/frontend/app/(main)/.DS_Store b/frontend/app/(main)/.DS_Store
index 08b5284..cbb7f33 100644
Binary files a/frontend/app/(main)/.DS_Store and b/frontend/app/(main)/.DS_Store differ
diff --git a/frontend/app/(main)/cart/page.tsx b/frontend/app/(main)/cart/page.tsx
index 8462b1f..6102782 100644
--- a/frontend/app/(main)/cart/page.tsx
+++ b/frontend/app/(main)/cart/page.tsx
@@ -354,7 +354,7 @@ export default function CartPage() {
disabled={cart.items.length === 0}
asChild
>
-
+
Оформить заказ
@@ -432,7 +432,7 @@ export default function CartPage() {
disabled={cart.items.length === 0}
asChild
>
-
+
Оформить заказ
diff --git a/frontend/app/(main)/catalog/[slug]/page.tsx b/frontend/app/(main)/catalog/[slug]/page.tsx
index 533e348..0203c24 100644
--- a/frontend/app/(main)/catalog/[slug]/page.tsx
+++ b/frontend/app/(main)/catalog/[slug]/page.tsx
@@ -12,11 +12,12 @@ import { ImageSlider } from "@/components/product/ImageSlider"
import { ProductDetails as ProductDetailsComponent } from "@/components/product/ProductDetails"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { motion } from "framer-motion"
+import { motion, AnimatePresence } from "framer-motion"
import { useInView } from "react-intersection-observer"
import { Button } from "@/components/ui/button"
import { useCart } from "@/hooks/useCart"
import { toast } from "sonner"
+import { useWishlist } from "@/hooks/use-wishlist"
interface ProductPageProps {
params: {
@@ -32,18 +33,19 @@ export default function ProductPage({ params }: ProductPageProps) {
const [imageRef, imageInView] = useInView({ triggerOnce: true, threshold: 0.1 })
const [detailsRef, detailsInView] = useInView({ triggerOnce: true, threshold: 0.1 })
const { addToCart, loading: cartLoading } = useCart()
+ const { toggleItem, isInWishlist } = useWishlist();
+
+ // layout и хлебные крошки всегда видимы
+ // основной контент fade-in после загрузки
- // Загрузка товара при монтировании компонента
useEffect(() => {
const fetchProduct = async () => {
try {
setLoading(true)
const productData = await catalogService.getProductBySlug(params.slug)
-
if (!productData) {
return notFound()
}
-
setProduct(productData)
} catch (err) {
console.error("Ошибка при загрузке товара:", err)
@@ -52,34 +54,13 @@ export default function ProductPage({ params }: ProductPageProps) {
setLoading(false)
}
}
-
fetchProduct()
}, [params.slug])
- // Если все еще загружается, показываем скелетон
- if (loading) {
- return
- }
-
- // Если произошла ошибка, показываем сообщение
- if (error || !product) {
- return (
-
-
-
Товар не найден
-
К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.
-
-
Вернуться в каталог
-
-
-
- )
- }
-
return (
- {/* Навигация */}
+ {/* Навигация и хлебные крошки */}
Вернуться в каталог
-
{/* Хлебные крошки */}
-
- Главная
-
+ Главная
-
- Каталог
-
- {product.category_name && (
+ Каталог
+ {product?.category_name && (
<>
)}
- {product.name}
+ {product?.name || ''}
-
{/* Основной контент товара */}
- {/* Блок изображений */}
-
- }>
-
-
- {/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */}
- {new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
-
- Новинка
-
- )}
- {product.discount_price && (
-
- -{Math.round((1 - product.discount_price / product.price) * 100)}%
-
- )}
-
-
- {/* Кнопка добавления в избранное */}
-
- toast.success(`${product.name} добавлен в избранное`)}
- >
-
- Добавить в избранное
-
-
-
-
-
-
-
-
- {/* Блок информации о товаре */}
-
-
- {/* Категория и название */}
-
- {product.category_name && (
-
- {product.category_name}
+ {/* Блок изображений и инфо */}
+
+ {loading ? (
+
+
+
+ ) : error || !product ? (
+
+
+
+
Товар не найден
+
К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.
+
+
Вернуться в каталог
+
- )}
-
-
{product.name}
-
- {/* Цена */}
-
- {product.discount_price ? (
- <>
-
- {formatPrice(product.discount_price)}
-
-
- {formatPrice(product.price)}
-
- >
- ) : (
-
- {formatPrice(product.price)}
-
- )}
-
-
-
- {/* Компонент выбора размера и количества */}
-
-
-
-
- {/* Описание товара */}
-
-
-
-
- Описание
-
-
- Уход
-
-
-
-
-
- {product.description ? (
- typeof product.description === 'string' ? (
-
- ) : (
- Описание недоступно
- )
- ) : (
- Описание отсутствует
- )}
-
-
-
- {product.care_instructions ? (
- typeof product.care_instructions === 'string' ? (
-
- ) : (
- Инструкции по уходу недоступны
- )
- ) : (
- Инструкции по уходу отсутствуют
- )}
-
-
-
-
-
- {/* Информация о доставке и возврате */}
-
-
-
-
-
+ ) : (
+
+
+ {/* Блок изображений */}
+
+
+
+ {product && new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
+ Новинка
+ )}
+ {product?.discount_price && (
+
+ -{Math.round((1 - (product.discount_price! / product.price)) * 100)}%
+
+ )}
+
+ {/* Кнопка добавления в избранное */}
+
+ {product && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleItem({
+ id: product.id,
+ product_id: product.id,
+ name: product.name,
+ price: product.price,
+ discount_price: product.discount_price,
+ image: product.primary_image || (product.images && product.images[0]?.image_url) || '',
+ slug: product.slug,
+ });
+ }}
+ >
+
+ {isInWishlist(product.id) ? 'Убрать из избранного' : 'Добавить в избранное'}
+
+ )}
+
+
-
Доставка
-
- Доставка по всей России 1-3 рабочих дня
-
-
-
-
-
-
-
+ {/* Блок информации о товаре */}
+
+
+ {product?.category_name && (
+
+ {product.category_name}
+
+ )}
+
{product?.name}
+ {/* Цена */}
+
+ {product.discount_price ? (
+ <>
+
+ {formatPrice(product.discount_price)}
+
+
+ {formatPrice(product.price)}
+
+ >
+ ) : (
+
+ {formatPrice(product.price)}
+
+ )}
+
+
+ {/* Компонент выбора размера и количества */}
+
+
+ {/* Описание товара */}
+
+
+
+
+ Описание
+
+
+ Уход
+
+
+
+
+
+ {product.description ? (
+ typeof product.description === 'string' ? (
+
+ ) : (
+ Описание недоступно
+ )
+ ) : (
+ Описание отсутствует
+ )}
+
+
+
+ {product.care_instructions ? (
+ typeof product.care_instructions === 'string' ? (
+
+ ) : (
+ Инструкции по уходу недоступны
+ )
+ ) : (
+ Инструкции по уходу отсутствуют
+ )}
+
+
+
+
+ {/* Информация о доставке и возврате */}
+
+
+
+
+ Доставка по всей России 1-3 рабочих дня
+
+
+
+
+
+
+ Бесплатный возврат в течение 14 дней
+
+
+
-
Возврат
-
- Бесплатный возврат в течение 14 дней
-
-
-
+ )}
+
diff --git a/frontend/app/(main)/catalog/page.tsx b/frontend/app/(main)/catalog/page.tsx
index 15f1c07..5b4a0a8 100644
--- a/frontend/app/(main)/catalog/page.tsx
+++ b/frontend/app/(main)/catalog/page.tsx
@@ -75,6 +75,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
const [productsError, setProductsError] = useState
(null)
+ // Сохраняем последние успешные продукты для плавного UX
+ const [lastProducts, setLastProducts] = useState([])
+
// Получаем данные из хука
const { categories, collections, sizes, loading, error } = catalogData;
@@ -178,21 +181,17 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
try {
setIsLoadingProducts(true);
setProductsError(null);
-
// Получаем продукты через кэширующий сервис
const response = await catalogData.fetchProducts(queryParams);
-
if (!response || !response.products) {
setProductsError("Товары не найдены");
setProducts([]);
setTotalProducts(0);
return;
}
-
- // Фильтрация по размерам теперь выполняется на бэкенде
setProducts(response.products as ExtendedProduct[]);
setTotalProducts(response.total);
-
+ setLastProducts(response.products as ExtendedProduct[]); // сохраняем последние успешные
} catch (err) {
console.error("Ошибка при загрузке продуктов:", err);
setProductsError("Не удалось загрузить продукты");
@@ -200,9 +199,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
setIsLoadingProducts(false);
}
};
-
loadProducts();
- // Зависим только от параметров запроса и самой функции fetchProducts
}, [queryParams, catalogData.fetchProducts]);
// Обработчик выбора категории
@@ -452,8 +449,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
// Компонент сетки товаров
const ProductGrid = ({ products, loading }: { products: ExtendedProduct[], loading: boolean }) => {
- if (loading) {
- // Отображаем скелетон для загрузки
+ const showProducts = loading && lastProducts.length > 0 ? lastProducts : products;
+
+ // Если идет загрузка (или это первый рендер), всегда показываем только скелетон
+ if (loading || (products.length === 0 && lastProducts.length === 0)) {
return (
{Array.from({ length: 6 }).map((_, i) => (
@@ -469,10 +468,11 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
))}
- )
+ );
}
- if (products.length === 0) {
+ // Сообщение 'Товары не найдены' только если загрузка завершена и был хотя бы один успешный запрос
+ if (!loading && products.length === 0 && lastProducts.length > 0) {
return (
@@ -488,19 +488,27 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
- )
+ );
}
+ // Отображение товаров
return (
- {products.map((product) => (
-
- ))}
+
+ {showProducts.map((product) => (
+
+
+
+ ))}
+
- )
+ );
}
return (
@@ -577,9 +585,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
{/* Основной контент с товарами */}
- {isLoadingProducts ? (
-
- ) : productsError ? (
+ {productsError ? (
Ошибка при загрузке товаров
{productsError}
@@ -587,20 +593,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
Попробовать снова
- ) : products.length === 0 ? (
-
-
Товары не найдены
-
- Попробуйте изменить параметры фильтрации или поискать что-то другое.
-
-
- Сбросить все фильтры
-
-
) : (
<>
-
{/* Пагинация */}
{totalProducts > 12 && (
diff --git a/frontend/app/(main)/checkout/page.tsx b/frontend/app/(main)/checkout/page.tsx
index 7dec4d9..99079d2 100644
--- a/frontend/app/(main)/checkout/page.tsx
+++ b/frontend/app/(main)/checkout/page.tsx
@@ -13,6 +13,7 @@ import OrderSummary from "@/components/checkout/order-summary"
import { Separator } from "@/components/ui/separator"
import OrderComment from "@/components/checkout/order-comment"
import { orderService } from "@/lib/order-service"
+import { OrderCreate, Address } from "@/types/order"
import { useToast } from "@/components/ui/use-toast"
import { Loader2 } from "lucide-react"
@@ -24,10 +25,10 @@ export default function CheckoutPage() {
const { cart, loading, clearCart } = useCart()
const { toast } = useToast()
const router = useRouter()
-
+
// Состояния для обработки заказа
const [isSubmitting, setIsSubmitting] = useState(false)
-
+
// Состояния для форм и выбранных опций
const [userInfo, setUserInfo] = useState({
firstName: "",
@@ -35,47 +36,82 @@ export default function CheckoutPage() {
email: "",
phone: ""
})
-
+
const [deliveryMethod, setDeliveryMethod] = useState
("cdek")
-
- const [address, setAddress] = useState({
+
+ const [address, setAddress] = useState({
city: "Новокузнецк", // По умолчанию
street: "",
house: "",
apartment: "",
postalCode: ""
})
-
+
const [paymentMethod, setPaymentMethod] = useState("sbp")
-
+
const [orderComment, setOrderComment] = useState("")
-
+
+ // Собираем массив goods для СДЭК (TODO: заменить на реальные размеры/вес из товара)
+ const goods = cart.items.map(item => ({
+ length: 20, // TODO: заменить на реальные значения
+ width: 20,
+ height: 20,
+ weight: 2, // кг
+ }));
+
// Проверка валидности заполнения формы
const isFormValid = () => {
- const isUserInfoValid =
- userInfo.firstName &&
- userInfo.email &&
+ // Проверка информации о пользователе
+ const isUserInfoValid =
+ userInfo.firstName &&
+ userInfo.email &&
userInfo.phone
-
- const isAddressValid =
- address.city &&
- address.street &&
- address.house
-
- return isUserInfoValid && isAddressValid
+
+ // Проверка адреса в зависимости от способа доставки
+ let isAddressValid = false;
+
+ if (deliveryMethod === "cdek") {
+ // Для CDEK нужен выбранный пункт выдачи
+ isAddressValid = !!address.city && !!address.cdekPvz;
+
+ // Добавляем логирование для отладки
+ console.log("Проверка валидности формы для CDEK:", {
+ city: address.city,
+ cdekPvz: !!address.cdekPvz,
+ isAddressValid
+ });
+ } else {
+ // Для курьерской доставки нужен адрес
+ isAddressValid =
+ !!address.city &&
+ !!address.street &&
+ !!address.house;
+ }
+
+ return isUserInfoValid && isAddressValid;
}
-
+
// Обработчик оформления заказа
const handleSubmitOrder = async () => {
+ // Проверка валидности формы
if (!isFormValid()) {
- toast({
- variant: "destructive",
- title: "Форма не заполнена",
- description: "Пожалуйста, заполните все обязательные поля"
- })
+ // Проверяем, что именно не заполнено
+ if (deliveryMethod === "cdek" && !address.cdekPvz) {
+ toast({
+ variant: "destructive",
+ title: "Не выбран пункт выдачи СДЭК",
+ description: "Пожалуйста, нажмите кнопку 'Выбрать пункт выдачи' и выберите пункт выдачи СДЭК"
+ })
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Форма не заполнена",
+ description: "Пожалуйста, заполните все обязательные поля"
+ })
+ }
return
}
-
+
if (!cart.items || cart.items.length === 0) {
toast({
variant: "destructive",
@@ -84,12 +120,12 @@ export default function CheckoutPage() {
})
return
}
-
+
try {
setIsSubmitting(true)
-
+
// Подготавливаем данные для заказа
- const orderData = {
+ const orderData: OrderCreate = {
userInfo,
items: cart.items,
address,
@@ -97,7 +133,10 @@ export default function CheckoutPage() {
paymentMethod,
comment: orderComment
}
-
+
+ // Вызываем отладочную функцию для анализа структуры заказа
+ orderService.debugOrderStructure(orderData)
+
// Сохраняем состояние корзины для показа на странице успешного оформления
try {
// Создаем копию корзины
@@ -106,13 +145,13 @@ export default function CheckoutPage() {
} catch (error) {
console.error("Ошибка при сохранении состояния корзины:", error);
}
-
+
// Создаем заказ
const order = await orderService.createOrder(orderData)
-
+
// Очищаем корзину
await clearCart()
-
+
// Перенаправляем на страницу успешного оформления
router.push(`/checkout/success?order_id=${order.orderId}&total=${order.total}&email=${encodeURIComponent(userInfo.email)}`)
} catch (error) {
@@ -123,7 +162,7 @@ export default function CheckoutPage() {
} catch (error) {
console.error("Ошибка при очистке данных заказа:", error);
}
-
+
toast({
variant: "destructive",
title: "Ошибка",
@@ -133,7 +172,7 @@ export default function CheckoutPage() {
setIsSubmitting(false)
}
}
-
+
// Проверка наличия товаров в корзине
if (!loading && (!cart.items || cart.items.length === 0)) {
return (
@@ -141,7 +180,7 @@ export default function CheckoutPage() {
Оформление заказа
Ваша корзина пуста
-
router.push("/catalog")}
className="bg-black hover:bg-neutral-800"
>
@@ -155,7 +194,7 @@ export default function CheckoutPage() {
return (
Оформление заказа
-
+
{/* Левая колонка - формы и выбор опций */}
@@ -164,50 +203,51 @@ export default function CheckoutPage() {
Информация о получателе
-
+
{/* Способ доставки */}
Способ доставки
-
-
+
{/* Адрес доставки */}
-
+
{/* Комментарий к заказу */}
Комментарий к заказу
-
+
{/* Способ оплаты */}
-
+
{/* Правая колонка - сводка заказа */}
Ваш заказ
-
+
-
+
-
+
Нажимая на кнопку, вы соглашаетесь с условиями обработки персональных данных и правилами магазина
diff --git a/frontend/app/(main)/wishlist/page.tsx b/frontend/app/(main)/wishlist/page.tsx
index 07261b3..748f446 100644
--- a/frontend/app/(main)/wishlist/page.tsx
+++ b/frontend/app/(main)/wishlist/page.tsx
@@ -1,258 +1,181 @@
"use client"
-import { useState } from "react"
-import Image from "next/image"
+import { useEffect, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
-import { motion } from "framer-motion"
-import { Heart, ShoppingBag, Trash2, AlertCircle } from "lucide-react"
+import { motion, AnimatePresence } from "framer-motion"
+import { Heart, Trash2 } from "lucide-react"
+import { ProductCard } from "@/components/product/product-card"
+import { useWishlist } from "@/hooks/use-wishlist"
+import catalogService, { ProductDetails } from "@/lib/catalog"
+import { Skeleton } from "@/components/ui/skeleton"
+
+const recommended = [
+ {
+ id: 5,
+ name: "ЮБКА МИДИ ПЛИССЕ",
+ price: 3490,
+ image: "/placeholder.svg?height=600&width=400",
+ slug: "skirt-midi",
+ },
+ {
+ id: 6,
+ name: "ПАЛЬТО ИЗ ШЕРСТИ",
+ price: 12990,
+ image: "/placeholder.svg?height=600&width=400",
+ slug: "coat-wool",
+ },
+ {
+ id: 7,
+ name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
+ price: 7990,
+ oldPrice: 9990,
+ image: "/placeholder.svg?height=600&width=400",
+ slug: "sweater-cashmere",
+ },
+ {
+ id: 8,
+ name: "РУБАШКА ОВЕРСАЙЗ",
+ price: 4490,
+ image: "/placeholder.svg?height=600&width=400",
+ slug: "shirt-oversize",
+ },
+]
export default function WishlistPage() {
- // Mock wishlist items
- const [wishlistItems, setWishlistItems] = useState([
- {
- id: 1,
- name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
- price: 5990,
- oldPrice: 7990,
- image: "/placeholder.svg?height=600&width=400",
- inStock: true,
- },
- {
- id: 2,
- name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
- price: 4990,
- image: "/placeholder.svg?height=600&width=400",
- inStock: true,
- },
- {
- id: 3,
- name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ",
- price: 3990,
- image: "/placeholder.svg?height=600&width=400",
- inStock: false,
- },
- {
- id: 4,
- name: "ЖАКЕТ В КЛЕТКУ",
- price: 6990,
- oldPrice: 8990,
- image: "/placeholder.svg?height=600&width=400",
- inStock: true,
- },
- ])
+ const { items, removeItem, clearWishlist } = useWishlist()
+ const [products, setProducts] = useState([])
+ const [loading, setLoading] = useState(true)
- const removeFromWishlist = (id: number) => {
- setWishlistItems(wishlistItems.filter((item) => item.id !== id))
- }
+ useEffect(() => {
+ let isMounted = true
+ async function fetchProducts() {
+ setLoading(true)
+ const results: ProductDetails[] = []
+ for (const item of items) {
+ try {
+ const product = await catalogService.getProductById(item.product_id)
+ if (isMounted && product) results.push(product)
+ } catch (e) {
+ // ignore not found
+ }
+ }
+ if (isMounted) setProducts(results)
+ setLoading(false)
+ }
+ if (items.length > 0) fetchProducts()
+ else {
+ setProducts([])
+ setLoading(false)
+ }
+ return () => { isMounted = false }
+ }, [items])
return (
-
- {/* Hero Section */}
-
-
-
+
+
+
+
+
+ Вернуться к покупкам
+
+
Избранное
+
-
-
- ИЗБРАННОЕ
- Ваша персональная коллекция любимых товаров
-
-
-
-
-
-
- {wishlistItems.length === 0 ? (
-
-
-
+
+ {loading ? (
+
+
+ {[1,2,3].map(i => )}
- Ваш список избранного пуст
-
+
+ ) : products.length === 0 ? (
+
+
+
+
+ Ваш список избранного пуст
+
Добавляйте понравившиеся товары в избранное, чтобы не потерять их и быстро вернуться к ним позже.
-
- ПЕРЕЙТИ В КАТАЛОГ
+
+ Перейти в каталог
) : (
-
+
-
- ТОВАРЫ В ИЗБРАННОМ ({wishlistItems.length})
+
+ Товары в избранном ({products.length})
setWishlistItems([])}
+ className="border-primary text-primary hover:bg-primary hover:text-white"
+ onClick={clearWishlist}
>
- ОЧИСТИТЬ СПИСОК
+ Очистить список
-
- {wishlistItems.map((item) => (
-
-
-
-
-
-
-
removeFromWishlist(item.id)}
- >
-
- Удалить из избранного
-
-
- {!item.inStock && (
-
-
-
НЕТ В НАЛИЧИИ
-
Сообщить, когда появится
-
- )}
-
-
-
-
-
- {item.name}
-
-
-
-
- {item.price.toLocaleString()} ₽
- {item.oldPrice && (
-
- {item.oldPrice.toLocaleString()} ₽
-
- )}
-
-
-
- {item.inStock ? (
- <>
-
- ПОДРОБНЕЕ
-
-
- В КОРЗИНУ
-
- >
- ) : (
-
- ПОДРОБНЕЕ
-
- )}
-
-
-
+ {products.map((product) => (
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ removeItem(product.id);
+ }}
+ >
+
+ Удалить из избранного
+
+
))}
-
+
)}
-
-
-
- {/* Recommended Products */}
-
-
-
РЕКОМЕНДУЕМ ВАМ
+
+ {/* Рекомендации */}
+
+ Рекомендуем вам
- {[
- {
- id: 5,
- name: "ЮБКА МИДИ ПЛИССЕ",
- price: 3490,
- image: "/placeholder.svg?height=600&width=400",
- },
- {
- id: 6,
- name: "ПАЛЬТО ИЗ ШЕРСТИ",
- price: 12990,
- image: "/placeholder.svg?height=600&width=400",
- },
- {
- id: 7,
- name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
- price: 7990,
- oldPrice: 9990,
- image: "/placeholder.svg?height=600&width=400",
- },
- {
- id: 8,
- name: "РУБАШКА ОВЕРСАЙЗ",
- price: 4490,
- image: "/placeholder.svg?height=600&width=400",
- },
- ].map((item) => (
-
-
-
-
-
-
- Добавить в избранное
-
-
-
-
-
-
- {item.name}
-
-
-
-
- {item.price.toLocaleString()} ₽
- {item.oldPrice && (
-
- {item.oldPrice.toLocaleString()} ₽
-
- )}
-
-
-
- В КОРЗИНУ
-
-
-
+ {recommended.map((item) => (
+
))}
-
-
+
+
)
}
diff --git a/frontend/app/.DS_Store b/frontend/app/.DS_Store
index 411dca8..6d0fc2f 100644
Binary files a/frontend/app/.DS_Store and b/frontend/app/.DS_Store differ
diff --git a/frontend/app/admin/categories/page.tsx b/frontend/app/admin/categories/page.tsx
index 5b1839f..dc4cffd 100644
--- a/frontend/app/admin/categories/page.tsx
+++ b/frontend/app/admin/categories/page.tsx
@@ -1,21 +1,10 @@
'use client';
-import { useState, useEffect } from 'react';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import * as z from 'zod';
-import { Trash, Edit, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
-import { toast } from 'sonner';
+import { useState } from 'react';
+import { toast } from 'react-hot-toast';
+import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
import {
Dialog,
DialogContent,
@@ -23,489 +12,207 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
- DialogTrigger,
} from '@/components/ui/dialog';
import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@/components/ui/form';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Switch } from '@/components/ui/switch';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { categoryService } from '@/lib/catalog';
-import { Category, CategoryCreate, CategoryUpdate } from '@/lib/catalog';
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
-// Схема валидации для формы категории
-const categoryFormSchema = z.object({
- name: z.string().min(2, { message: 'Название должно содержать минимум 2 символа' }),
- slug: z.string().min(2, { message: 'Slug должен содержать минимум 2 символа' }).optional(),
- description: z.string().optional(),
- parent_id: z.number().nullable().optional(),
- is_active: z.boolean().default(true),
-});
+import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
+import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
+import CategoryTree from '@/components/admin/CategoryTree';
+import { CategoryForm, CategoryFormValues, categoryFormSchema } from '@/components/admin/CategoryForm';
-type CategoryFormValues = z.infer
;
+import { Category } from '@/lib/catalog-admin';
+import { useAdminMutation } from '@/hooks/useAdminQuery';
+import { cacheKeys } from '@/lib/api-cache';
+import useCategoriesCache from '@/hooks/useCategoriesCache';
-// Компонент для отображения категории в виде дерева
-const CategoryItem = ({
- category,
- level = 0,
- onEdit,
- onDelete,
- onAddSubcategory,
- categories,
-}: {
- category: Category;
- level?: number;
- onEdit: (category: Category) => void;
- onDelete: (categoryId: number) => void;
- onAddSubcategory: (parentId: number) => void;
- categories: Category[];
-}) => {
- const [isExpanded, setIsExpanded] = useState(false);
- const hasSubcategories = category.subcategories && category.subcategories.length > 0;
- return (
-
-
- {hasSubcategories ? (
-
setIsExpanded(!isExpanded)}
- className="mr-2 text-gray-500 hover:text-gray-700"
- >
- {isExpanded ? : }
-
- ) : (
-
- )}
-
{category.name}
-
-
onAddSubcategory(category.id)}
- title="Добавить подкатегорию"
- >
-
-
-
onEdit(category)}
- title="Редактировать категорию"
- >
-
-
-
onDelete(category.id)}
- title="Удалить категорию"
- disabled={hasSubcategories}
- >
-
-
-
-
- {isExpanded && category.subcategories && category.subcategories.length > 0 && (
-
- {category.subcategories.map((subcategory) => (
-
- ))}
-
- )}
-
- );
-};
-// Компонент диалогового окна для редактирования категории
-const EditCategoryDialog = ({
- isOpen,
- onClose,
- category,
- onSave,
- categories,
- mode,
- parentId,
-}: {
- isOpen: boolean;
- onClose: () => void;
- category: Category | null;
- onSave: (data: CategoryFormValues) => void;
- categories: Category[];
- mode: 'edit' | 'create';
- parentId?: number | null;
-}) => {
- const form = useForm({
- resolver: zodResolver(categoryFormSchema),
- defaultValues: category
- ? {
- name: category.name,
- slug: category.slug || '',
- description: category.description || '',
- parent_id: category.parent_id,
- is_active: category.is_active,
- }
- : {
- name: '',
- slug: '',
- description: '',
- parent_id: parentId || null,
- is_active: true,
- },
- });
-
- useEffect(() => {
- if (isOpen) {
- form.reset(
- category
- ? {
- name: category.name,
- slug: category.slug || '',
- description: category.description || '',
- parent_id: category.parent_id,
- is_active: category.is_active,
- }
- : {
- name: '',
- slug: '',
- description: '',
- parent_id: parentId || null,
- is_active: true,
- }
- );
- }
- }, [category, form, isOpen, parentId]);
-
- const handleSubmit = (data: CategoryFormValues) => {
- onSave(data);
- };
-
- // Функция для получения плоского списка категорий для выбора родителя
- const getSelectableCategories = (
- categories: Category[],
- excludeId: number | null = null
- ): { id: number; name: string; level: number }[] => {
- const result: { id: number; name: string; level: number }[] = [];
-
- const traverse = (cats: Category[], level = 0) => {
- cats.forEach((cat) => {
- if (excludeId === null || cat.id !== excludeId) {
- result.push({ id: cat.id, name: cat.name, level });
- if (cat.subcategories && cat.subcategories.length > 0) {
- traverse(cat.subcategories, level + 1);
- }
- }
- });
- };
-
- traverse(categories);
- return result;
- };
-
- const selectableCategories = getSelectableCategories(
- categories,
- mode === 'edit' ? category?.id : null
- );
-
- return (
- !open && onClose()}>
-
-
-
- {mode === 'edit' ? 'Редактирование категории' : 'Создание категории'}
-
-
- {mode === 'edit'
- ? 'Отредактируйте информацию о категории'
- : 'Создайте новую категорию'}
-
-
-
-
-
-
- );
-};
export default function CategoriesPage() {
- const [categories, setCategories] = useState([]);
- const [loading, setLoading] = useState(true);
- const [categoryToEdit, setCategoryToEdit] = useState(null);
- const [isDialogOpen, setIsDialogOpen] = useState(false);
- const [dialogMode, setDialogMode] = useState<'edit' | 'create'>('create');
- const [parentIdForCreate, setParentIdForCreate] = useState(null);
+ // Состояние для диалогов
+ const [showFormDialog, setShowFormDialog] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [editingCategory, setEditingCategory] = useState(null);
+ const [parentCategoryId, setParentCategoryId] = useState(null);
+ const [categoryToDelete, setCategoryToDelete] = useState(null);
- // Загрузка категорий при монтировании компонента
- useEffect(() => {
- const loadCategories = async () => {
- setLoading(true);
- try {
- const data = await categoryService.getCategories();
- setCategories(data);
- } catch (error) {
- console.error('Ошибка при загрузке категорий:', error);
- toast.error('Ошибка при загрузке категорий');
- } finally {
- setLoading(false);
- }
- };
+ // Используем хук для получения категорий
+ const { categories, isLoading, error } = useCategoriesCache();
- loadCategories();
- }, []);
-
- // Обработчик редактирования категории
- const handleEdit = (category: Category) => {
- setCategoryToEdit(category);
- setDialogMode('edit');
- setIsDialogOpen(true);
- };
-
- // Обработчик создания подкатегории
- const handleAddSubcategory = (parentId: number) => {
- setCategoryToEdit(null);
- setDialogMode('create');
- setParentIdForCreate(parentId);
- setIsDialogOpen(true);
- };
-
- // Обработчик создания корневой категории
- const handleAddRootCategory = () => {
- setCategoryToEdit(null);
- setDialogMode('create');
- setParentIdForCreate(null);
- setIsDialogOpen(true);
- };
-
- // Обработчик удаления категории
- const handleDelete = async (categoryId: number) => {
- if (!confirm('Вы действительно хотите удалить эту категорию?')) {
- return;
+ // Мутации для работы с категориями
+ const createMutation = useAdminMutation(
+ 'post',
+ '/catalog/categories',
+ {
+ onSuccessMessage: 'Категория успешно создана',
+ onErrorMessage: 'Не удалось создать категорию',
+ invalidateQueries: [cacheKeys.categories]
}
+ );
- try {
- const result = await categoryService.deleteCategory(categoryId);
- if (result) {
- // Обновляем список категорий после удаления
- setCategories(categories.filter(cat => cat.id !== categoryId));
- toast.success('Категория успешно удалена');
- } else {
- toast.error('Не удалось удалить категорию');
- }
- } catch (error) {
- console.error('Ошибка при удалении категории:', error);
- toast.error('Ошибка при удалении категории');
+ const updateMutation = useAdminMutation(
+ 'put',
+ (variables) => `/catalog/categories/${variables.id}`,
+ {
+ onSuccessMessage: 'Категория успешно обновлена',
+ onErrorMessage: 'Не удалось обновить категорию',
+ invalidateQueries: [cacheKeys.categories]
}
+ );
+
+ const deleteMutation = useAdminMutation(
+ 'delete',
+ (id: number) => `/catalog/categories/${id}`,
+ {
+ onSuccessMessage: 'Категория успешно удалена',
+ onErrorMessage: 'Не удалось удалить категорию',
+ invalidateQueries: [cacheKeys.categories]
+ }
+ );
+
+ // Обработчики событий
+ const handleAddCategory = (parentId: number | null) => {
+ setEditingCategory(null);
+ setParentCategoryId(parentId);
+ setShowFormDialog(true);
};
- // Обработчик сохранения категории
- const handleSave = async (values: CategoryFormValues) => {
+ const handleEditCategory = (category: Category) => {
+ setEditingCategory(category);
+ setParentCategoryId(category.parent_id);
+ setShowFormDialog(true);
+ };
+
+ const handleDeleteCategory = (category: Category) => {
+ setCategoryToDelete(category);
+ setShowDeleteDialog(true);
+ };
+
+ const handleFormSubmit = async (values: CategoryFormValues) => {
try {
- if (dialogMode === 'edit' && categoryToEdit) {
- const result = await categoryService.updateCategory(categoryToEdit.id, values);
- if (result) {
- // Обновляем категорию в списке
- setCategories(categories.map(cat =>
- cat.id === categoryToEdit.id ? { ...cat, ...values } : cat
- ));
- toast.success('Категория успешно обновлена');
- } else {
- toast.error('Не удалось обновить категорию');
- }
- } else {
- const result = await categoryService.createCategory({
+ if (editingCategory) {
+ // Обновление существующей категории
+ await updateMutation.mutateAsync({
...values,
- parent_id: parentIdForCreate || undefined
+ id: editingCategory.id
+ });
+ } else {
+ // Создание новой категории
+ await createMutation.mutateAsync({
+ ...values,
+ parent_id: parentCategoryId
});
- if (result) {
- // Добавляем новую категорию в список
- setCategories([...categories, result]);
- toast.success('Категория успешно создана');
- } else {
- toast.error('Не удалось создать категорию');
- }
}
- setIsDialogOpen(false);
+ setShowFormDialog(false);
} catch (error) {
console.error('Ошибка при сохранении категории:', error);
- toast.error('Ошибка при сохранении категории');
}
};
- return (
-
-
-
-
-
- Управление категориями
-
- Создавайте, редактируйте и удаляйте категории товаров
-
-
-
- Добавить категорию
-
-
-
-
- {loading ? (
-
-
-
- ) : categories.length === 0 ? (
-
- Категории не найдены. Создайте первую категорию, нажав на кнопку "Добавить категорию".
-
- ) : (
-
- {categories.map((category) => (
-
- ))}
-
- )}
-
-
+ const handleConfirmDelete = async () => {
+ if (categoryToDelete) {
+ try {
+ await deleteMutation.mutateAsync(categoryToDelete.id);
+ setShowDeleteDialog(false);
+ setCategoryToDelete(null);
+ } catch (error) {
+ console.error('Ошибка при удалении категории:', error);
+ }
+ }
+ };
- {/* Диалоговое окно для редактирования/создания категории */}
-
setIsDialogOpen(false)}
- category={categoryToEdit}
- onSave={handleSave}
- categories={categories}
- mode={dialogMode}
- parentId={parentIdForCreate}
+ // Значения формы по умолчанию
+ const defaultFormValues: CategoryFormValues = {
+ name: editingCategory?.name || '',
+ slug: editingCategory?.slug || '',
+ description: editingCategory?.description || '',
+ parent_id: editingCategory?.parent_id ? String(editingCategory.parent_id) : null,
+ is_active: editingCategory?.is_active ?? true
+ };
+
+ // Состояние загрузки
+ const isSaving = createMutation.isPending || updateMutation.isPending;
+ const isDeleting = deleteMutation.isPending;
+
+ return (
+
+
-
+
+ console.log('Selected category:', category)}
+ onEditCategory={handleEditCategory}
+ onDeleteCategory={handleDeleteCategory}
+ onAddCategory={handleAddCategory}
+ showActions={true}
+ showCounts={true}
+ />
+
+ {/* Диалог формы категории */}
+
+
+
+
+ {editingCategory ? 'Редактирование категории' : 'Создание новой категории'}
+
+
+ {editingCategory
+ ? 'Измените данные категории и нажмите "Сохранить"'
+ : 'Заполните форму для создания новой категории'}
+
+
+
+ setShowFormDialog(false)}
+ isEditing={!!editingCategory}
+ />
+
+
+
+ {/* Диалог подтверждения удаления */}
+
+
+
+ Подтверждение удаления
+
+ Вы уверены, что хотите удалить категорию "{categoryToDelete?.name}"?
+ {categoryToDelete?.products_count && categoryToDelete.products_count > 0 && (
+
+ Внимание! В этой категории есть товары ({categoryToDelete.products_count} шт.).
+ При удалении категории товары останутся без категории.
+
+ )}
+
+
+
+ Отмена
+
+ {isDeleting ? 'Удаление...' : 'Удалить'}
+
+
+
+
+
);
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/frontend/app/admin/dashboard/page.tsx b/frontend/app/admin/dashboard/page.tsx
index 7068a72..13fc323 100644
--- a/frontend/app/admin/dashboard/page.tsx
+++ b/frontend/app/admin/dashboard/page.tsx
@@ -1,23 +1,20 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState } from 'react';
import Link from 'next/link';
import { BarChart3, Package, Users, ShoppingBag } from 'lucide-react';
-import { Order } from '@/lib/orders';
-import { Product } from '@/lib/catalog';
-import api from '@/lib/api';
-import useAdminApi from '@/hooks/useAdminApi';
-import useAdminCache from '@/hooks/useAdminCache';
+import { Order, Address } from '@/lib/orders';
+import { Product, ProductDetails } from '@/lib/catalog';
+import { useDashboardStats, useRecentOrders, usePopularProducts } from '@/hooks/useAdminQuery';
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
import AdminLoadingState from '@/components/admin/AdminLoadingState';
-// Интерфейс статистики для дашборда
+// Интерфейс для статистики дашборда
interface DashboardStats {
- ordersCount: number;
- totalSales: number;
- customersCount: number;
- productsCount: number;
- [key: string]: any;
+ total_orders: number;
+ total_users: number;
+ total_revenue: number;
+ total_products: number;
}
// Компонент статистической карточки
@@ -63,6 +60,14 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
);
}
+ const getUserName = (order: Order) => {
+ const address = order.shipping_address;
+ if (address) {
+ return `${address.first_name} ${address.last_name}`;
+ }
+ return `Пользователь #${order.user_id}`;
+ };
+
return (
@@ -84,7 +89,7 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
{orders.map((order) => (
#{order.id}
- {order.user_name || `Пользователь #${order.user_id}`}
+ {getUserName(order)}
{new Date(order.created_at).toLocaleDateString('ru-RU')}
@@ -97,14 +102,13 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
{order.status === 'delivered' ? 'Доставлен' :
order.status === 'processing' ? 'В обработке' :
order.status === 'shipped' ? 'Отправлен' :
- order.status === 'paid' ? 'Оплачен' :
+ order.status === 'pending' ? 'Ожидает' :
+ order.status === 'cancelled' ? 'Отменен' :
order.status}
- {order.total !== undefined && order.total !== null
- ? `${order.total.toLocaleString('ru-RU')} ₽`
- : 'Н/Д'}
+ {`${order.total_amount.toLocaleString('ru-RU')} ₽`}
@@ -134,7 +138,7 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
// Компонент популярных товаров
interface PopularProductsProps {
- products: Product[];
+ products: ProductDetails[];
loading: boolean;
error: string | null;
}
@@ -174,17 +178,17 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
{product.name || 'Без названия'}
{product.category?.name || 'Без категории'}
- {typeof product.sales === 'number' ? product.sales : 0}
+ 0
20 ? 'bg-green-100 text-green-800' :
- typeof product.stock === 'number' && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
+ ${product.stock && product.stock > 20 ? 'bg-green-100 text-green-800' :
+ product.stock && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
- {typeof product.stock === 'number' ? product.stock : 'Н/Д'}
+ {product.stock || 'Н/Д'}
-
+
Редактировать
@@ -200,212 +204,90 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
-
-
- Посмотреть все товары →
-
-
);
};
+// Основной компонент дашборда
export default function AdminDashboard() {
- // Используем кэш для статистики
- const statsCache = useAdminCache({
- key: 'dashboard-stats',
- ttl: 5 * 60 * 1000, // 5 минут
- });
+ // Получаем данные через React Query хуки
+ const { data: stats, isLoading: statsLoading, error: statsError } = useDashboardStats();
+ const { data: recentOrders = [], isLoading: ordersLoading, error: ordersError } = useRecentOrders(5);
+ const { data: popularProducts = [], isLoading: productsLoading, error: productsError } = usePopularProducts(5);
- // Используем кэш для заказов
- const ordersCache = useAdminCache({
- key: 'recent-orders',
- ttl: 2 * 60 * 1000, // 2 минуты
- });
-
- // Используем кэш для товаров
- const productsCache = useAdminCache({
- key: 'popular-products',
- ttl: 5 * 60 * 1000, // 5 минут
- });
-
- // API для статистики
- const statsApi = useAdminApi({
- onSuccess: (data) => {
- if (data) {
- // Нормализуем данные статистики
- const normalizedStats = {
- ordersCount: typeof data.ordersCount === 'number' ? data.ordersCount : 0,
- totalSales: typeof data.totalSales === 'number' ? data.totalSales : 0,
- customersCount: typeof data.customersCount === 'number' ? data.customersCount : 0,
- productsCount: typeof data.productsCount === 'number' ? data.productsCount : 0
- };
- statsCache.setData(normalizedStats);
- }
- }
- });
-
- // API для заказов
- const ordersApi = useAdminApi({
- onSuccess: (data) => {
- if (data && Array.isArray(data)) {
- // Нормализуем данные заказов
- const normalizedOrders = data.map(order => ({
- id: order.id || 0,
- user_id: order.user_id || 0,
- user_name: order.user_name || '',
- created_at: order.created_at || new Date().toISOString(),
- status: order.status || 'processing',
- total: typeof order.total === 'number' ? order.total : 0
- }));
- ordersCache.setData(normalizedOrders);
- }
- }
- });
-
- // API для товаров
- const productsApi = useAdminApi({
- onSuccess: (data) => {
- if (data) {
- let productsArray = Array.isArray(data) ? data :
- (data.products && Array.isArray(data.products)) ? data.products : [];
-
- // Нормализуем данные товаров
- const normalizedProducts = productsArray.map(product => {
- // Определяем stock как сумму остатков всех вариантов, если они есть
- let totalStock = 0;
- if (product.variants && Array.isArray(product.variants)) {
- totalStock = product.variants.reduce((sum: number, variant: any) =>
- sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
- } else if (typeof product.stock === 'number') {
- totalStock = product.stock;
- }
-
- // Создаем корректный объект Product
- return {
- id: product.id || 0,
- name: product.name || 'Без названия',
- category: product.category ||
- (product.category_id ? { id: product.category_id, name: 'Категория ' + product.category_id } :
- { id: 0, name: 'Без категории' }),
- category_id: product.category_id,
- sales: typeof product.sales === 'number' ? product.sales : 0,
- stock: totalStock,
- price: typeof product.price === 'number' ? product.price : 0,
- images: product.images && Array.isArray(product.images) ?
- product.images.map((img: any) => img.image_url) : [],
- description: product.description || ''
- };
- });
-
- productsCache.setData(normalizedProducts);
- }
- }
- });
-
- // Загрузка данных при монтировании компонента
- useEffect(() => {
- const fetchDashboardData = async () => {
- // Загружаем статистику
- await statsCache.loadData(async () => {
- const response = await statsApi.get('/admin/dashboard/stats');
- return response || {
- ordersCount: 0,
- totalSales: 0,
- customersCount: 0,
- productsCount: 0
- };
- });
-
- // Загружаем последние заказы
- await ordersCache.loadData(async () => {
- const response = await ordersApi.get('/admin/orders/recent', {
- limit: 4,
- sort_by: 'created_at',
- sort_dir: 'desc'
- });
- return response || [];
- });
-
- // Загружаем популярные товары
- await productsCache.loadData(async () => {
- const response = await productsApi.get('/admin/products/popular', {
- limit: 4
- });
- return response || [];
- });
- };
-
- fetchDashboardData();
- }, []);
-
- // Получаем данные из кэша
- const stats = statsCache.data || {
- ordersCount: 0,
- totalSales: 0,
- customersCount: 0,
- productsCount: 0
+ // Форматируем статистику
+ const formatCurrency = (value: number) => {
+ return new Intl.NumberFormat('ru-RU', {
+ style: 'currency',
+ currency: 'RUB',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0
+ }).format(value);
};
- const recentOrders = ordersCache.data || [];
- const popularProducts = productsCache.data || [];
+ // Если загружаются все данные, показываем общий индикатор загрузки
+ if (statsLoading && ordersLoading && productsLoading) {
+ return ;
+ }
- // Состояние загрузки
- const loading = {
- stats: statsApi.isLoading || statsCache.isLoading,
- orders: ordersApi.isLoading || ordersCache.isLoading,
- products: productsApi.isLoading || productsCache.isLoading
- };
+ // Если есть ошибка в получении статистики, показываем ошибку
+ if (statsError) {
+ return (
+
+ );
+ }
- // Состояние ошибок
- const error = {
- stats: statsApi.error,
- orders: ordersApi.error,
- products: productsApi.error
+ const dashboardStats = stats as DashboardStats || {
+ total_orders: 0,
+ total_revenue: 0,
+ total_users: 0,
+ total_products: 0
};
return (
-
-
Дашборд
-
- {/* Статистические карточки */}
+
+ {/* Статистика */}
}
- color="bg-blue-500"
+ value={dashboardStats.total_orders}
+ icon={
}
+ color="bg-purple-100"
/>
}
- color="bg-green-500"
+ title="Общая выручка"
+ value={formatCurrency(dashboardStats.total_revenue)}
+ icon={
}
+ color="bg-green-100"
/>
}
- color="bg-purple-500"
+ title="Клиентов"
+ value={dashboardStats.total_users}
+ icon={
}
+ color="bg-blue-100"
/>
}
- color="bg-orange-500"
+ title="Товаров"
+ value={dashboardStats.total_products}
+ icon={
}
+ color="bg-orange-100"
/>
{/* Последние заказы и популярные товары */}
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
index 3f39634..12ad74f 100644
--- a/frontend/app/admin/layout.tsx
+++ b/frontend/app/admin/layout.tsx
@@ -4,6 +4,7 @@ import { ReactNode, useEffect, useState } from 'react';
import Link from 'next/link';
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings, Home, Layers, FolderTree, LogOut } from 'lucide-react';
import { authApi } from '@/lib/auth-api';
+import AdminQueryProvider from '@/providers/AdminQueryProvider';
// Интерфейс для компонента MenuItem
interface MenuItemProps {
@@ -26,11 +27,11 @@ interface User {
// Компонент элемента меню
const MenuItem = ({ href, icon, label, active = false }: MenuItemProps) => {
return (
-
@@ -54,40 +55,40 @@ const links = [
// Проверка является ли пользователь администратором
function isUserAdmin(userProfile: any): boolean {
console.log('Проверка прав администратора для:', userProfile);
-
+
if (!userProfile) {
console.log('Профиль пользователя пустой или не определен');
return false;
}
-
+
// Детальная проверка различных способов указания прав администратора
if (userProfile.is_admin === true) {
console.log('Пользователь админ по свойству is_admin');
return true;
}
-
+
if (userProfile.role === 'admin') {
console.log('Пользователь админ по роли admin');
return true;
}
-
+
if (userProfile.role === 'ADMIN') {
console.log('Пользователь админ по роли ADMIN');
return true;
}
-
+
// Проверка прав в массиве
if (Array.isArray(userProfile.roles) && userProfile.roles.includes('admin')) {
console.log('Пользователь админ по массиву ролей');
return true;
}
-
+
// Проверка поля admin_access если оно есть
if (userProfile.admin_access === true) {
console.log('Пользователь админ по свойству admin_access');
return true;
}
-
+
console.log('Пользователь НЕ админ, не найдены признаки админских прав');
return false;
}
@@ -125,33 +126,33 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
try {
const token = getToken();
console.log('Проверка токена в layout:', token ? token.substring(0, 20) + '...' : 'нет токена');
-
+
if (!token) {
setIsAuthenticated(false);
setIsAdmin(false);
setIsLoading(false);
-
+
// Перенаправляем на страницу входа, если мы не там
- if (window.location.pathname.startsWith('/admin') &&
+ if (window.location.pathname.startsWith('/admin') &&
window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login';
}
return;
}
-
+
// Проверяем токен и получаем профиль пользователя
const userProfile = await authApi.getProfile();
console.log('Профиль пользователя в layout:', userProfile);
-
+
if (userProfile) {
setIsAuthenticated(true);
setCurrentUser(userProfile);
-
+
// Проверяем права администратора
const userAdminStatus = isUserAdmin(userProfile);
console.log('Результат проверки админских прав:', userAdminStatus);
setIsAdmin(userAdminStatus);
-
+
// Если пользователь авторизован, но не админ - показываем ошибку и предлагаем выйти
if (!userAdminStatus && window.location.pathname !== '/admin/login') {
setAuthError('У вас нет прав доступа к админ-панели');
@@ -166,9 +167,9 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
// Не удаляем токен при перезагрузке страницы
// localStorage.removeItem(TOKEN_KEY);
console.log('Профиль не получен, но токен сохраняем');
-
+
// Перенаправляем на страницу входа
- if (window.location.pathname.startsWith('/admin') &&
+ if (window.location.pathname.startsWith('/admin') &&
window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login';
}
@@ -184,7 +185,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
setIsLoading(false);
}
};
-
+
checkAuth();
}, []);
@@ -210,7 +211,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
Доступ запрещен
{authError || 'У вас нет прав доступа к админ-панели.'}
Войдите с аккаунтом администратора.
-
+
{currentUser && (
Текущий пользователь: {currentUser.email}
@@ -222,7 +223,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
)}
-
@@ -243,55 +244,57 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
// Для авторизованных пользователей с правами админа показываем полный интерфейс
return (
-
- {/* Боковое меню */}
-
-
-
Админ-панель
- {currentUser && (
-
- {currentUser.email} {currentUser.is_admin ? '(Админ)' : ''}
-
- )}
-
-
-
- {links.map((link) => (
-
- ))}
- }
- label="На сайт"
- />
- {/* Кнопка выхода */}
-
-
- Выйти
-
-
-
-
-
- {/* Основной контент */}
-