ifixkart-backend/app/api/v1/routers/AdminOrderRouter.py

400 lines
14 KiB
Python

"""
@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
)