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)}% - - )} -
- - {/* Кнопка добавления в избранное */} -
- -
- - -
-
-
- - {/* Блок информации о товаре */} - -
- {/* Категория и название */} - - {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 && ( + + )} +
+
-

Доставка

-

- Доставка по всей России 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() {

Оформление заказа

Ваша корзина пуста

- ) : ( -
+
-

- ТОВАРЫ В ИЗБРАННОМ ({wishlistItems.length}) +

+ Товары в избранном ({products.length})

-
- {wishlistItems.map((item) => ( - -
-
- {item.name} -
- - - - {!item.inStock && ( -
- -

НЕТ В НАЛИЧИИ

-

Сообщить, когда появится

-
- )} -
- -
- -

- {item.name} -

- - -
- {item.price.toLocaleString()} ₽ - {item.oldPrice && ( - - {item.oldPrice.toLocaleString()} ₽ - - )} -
- -
- {item.inStock ? ( - <> - - - - ) : ( - - )} -
-
-
+ {products.map((product) => ( +
+ + +
))}
-
+ )} -
- - - {/* 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.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 ? ( - - ) : ( -
- )} -
{category.name}
-
- - - -
-
- {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' - ? 'Отредактируйте информацию о категории' - : 'Создайте новую категорию'} - - -
- - ( - - Название категории - - - - - - )} - /> - ( - - Slug (для URL) - - - - - Используется в URL. Только латинские буквы, цифры и дефисы. - - - - )} - /> - ( - - Описание - -