281 lines
8.7 KiB
Python
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.",
|
|
}
|