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

281 lines
8.7 KiB
Python

"""
@router PosSyncRouter (Backend/app/api/v1/routers/PosSyncRouter.py)
Fully Dynamic Database-Driven POS Synchronization Gateway (Zero Hardcoded Data)
"""
import ulid
from datetime import datetime, date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, func, desc
from sqlalchemy.orm import Session
from app.core.database.db_session import get_db
from app.models.POSTerminalModel import POSTerminal, POSTransactionLog
router = APIRouter(prefix="/api/v1/pos", tags=["Offline POS Synchronization"])
class POSTransactionItem(BaseModel):
variant_id: str
qty: int
unit_price: float
class POSTransaction(BaseModel):
pos_transaction_id: str
local_sequence: int
invoice_no: str
total_amount: float
created_at: str
items: List[POSTransactionItem]
class POSSyncPayload(BaseModel):
device_id: str
terminal_id: str
store_id: str
transactions: List[POSTransaction]
class RegisterTerminalRequest(BaseModel):
terminal_id: str
store_name: str
ip_address: Optional[str] = "127.0.0.1"
@router.post("/terminals/register")
def register_pos_terminal(payload: RegisterTerminalRequest, db: Session = Depends(get_db)):
"""
Dynamically registers or updates a POS terminal.
"""
terminal = db.execute(
select(POSTerminal).where(POSTerminal.terminal_id == payload.terminal_id)
).scalar_one_or_none()
if not terminal:
terminal = POSTerminal(
terminal_id=payload.terminal_id,
store_name=payload.store_name,
status="ONLINE",
ip_address=payload.ip_address,
last_heartbeat=datetime.utcnow(),
synced_today=0,
pending_queue=0,
)
db.add(terminal)
else:
terminal.store_name = payload.store_name
terminal.ip_address = payload.ip_address
terminal.status = "ONLINE"
terminal.last_heartbeat = datetime.utcnow()
db.commit()
db.refresh(terminal)
return {
"status": "SUCCESS",
"message": f"Terminal '{terminal.terminal_id}' registered successfully.",
"terminal_id": terminal.terminal_id,
"store_name": terminal.store_name,
}
@router.post("/sync")
def sync_pos_transactions(payload: POSSyncPayload, db: Session = Depends(get_db)):
"""
Idempotent database-persisted synchronization gateway for offline POS transactions.
Dynamically registers unknown terminals upon sync submission.
"""
terminal = db.execute(
select(POSTerminal).where(POSTerminal.terminal_id == payload.terminal_id)
).scalar_one_or_none()
if not terminal:
terminal = POSTerminal(
terminal_id=payload.terminal_id,
store_name=payload.store_id or f"Store ({payload.terminal_id})",
status="ONLINE",
last_heartbeat=datetime.utcnow(),
synced_today=0,
pending_queue=0,
)
db.add(terminal)
db.commit()
db.refresh(terminal)
processed = []
for tx in payload.transactions:
# Check idempotency (prevent duplicate syncs)
existing = db.execute(
select(POSTransactionLog).where(
POSTransactionLog.pos_transaction_id == tx.pos_transaction_id
)
).scalar_one_or_none()
if existing:
processed.append({
"pos_transaction_id": tx.pos_transaction_id,
"status": "ALREADY_SYNCED",
"invoice_no": tx.invoice_no,
})
continue
log_entry = POSTransactionLog(
sync_id=f"POS-LOG-{str(ulid.ULID())[:8]}",
terminal_id=terminal.terminal_id,
invoice_no=tx.invoice_no,
pos_transaction_id=tx.pos_transaction_id,
items_count=len(tx.items),
total_amount=tx.total_amount,
status="SYNCED",
created_at=datetime.utcnow(),
)
db.add(log_entry)
terminal.synced_today = (terminal.synced_today or 0) + 1
terminal.last_heartbeat = datetime.utcnow()
terminal.status = "ONLINE"
processed.append({
"pos_transaction_id": tx.pos_transaction_id,
"status": "SYNCED",
"invoice_no": tx.invoice_no,
})
db.commit()
return {
"status": "SUCCESS",
"synced_count": len(processed),
"terminal_id": payload.terminal_id,
"results": processed,
}
@router.get("/status")
def get_pos_sync_status(db: Session = Depends(get_db)):
"""
Returns live health telemetry for offline POS sync gateways and store terminals strictly from MySQL DB.
"""
terminals = db.execute(select(POSTerminal)).scalars().all()
today_start = datetime.combine(date.today(), datetime.min.time())
revenue_today = db.execute(
select(func.sum(POSTransactionLog.total_amount)).where(
POSTransactionLog.created_at >= today_start,
POSTransactionLog.status == "SYNCED",
)
).scalar() or 0.0
synced_today_total = db.execute(
select(func.count(POSTransactionLog.sync_id)).where(
POSTransactionLog.created_at >= today_start,
POSTransactionLog.status == "SYNCED",
)
).scalar() or 0
pending_retries = db.execute(
select(func.count(POSTransactionLog.sync_id)).where(
POSTransactionLog.status == "PENDING_RETRY"
)
).scalar() or 0
terminals_list = []
now = datetime.utcnow()
for t in terminals:
time_diff = (now - t.last_heartbeat).total_seconds() if t.last_heartbeat else 99999
if time_diff < 60:
heartbeat_str = f"{int(time_diff)} seconds ago"
elif time_diff < 3600:
heartbeat_str = f"{int(time_diff // 60)} minutes ago"
else:
heartbeat_str = f"{int(time_diff // 3600)} hours ago"
terminals_list.append({
"terminal_id": t.terminal_id,
"store_name": t.store_name,
"status": t.status,
"ip_address": t.ip_address or "127.0.0.1",
"last_heartbeat": heartbeat_str,
"synced_today": t.synced_today,
"pending_queue": t.pending_queue,
})
last_log = db.execute(
select(POSTransactionLog).order_by(desc(POSTransactionLog.created_at)).limit(1)
).scalar_one_or_none()
last_sync = last_log.created_at.isoformat() if last_log else None
return {
"gateway_status": "ONLINE",
"active_terminals_count": len(terminals),
"synced_today_count": synced_today_total,
"pending_retries_count": pending_retries,
"offline_revenue_today": round(revenue_today, 2),
"last_sync_timestamp": last_sync,
"terminals": terminals_list,
}
@router.get("/logs")
def get_pos_sync_logs(
terminal_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
db: Session = Depends(get_db)
):
"""
Returns real database audit logs for offline POS sync transaction batches.
"""
stmt = select(POSTransactionLog).order_by(desc(POSTransactionLog.created_at))
if terminal_id:
stmt = stmt.where(POSTransactionLog.terminal_id == terminal_id)
if status:
stmt = stmt.where(POSTransactionLog.status == status)
logs = db.execute(stmt.limit(limit)).scalars().all()
logs_list = [
{
"sync_id": log.sync_id,
"terminal_id": log.terminal_id,
"invoice_no": log.invoice_no,
"pos_transaction_id": log.pos_transaction_id,
"items_count": log.items_count,
"total_amount": log.total_amount,
"status": log.status,
"created_at": log.created_at.isoformat(),
"error_detail": log.error_detail,
}
for log in logs
]
total_count = db.execute(select(func.count(POSTransactionLog.sync_id))).scalar() or 0
return {"total": total_count, "logs": logs_list}
@router.post("/terminals/{terminal_id}/reset")
def reset_terminal_sync(terminal_id: str, db: Session = Depends(get_db)):
"""
Clears pending sync locks or re-syncs state for a given POS terminal in MySQL database.
"""
terminal = db.execute(
select(POSTerminal).where(POSTerminal.terminal_id == terminal_id)
).scalar_one_or_none()
if not terminal:
raise HTTPException(status_code=404, detail=f"POS Terminal '{terminal_id}' not found")
terminal.pending_queue = 0
terminal.status = "ONLINE"
terminal.last_heartbeat = datetime.utcnow()
db.commit()
return {
"status": "SUCCESS",
"terminal_id": terminal_id,
"message": f"Terminal '{terminal_id}' sync state reset successfully in database.",
}