353 lines
13 KiB
Python
353 lines
13 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"
|
|
|
|
@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 GST 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",
|
|
actor_email=current_user.email
|
|
)
|