""" @router AdminOrderRouter (Backend/app/api/v1/routers/AdminOrderRouter.py) @purpose CRM Admin endpoint controllers to search, view, and modify storefront customer orders and trigger status transitions. """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List, Optional from datetime import datetime import ulid import json from app.core.database.db_session import get_db from app.core.permissions.RoleChecker import get_current_user from app.models.UserModel import User from app.models.OrderModel import Order, OrderItem, OrderStatusHistory from app.models.EcomCustomerModel import EcomCustomer from app.services.InventoryService import record_ledger_entry from app.models.InventoryLedgerModel import InventoryLedger from app.models.InvoiceModel import Invoice from app.models.ProductModel import ProductVariant router = APIRouter(prefix="/api/v1/admin/orders", tags=["Admin CRM Customer Orders"]) # --- Schemas --- class OrderItemDetail(BaseModel): product_id: str variant_id: str product_name: str sku: str unit_price: float quantity: int total_price: float class StatusHistorySchema(BaseModel): previous_status: Optional[str] new_status: str changed_by: str reason: Optional[str] created_at: datetime class AdminOrderDetailResponse(BaseModel): order_id: str order_no: str customer_id: Optional[str] customer_email: Optional[str] = None customer_name: Optional[str] = None total_amount: float discount_amount: float tax_amount: float shipping_cost: float final_amount: float status: str payment_status: str fulfillment_status: str shipping_address_json: Optional[str] = None billing_address_json: Optional[str] = None tracking_number: Optional[str] = None courier_name: Optional[str] = None created_at: datetime items: List[OrderItemDetail] history: List[StatusHistorySchema] class AdminOrderListResponse(BaseModel): order_id: str order_no: str customer_email: Optional[str] = None customer_name: Optional[str] = None customer_phone: Optional[str] = None final_amount: float status: str payment_status: str created_at: datetime class StatusUpdatePayload(BaseModel): status: str tracking_number: Optional[str] = None courier_name: Optional[str] = None reason: Optional[str] = None # --- Endpoints --- @router.get("", response_model=List[AdminOrderListResponse]) def list_all_orders( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ List all customer orders in the system. """ orders = db.query(Order).order_by(Order.created_at.desc()).all() result = [] for o in orders: cust_email = None cust_name = None cust_phone = None if o.customer: cust_email = o.customer.email cust_name = f"{o.customer.first_name} {o.customer.last_name}".strip() cust_phone = o.customer.phone elif o.shipping_address_json or o.billing_address_json: try: addr = json.loads(o.shipping_address_json or o.billing_address_json or "{}") cust_name = addr.get("full_name") or addr.get("name") cust_phone = addr.get("phone") cust_email = addr.get("email") except Exception: pass result.append( AdminOrderListResponse( order_id=o.order_id, order_no=o.order_no, customer_email=cust_email or "N/A", customer_name=cust_name or "Walk-in Guest", customer_phone=cust_phone or "N/A", final_amount=float(o.final_amount), status=o.status, payment_status=o.payment_status, created_at=o.created_at ) ) return result @router.get("/{order_id}", response_model=AdminOrderDetailResponse) def get_order_details( order_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ Retrieve full details for any order including items, customer metadata, and status logs. """ order = db.query(Order).filter(Order.order_id == order_id).first() if not order: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Order not found" ) cust_email = None cust_name = None if order.customer: cust_email = order.customer.email cust_name = f"{order.customer.first_name} {order.customer.last_name}" return AdminOrderDetailResponse( order_id=order.order_id, order_no=order.order_no, customer_id=order.customer_id, customer_email=cust_email, customer_name=cust_name, total_amount=float(order.total_amount), discount_amount=float(order.discount_amount), tax_amount=float(order.tax_amount), shipping_cost=float(order.shipping_cost), final_amount=float(order.final_amount), status=order.status, payment_status=order.payment_status, fulfillment_status=order.fulfillment_status, shipping_address_json=order.shipping_address_json, billing_address_json=order.billing_address_json, tracking_number=order.tracking_number, courier_name=order.courier_name, created_at=order.created_at, items=[ OrderItemDetail( product_id=item.product_id, variant_id=item.variant_id, product_name=item.product_name, sku=item.sku, unit_price=float(item.unit_price), quantity=item.quantity, total_price=float(item.total_price) ) for item in order.items ], history=[ StatusHistorySchema( previous_status=h.previous_status, new_status=h.new_status, changed_by=h.changed_by, reason=h.reason, created_at=h.created_at ) for h in order.history ] ) @router.put("/{order_id}/status") def update_order_status( order_id: str, payload: StatusUpdatePayload, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ Transition an order status, record timeline logs, write ledger confirmations/releases, and update tracking coordinates. """ order = db.query(Order).filter(Order.order_id == order_id).first() if not order: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Order not found" ) previous_status = order.status new_status = payload.status # Transition tracking info if provided if payload.tracking_number: order.tracking_number = payload.tracking_number if payload.courier_name: order.courier_name = payload.courier_name # Set fulfillment status flags if new_status in ["SHIPPED", "DELIVERED"]: order.fulfillment_status = "FULFILLED" elif new_status == "CANCELLED": order.fulfillment_status = "CANCELLED" # --- STOCK CONFIRMATION / RELEASE LOGIC --- # 1. Transition: ORDER_CREATED/PAYMENT_PENDING -> ORDER_CONFIRMED/PROCESSING/SHIPPED/DELIVERED if new_status in ["ORDER_CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED"] and previous_status in ["ORDER_CREATED", "PAYMENT_PENDING"]: # Record the physical sale transition in the ledger by subtracting quantity for item in order.items: record_ledger_entry( variant_id=item.variant_id, event_type="ONLINE_SALE_FROM_RESERVATION", qty=-item.quantity, # Deduct stock upon confirmation reference_id=order.order_id, db=db, notes=f"Physical stock sale confirmed from reservation for order {order.order_no}", commit=False, ) order.payment_status = "PAYMENT_CAPTURED" # 2. Transition: Active -> CANCELLED elif new_status == "CANCELLED" and previous_status != "CANCELLED": # Check if the order was already confirmed was_confirmed = db.query(InventoryLedger).filter( InventoryLedger.reference_id == order.order_id, InventoryLedger.event_type == "ONLINE_SALE_FROM_RESERVATION" ).first() is not None # Check return eligibility: eligible if never delivered, or delivered within last 6 days can_restore = True if previous_status == "DELIVERED": del_history = db.query(OrderStatusHistory).filter( OrderStatusHistory.order_id == order.order_id, OrderStatusHistory.new_status == "DELIVERED" ).order_by(OrderStatusHistory.created_at.desc()).first() del_time = del_history.created_at if del_history else order.updated_at from datetime import datetime now = datetime.now(del_time.tzinfo) if del_time.tzinfo else datetime.now() delta = now - del_time if delta.days >= 6: can_restore = False for item in order.items: if was_confirmed: if can_restore: # Record RETURN (+qty) to restore physical and available stock if within 6 days record_ledger_entry( variant_id=item.variant_id, event_type="RETURN", qty=item.quantity, reference_id=order.order_id, db=db, notes=f"Restored stock from cancelled confirmed order {order.order_no} within 6-day window", commit=False, ) else: # Beyond 6 days, do not restore stock (write 0 delta) record_ledger_entry( variant_id=item.variant_id, event_type="RETURN", qty=0, reference_id=order.order_id, db=db, notes=f"Stock not restored: returned order {order.order_no} was past 6-day return window", commit=False, ) else: # If not confirmed yet, release the ONLINE_RESERVE reservation (qty=0) record_ledger_entry( variant_id=item.variant_id, event_type="ONLINE_RESERVE_RELEASE", qty=0, reference_id=order.order_id, db=db, notes=f"Released reservation for cancelled order {order.order_no}", commit=False, ) order.payment_status = "REFUNDED" if order.payment_status == "PAYMENT_CAPTURED" else "PAYMENT_FAILED" # Add change log history = OrderStatusHistory( history_id=str(ulid.ULID()), order_id=order.order_id, previous_status=previous_status, new_status=new_status, changed_by=current_user.email, reason=payload.reason or "Status updated via CRM Admin Panel" ) db.add(history) # Set status order.status = new_status db.commit() return {"message": "Order status updated successfully", "order_id": order_id, "new_status": order.status} # --- Manual Order Creation Schemas --- class AdminOrderCreateItem(BaseModel): variant_id: str quantity: int unit_price: float class AdminOrderCreateRequest(BaseModel): customer_name: Optional[str] = None customer_email: Optional[str] = None customer_phone: Optional[str] = None items: List[AdminOrderCreateItem] shipping_address: Optional[str] = None payment_status: str = "PAID" payment_method: str = "CASH" # CASH, UPI, CARD, MIXED_PAYMENT cash_amount: Optional[float] = 0.0 digital_amount: Optional[float] = 0.0 digital_method: Optional[str] = None # UPI or CARD razorpay_order_id: Optional[str] = None razorpay_payment_id: Optional[str] = None razorpay_signature: Optional[str] = None class PosRazorpayOrderRequest(BaseModel): amount: float @router.post("/create-pos-razorpay-order") def create_pos_razorpay_order( payload: PosRazorpayOrderRequest, current_user: User = Depends(get_current_user) ): """ Creates a Razorpay order for POS walk-in counter checkout. """ from app.core.razorpay import RazorpayService from app.core.config.Config import settings if payload.amount <= 0: raise HTTPException(status_code=400, detail="Amount must be greater than 0") amount_paise = int(round(payload.amount * 100)) rzp = RazorpayService() receipt_id = f"pos_rcpt_{ulid.ULID()}" order = rzp.create_order( amount_paise=amount_paise, receipt=receipt_id, notes={"pos_cashier": current_user.email} ) return { "razorpay_order_id": order["id"], "amount_paise": amount_paise, "amount": payload.amount, "key_id": settings.RAZORPAY_KEY_ID } @router.post("/create") def create_admin_order( payload: AdminOrderCreateRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ Directly write a manual/offline store walk-in counter order and auto-generate its Sales Invoice. """ from app.services.OrderService import OrderService items_dicts = [item.dict() for item in payload.items] return OrderService.create_walk_in_order( db=db, items_payload=items_dicts, customer_name=payload.customer_name, customer_email=payload.customer_email, customer_phone=payload.customer_phone, shipping_address=payload.shipping_address, payment_status=payload.payment_status or "PAID", payment_method=payload.payment_method or "CASH", cash_amount=payload.cash_amount or 0.0, digital_amount=payload.digital_amount or 0.0, digital_method=payload.digital_method, razorpay_order_id=payload.razorpay_order_id, razorpay_payment_id=payload.razorpay_payment_id, razorpay_signature=payload.razorpay_signature, actor_email=current_user.email )