from decimal import Decimal from fastapi import HTTPException from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.models import BotDialog, CartItem, Category, Order, OrderItem, Product from app.schemas import ( CartItemOut, CartOut, CatalogOut, CategoryOut, DialogOut, OrderItemOut, OrderOut, ProductOut, money, ) SORTS = { "price_asc": (Product.price.asc(), Product.id.asc()), "price_desc": (Product.price.desc(), Product.id.desc()), "new": (Product.id.desc(),), } async def catalog(db: AsyncSession, *, category_id: int | None, sort: str, limit: int) -> CatalogOut: categories = (await db.scalars(select(Category).order_by(Category.id))).all() stmt = ( select(Product, Category.name) .join(Category, Category.id == Product.category_id) .where(Product.is_active.is_(True)) ) if category_id is not None: stmt = stmt.where(Product.category_id == category_id) stmt = stmt.order_by(*SORTS[sort]).limit(limit) rows = (await db.execute(stmt)).all() return CatalogOut( categories=[CategoryOut.model_validate(item) for item in categories], products=[_product_out(product, category_name) for product, category_name in rows], ) async def product_detail(db: AsyncSession, product_id: int) -> tuple[Product, str]: row = ( await db.execute( select(Product, Category.name) .join(Category, Category.id == Product.category_id) .where(Product.id == product_id, Product.is_active.is_(True)) ) ).one_or_none() if row is None: raise HTTPException(status_code=404, detail="Товар не найден") return row def _product_out(product: Product, category_name: str) -> ProductOut: return ProductOut( id=product.id, category_id=product.category_id, category_name=category_name, title=product.title, description=product.description, price=product.price, ) async def load_cart(db: AsyncSession, owner_key: str) -> CartOut: rows = ( await db.execute( select(CartItem, Product) .join(Product, Product.id == CartItem.product_id) .where(CartItem.owner_key == owner_key, Product.is_active.is_(True)) .order_by(CartItem.id) ) ).all() items: list[CartItemOut] = [] total = Decimal("0.00") for item, product in rows: line = (product.price * item.qty).quantize(Decimal("0.01")) total += line items.append( CartItemOut( product_id=product.id, title=product.title, price=money(product.price), qty=item.qty, line_total=money(line), ) ) return CartOut(items=items, total=money(total)) async def add_item(db: AsyncSession, *, owner_key: str, product_id: int, qty: int) -> CartOut: product = await db.get(Product, product_id) if product is None or not product.is_active: raise HTTPException(status_code=404, detail="Товар не найден") stmt = pg_insert(CartItem).values(owner_key=owner_key, product_id=product_id, qty=qty) stmt = stmt.on_conflict_do_update( constraint="uq_cart_owner_product", set_={"qty": func.least(CartItem.qty + stmt.excluded.qty, 99)}, ) await db.execute(stmt) return await load_cart(db, owner_key) async def remove_item(db: AsyncSession, *, owner_key: str, product_id: int) -> CartOut: item = await db.scalar( select(CartItem).where(CartItem.owner_key == owner_key, CartItem.product_id == product_id) ) if item is not None: await db.delete(item) await db.flush() return await load_cart(db, owner_key) async def checkout(db: AsyncSession, *, owner_key: str, customer_name: str, customer_phone: str) -> OrderOut: rows = ( await db.execute( select(CartItem, Product) .join(Product, Product.id == CartItem.product_id) .where(CartItem.owner_key == owner_key) .order_by(CartItem.id) .with_for_update(of=CartItem) ) ).all() if not rows: raise HTTPException(status_code=400, detail="Корзина пуста") if any(not product.is_active for _, product in rows): raise HTTPException(status_code=409, detail="В корзине есть недоступный товар") total = sum((product.price * item.qty for item, product in rows), Decimal("0.00")).quantize(Decimal("0.01")) order = Order( owner_key=owner_key, customer_name=customer_name, customer_phone=customer_phone, total=total, ) db.add(order) await db.flush() item_rows: list[OrderItemOut] = [] for item, product in rows: db.add( OrderItem( order_id=order.id, product_id=product.id, title=product.title, price=product.price, qty=item.qty, ) ) item_rows.append( OrderItemOut( product_id=product.id, title=product.title, price=money(product.price), qty=item.qty, ) ) await db.delete(item) await db.flush() await db.refresh(order) return OrderOut( id=order.id, status=order.status, customer_name=order.customer_name, customer_phone=order.customer_phone, total=money(order.total), created_at=order.created_at, items=item_rows, ) async def get_order(db: AsyncSession, *, owner_key: str, order_id: int) -> OrderOut: order = await db.scalar(select(Order).where(Order.id == order_id, Order.owner_key == owner_key)) if order is None: raise HTTPException(status_code=404, detail="Заказ не найден") items = ( await db.scalars(select(OrderItem).where(OrderItem.order_id == order.id).order_by(OrderItem.id)) ).all() return OrderOut( id=order.id, status=order.status, customer_name=order.customer_name, customer_phone=order.customer_phone, total=money(order.total), created_at=order.created_at, items=[ OrderItemOut(product_id=item.product_id, title=item.title, price=money(item.price), qty=item.qty) for item in items ], ) async def get_dialog(db: AsyncSession, telegram_user_id: int) -> DialogOut | None: row = await db.get(BotDialog, telegram_user_id) if row is None: return None return DialogOut.model_validate(row) async def save_dialog(db: AsyncSession, telegram_user_id: int, step: str, name: str | None) -> DialogOut: row = await db.get(BotDialog, telegram_user_id) if row is None: row = BotDialog(telegram_user_id=telegram_user_id, step=step, name=name) db.add(row) else: row.step = step row.name = name await db.flush() return DialogOut.model_validate(row) async def clear_dialog(db: AsyncSession, telegram_user_id: int) -> None: row = await db.get(BotDialog, telegram_user_id) if row is not None: await db.delete(row) await db.flush()