Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -13,6 +13,8 @@ from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role, admin_role_required
from shared.routing import RouteBuilder
from shared.security import create_audit_logger, AuditSeverity, AuditAction
from app.services.pos_config_service import POSConfigurationService
from app.schemas.pos_config import POSConfigurationListResponse
router = APIRouter()
logger = structlog.get_logger()
@@ -22,23 +24,41 @@ route_builder = RouteBuilder('pos')
@router.get(
route_builder.build_base_route("configurations"),
response_model=dict
response_model=POSConfigurationListResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def list_pos_configurations(
tenant_id: UUID = Path(...),
pos_system: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""List all POS configurations for a tenant"""
try:
return {
"configurations": [],
"total": 0,
"supported_systems": ["square", "toast", "lightspeed"]
}
service = POSConfigurationService()
configurations = await service.get_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active,
skip=skip,
limit=limit
)
total = await service.count_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active
)
return POSConfigurationListResponse(
configurations=configurations,
total=total,
supported_systems=["square", "toast", "lightspeed"]
)
except Exception as e:
logger.error("Failed to list POS configurations", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to list configurations: {str(e)}")

View File

@@ -14,6 +14,8 @@ from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role, admin_role_required
from shared.routing import RouteBuilder
from app.services.pos_transaction_service import POSTransactionService
from app.services.pos_config_service import POSConfigurationService
router = APIRouter()
logger = structlog.get_logger()
@@ -74,15 +76,33 @@ async def get_sync_status(
):
"""Get synchronization status and recent sync history"""
try:
transaction_service = POSTransactionService()
# Get sync metrics from transaction service
sync_metrics = await transaction_service.get_sync_metrics(tenant_id)
# Get last successful sync time
sync_status = sync_metrics["sync_status"]
last_successful_sync = sync_status.get("last_sync_at")
# Calculate sync success rate
total = sync_metrics["total_transactions"]
synced = sync_status.get("synced", 0)
success_rate = (synced / total * 100) if total > 0 else 100.0
return {
"current_sync": None,
"last_successful_sync": None,
"recent_syncs": [],
"last_successful_sync": last_successful_sync.isoformat() if last_successful_sync else None,
"recent_syncs": [], # Could be enhanced with actual sync history
"sync_health": {
"status": "healthy",
"success_rate": 95.5,
"average_duration_minutes": 3.2,
"last_error": None
"status": "healthy" if success_rate > 90 else "degraded" if success_rate > 70 else "unhealthy",
"success_rate": round(success_rate, 2),
"average_duration_minutes": 3.2, # Placeholder - could calculate from actual data
"last_error": None,
"total_transactions": total,
"synced_count": synced,
"pending_count": sync_status.get("pending", 0),
"failed_count": sync_status.get("failed", 0)
}
}
except Exception as e:
@@ -159,12 +179,35 @@ async def test_pos_connection(
):
"""Test connection to POS system (Admin/Owner only)"""
try:
config_service = POSConfigurationService()
# Get the configuration to verify it exists
configurations = await config_service.get_configurations_by_tenant(
tenant_id=tenant_id,
skip=0,
limit=100
)
config = next((c for c in configurations if str(c.id) == str(config_id)), None)
if not config:
raise HTTPException(status_code=404, detail="Configuration not found")
# For demo purposes, we assume connection is successful if config exists
# In production, this would actually test the POS API connection
is_connected = config.is_connected and config.is_active
return {
"status": "success",
"message": "Connection test successful",
"success": is_connected,
"status": "success" if is_connected else "failed",
"message": f"Connection test {'successful' if is_connected else 'failed'} for {config.pos_system}",
"tested_at": datetime.utcnow().isoformat(),
"config_id": str(config_id)
"config_id": str(config_id),
"pos_system": config.pos_system,
"health_status": config.health_status
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to test POS connection", error=str(e),
tenant_id=tenant_id, config_id=config_id)

View File

@@ -4,15 +4,22 @@ ATOMIC layer - Basic CRUD operations for POS transactions
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import Optional, Dict, Any
from typing import Optional
from uuid import UUID
from datetime import datetime
from decimal import Decimal
import structlog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from app.services.pos_transaction_service import POSTransactionService
from app.schemas.pos_transaction import (
POSTransactionResponse,
POSTransactionListResponse,
POSTransactionDashboardSummary
)
router = APIRouter()
logger = structlog.get_logger()
@@ -21,7 +28,7 @@ route_builder = RouteBuilder('pos')
@router.get(
route_builder.build_base_route("transactions"),
response_model=dict
response_model=POSTransactionListResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def list_pos_transactions(
@@ -38,20 +45,46 @@ async def list_pos_transactions(
):
"""List POS transactions for a tenant"""
try:
return {
"transactions": [],
"total": 0,
"has_more": False,
"summary": {
"total_amount": 0,
"transaction_count": 0,
"sync_status": {
"synced": 0,
"pending": 0,
"failed": 0
}
service = POSTransactionService()
transactions = await service.get_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced,
skip=offset,
limit=limit
)
total = await service.count_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced
)
# Get sync metrics for summary
sync_metrics = await service.get_sync_metrics(tenant_id)
# Calculate summary
total_amount = sum(float(t.total_amount) for t in transactions if t.status == "completed")
has_more = (offset + limit) < total
return POSTransactionListResponse(
transactions=transactions,
total=total,
has_more=has_more,
summary={
"total_amount": total_amount,
"transaction_count": len(transactions),
"sync_status": sync_metrics["sync_status"]
}
}
)
except Exception as e:
logger.error("Failed to list POS transactions", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to list transactions: {str(e)}")
@@ -59,7 +92,7 @@ async def list_pos_transactions(
@router.get(
route_builder.build_resource_detail_route("transactions", "transaction_id"),
response_model=dict
response_model=POSTransactionResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_pos_transaction(
@@ -70,13 +103,46 @@ async def get_pos_transaction(
):
"""Get a specific POS transaction"""
try:
return {
"id": str(transaction_id),
"tenant_id": str(tenant_id),
"status": "completed",
"is_synced": True
}
service = POSTransactionService()
transaction = await service.get_transaction_with_items(
transaction_id=transaction_id,
tenant_id=tenant_id
)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
return transaction
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get POS transaction", error=str(e),
tenant_id=tenant_id, transaction_id=transaction_id)
raise HTTPException(status_code=500, detail=f"Failed to get transaction: {str(e)}")
@router.get(
route_builder.build_operations_route("transactions-dashboard"),
response_model=POSTransactionDashboardSummary
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_transactions_dashboard(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Get dashboard summary for POS transactions"""
try:
service = POSTransactionService()
summary = await service.get_dashboard_summary(tenant_id)
logger.info("Transactions dashboard retrieved",
tenant_id=str(tenant_id),
total_today=summary.total_transactions_today)
return summary
except Exception as e:
logger.error("Failed to get transactions dashboard", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get dashboard: {str(e)}")

View File

@@ -0,0 +1,82 @@
"""
POS Configuration Repository using Repository Pattern
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.pos_config import POSConfiguration
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class POSConfigurationRepository(BaseRepository[POSConfiguration, dict, dict]):
"""Repository for POS configuration operations"""
def __init__(self, session: AsyncSession):
super().__init__(POSConfiguration, session)
async def get_configurations_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100
) -> List[POSConfiguration]:
"""Get POS configurations for a specific tenant with optional filters"""
try:
query = select(self.model).where(self.model.tenant_id == tenant_id)
# Apply filters
conditions = []
if pos_system:
conditions.append(self.model.pos_system == pos_system)
if is_active is not None:
conditions.append(self.model.is_active == is_active)
if conditions:
query = query.where(and_(*conditions))
query = query.offset(skip).limit(limit).order_by(self.model.created_at.desc())
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get configurations by tenant", error=str(e), tenant_id=tenant_id)
raise
async def count_configurations_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
is_active: Optional[bool] = None
) -> int:
"""Count POS configurations for a specific tenant with optional filters"""
try:
from sqlalchemy import func
query = select(func.count(self.model.id)).where(self.model.tenant_id == tenant_id)
# Apply filters
conditions = []
if pos_system:
conditions.append(self.model.pos_system == pos_system)
if is_active is not None:
conditions.append(self.model.is_active == is_active)
if conditions:
query = query.where(and_(*conditions))
result = await self.session.execute(query)
count = result.scalar() or 0
return count
except Exception as e:
logger.error("Failed to count configurations by tenant", error=str(e), tenant_id=tenant_id)
raise

View File

@@ -0,0 +1,113 @@
"""
POS Transaction Item Repository using Repository Pattern
"""
from typing import List, Optional
from uuid import UUID
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.models.pos_transaction import POSTransactionItem
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class POSTransactionItemRepository(BaseRepository[POSTransactionItem, dict, dict]):
"""Repository for POS transaction item operations"""
def __init__(self, session: AsyncSession):
super().__init__(POSTransactionItem, session)
async def get_items_by_transaction(
self,
transaction_id: UUID
) -> List[POSTransactionItem]:
"""Get all items for a transaction"""
try:
query = select(POSTransactionItem).where(
POSTransactionItem.transaction_id == transaction_id
).order_by(POSTransactionItem.created_at)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get transaction items",
transaction_id=str(transaction_id),
error=str(e))
raise
async def get_items_by_product(
self,
tenant_id: UUID,
product_name: str,
skip: int = 0,
limit: int = 100
) -> List[POSTransactionItem]:
"""Get all transaction items for a specific product"""
try:
query = select(POSTransactionItem).where(
and_(
POSTransactionItem.tenant_id == tenant_id,
POSTransactionItem.product_name.ilike(f"%{product_name}%")
)
).order_by(POSTransactionItem.created_at.desc()).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get items by product",
product_name=product_name,
error=str(e))
raise
async def get_items_by_sku(
self,
tenant_id: UUID,
sku: str
) -> List[POSTransactionItem]:
"""Get all transaction items for a specific SKU"""
try:
query = select(POSTransactionItem).where(
and_(
POSTransactionItem.tenant_id == tenant_id,
POSTransactionItem.sku == sku
)
).order_by(POSTransactionItem.created_at.desc())
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get items by SKU",
sku=sku,
error=str(e))
raise
async def get_items_by_category(
self,
tenant_id: UUID,
category: str,
skip: int = 0,
limit: int = 100
) -> List[POSTransactionItem]:
"""Get all transaction items for a specific category"""
try:
query = select(POSTransactionItem).where(
and_(
POSTransactionItem.tenant_id == tenant_id,
POSTransactionItem.product_category == category
)
).order_by(POSTransactionItem.created_at.desc()).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get items by category",
category=category,
error=str(e))
raise

View File

@@ -0,0 +1,362 @@
"""
POS Transaction Repository using Repository Pattern
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, date, timedelta
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import structlog
from app.models.pos_transaction import POSTransaction, POSTransactionItem
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class POSTransactionRepository(BaseRepository[POSTransaction, dict, dict]):
"""Repository for POS transaction operations"""
def __init__(self, session: AsyncSession):
super().__init__(POSTransaction, session)
async def get_transactions_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
status: Optional[str] = None,
is_synced: Optional[bool] = None,
skip: int = 0,
limit: int = 50
) -> List[POSTransaction]:
"""Get POS transactions for a specific tenant with optional filters"""
try:
query = select(self.model).options(
selectinload(POSTransaction.items)
).where(self.model.tenant_id == tenant_id)
# Apply filters
conditions = []
if pos_system:
conditions.append(self.model.pos_system == pos_system)
if status:
conditions.append(self.model.status == status)
if is_synced is not None:
conditions.append(self.model.is_synced_to_sales == is_synced)
if start_date:
conditions.append(self.model.transaction_date >= start_date)
if end_date:
conditions.append(self.model.transaction_date <= end_date)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(self.model.transaction_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get transactions by tenant", error=str(e), tenant_id=tenant_id)
raise
async def count_transactions_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
status: Optional[str] = None,
is_synced: Optional[bool] = None
) -> int:
"""Count POS transactions for a specific tenant with optional filters"""
try:
query = select(func.count(self.model.id)).where(self.model.tenant_id == tenant_id)
# Apply filters
conditions = []
if pos_system:
conditions.append(self.model.pos_system == pos_system)
if status:
conditions.append(self.model.status == status)
if is_synced is not None:
conditions.append(self.model.is_synced_to_sales == is_synced)
if start_date:
conditions.append(self.model.transaction_date >= start_date)
if end_date:
conditions.append(self.model.transaction_date <= end_date)
if conditions:
query = query.where(and_(*conditions))
result = await self.session.execute(query)
count = result.scalar() or 0
return count
except Exception as e:
logger.error("Failed to count transactions by tenant", error=str(e), tenant_id=tenant_id)
raise
async def get_transaction_with_items(
self,
transaction_id: UUID,
tenant_id: UUID
) -> Optional[POSTransaction]:
"""Get transaction with all its items"""
try:
query = select(POSTransaction).options(
selectinload(POSTransaction.items)
).where(
and_(
POSTransaction.id == transaction_id,
POSTransaction.tenant_id == tenant_id
)
)
result = await self.session.execute(query)
return result.scalar_one_or_none()
except Exception as e:
logger.error("Failed to get transaction with items",
transaction_id=str(transaction_id),
error=str(e))
raise
async def get_transactions_by_pos_config(
self,
pos_config_id: UUID,
skip: int = 0,
limit: int = 50
) -> List[POSTransaction]:
"""Get transactions for a specific POS configuration"""
try:
query = select(POSTransaction).options(
selectinload(POSTransaction.items)
).where(
POSTransaction.pos_config_id == pos_config_id
).order_by(desc(POSTransaction.transaction_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get transactions by pos config",
pos_config_id=str(pos_config_id),
error=str(e))
raise
async def get_transactions_by_date_range(
self,
tenant_id: UUID,
start_date: date,
end_date: date,
skip: int = 0,
limit: int = 100
) -> List[POSTransaction]:
"""Get transactions within date range"""
try:
start_datetime = datetime.combine(start_date, datetime.min.time())
end_datetime = datetime.combine(end_date, datetime.max.time())
query = select(POSTransaction).options(
selectinload(POSTransaction.items)
).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= start_datetime,
POSTransaction.transaction_date <= end_datetime
)
).order_by(desc(POSTransaction.transaction_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
return result.scalars().all()
except Exception as e:
logger.error("Failed to get transactions by date range",
start_date=str(start_date),
end_date=str(end_date),
error=str(e))
raise
async def get_dashboard_metrics(
self,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get dashboard metrics for transactions"""
try:
# Today's metrics
today = datetime.now().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = today - timedelta(days=today.weekday())
week_start_datetime = datetime.combine(week_start, datetime.min.time())
month_start = today.replace(day=1)
month_start_datetime = datetime.combine(month_start, datetime.min.time())
# Transaction counts by period
transactions_today = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= today_start,
POSTransaction.transaction_date <= today_end,
POSTransaction.status == "completed"
)
)
)
transactions_week = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= week_start_datetime,
POSTransaction.status == "completed"
)
)
)
transactions_month = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= month_start_datetime,
POSTransaction.status == "completed"
)
)
)
# Revenue by period
revenue_today = await self.session.execute(
select(func.coalesce(func.sum(POSTransaction.total_amount), 0)).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= today_start,
POSTransaction.transaction_date <= today_end,
POSTransaction.status == "completed"
)
)
)
revenue_week = await self.session.execute(
select(func.coalesce(func.sum(POSTransaction.total_amount), 0)).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= week_start_datetime,
POSTransaction.status == "completed"
)
)
)
revenue_month = await self.session.execute(
select(func.coalesce(func.sum(POSTransaction.total_amount), 0)).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.transaction_date >= month_start_datetime,
POSTransaction.status == "completed"
)
)
)
# Status breakdown
status_counts = await self.session.execute(
select(POSTransaction.status, func.count()).select_from(POSTransaction).where(
POSTransaction.tenant_id == tenant_id
).group_by(POSTransaction.status)
)
status_breakdown = {status: count for status, count in status_counts.fetchall()}
# Payment method breakdown
payment_counts = await self.session.execute(
select(POSTransaction.payment_method, func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.status == "completed"
)
).group_by(POSTransaction.payment_method)
)
payment_breakdown = {method: count for method, count in payment_counts.fetchall()}
# Average transaction value
avg_transaction_value = await self.session.execute(
select(func.coalesce(func.avg(POSTransaction.total_amount), 0)).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.status == "completed"
)
)
)
return {
"total_transactions_today": transactions_today.scalar(),
"total_transactions_this_week": transactions_week.scalar(),
"total_transactions_this_month": transactions_month.scalar(),
"revenue_today": float(revenue_today.scalar()),
"revenue_this_week": float(revenue_week.scalar()),
"revenue_this_month": float(revenue_month.scalar()),
"status_breakdown": status_breakdown,
"payment_method_breakdown": payment_breakdown,
"average_transaction_value": float(avg_transaction_value.scalar())
}
except Exception as e:
logger.error("Failed to get dashboard metrics", error=str(e), tenant_id=tenant_id)
raise
async def get_sync_status_summary(
self,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get sync status summary for transactions"""
try:
# Count synced vs unsynced
synced_count = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.is_synced_to_sales == True
)
)
)
pending_count = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.is_synced_to_sales == False,
POSTransaction.sync_error.is_(None)
)
)
)
failed_count = await self.session.execute(
select(func.count()).select_from(POSTransaction).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.is_synced_to_sales == False,
POSTransaction.sync_error.isnot(None)
)
)
)
# Get last sync time
last_sync = await self.session.execute(
select(func.max(POSTransaction.sync_completed_at)).where(
and_(
POSTransaction.tenant_id == tenant_id,
POSTransaction.is_synced_to_sales == True
)
)
)
return {
"synced": synced_count.scalar(),
"pending": pending_count.scalar(),
"failed": failed_count.scalar(),
"last_sync_at": last_sync.scalar()
}
except Exception as e:
logger.error("Failed to get sync status summary", error=str(e), tenant_id=tenant_id)
raise

View File

@@ -0,0 +1,95 @@
"""
Pydantic schemas for POS configuration API requests and responses
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum
class POSProvider(str, Enum):
"""POS provider types"""
SQUARE = "square"
TOAST = "toast"
LIGHTSPEED = "lightspeed"
class POSConfigurationBase(BaseModel):
"""Base schema for POS configurations"""
class Config:
from_attributes = True
use_enum_values = True
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
class POSConfigurationResponse(POSConfigurationBase):
"""Schema for POS configuration API responses"""
id: str
tenant_id: str
pos_system: POSProvider
provider_name: str
is_active: bool
is_connected: bool
webhook_url: Optional[str] = None
webhook_secret: Optional[str] = None
environment: str = "sandbox"
location_id: Optional[str] = None
merchant_id: Optional[str] = None
sync_enabled: bool = True
sync_interval_minutes: str = "5"
auto_sync_products: bool = True
auto_sync_transactions: bool = True
last_sync_at: Optional[datetime] = None
last_successful_sync_at: Optional[datetime] = None
last_sync_status: Optional[str] = None
last_sync_message: Optional[str] = None
provider_settings: Optional[Dict[str, Any]] = None
last_health_check_at: Optional[datetime] = None
health_status: str = "unknown"
health_message: Optional[str] = None
created_at: datetime
updated_at: datetime
notes: Optional[str] = None
@classmethod
def from_orm(cls, obj):
"""Convert ORM object to schema with proper UUID handling"""
return cls(
id=str(obj.id),
tenant_id=str(obj.tenant_id),
pos_system=obj.pos_system,
provider_name=obj.provider_name,
is_active=obj.is_active,
is_connected=obj.is_connected,
webhook_url=obj.webhook_url,
webhook_secret=obj.webhook_secret,
environment=obj.environment,
location_id=obj.location_id,
merchant_id=obj.merchant_id,
sync_enabled=obj.sync_enabled,
sync_interval_minutes=obj.sync_interval_minutes,
auto_sync_products=obj.auto_sync_products,
auto_sync_transactions=obj.auto_sync_transactions,
last_sync_at=obj.last_sync_at,
last_successful_sync_at=obj.last_successful_sync_at,
last_sync_status=obj.last_sync_status,
last_sync_message=obj.last_sync_message,
provider_settings=obj.provider_settings,
last_health_check_at=obj.last_health_check_at,
health_status=obj.health_status,
health_message=obj.health_message,
created_at=obj.created_at,
updated_at=obj.updated_at,
notes=obj.notes
)
class POSConfigurationListResponse(BaseModel):
"""Schema for POS configuration list API response"""
configurations: List[POSConfigurationResponse]
total: int
supported_systems: List[str] = ["square", "toast", "lightspeed"]

View File

@@ -0,0 +1,248 @@
"""
Pydantic schemas for POS transaction API requests and responses
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from enum import Enum
class TransactionType(str, Enum):
"""Transaction type enumeration"""
SALE = "sale"
REFUND = "refund"
VOID = "void"
EXCHANGE = "exchange"
class TransactionStatus(str, Enum):
"""Transaction status enumeration"""
COMPLETED = "completed"
PENDING = "pending"
FAILED = "failed"
REFUNDED = "refunded"
VOIDED = "voided"
class PaymentMethod(str, Enum):
"""Payment method enumeration"""
CARD = "card"
CASH = "cash"
DIGITAL_WALLET = "digital_wallet"
OTHER = "other"
class OrderType(str, Enum):
"""Order type enumeration"""
DINE_IN = "dine_in"
TAKEOUT = "takeout"
DELIVERY = "delivery"
PICKUP = "pickup"
class POSTransactionItemResponse(BaseModel):
"""Schema for POS transaction item response"""
id: str
transaction_id: str
tenant_id: str
external_item_id: Optional[str] = None
sku: Optional[str] = None
product_name: str
product_category: Optional[str] = None
product_subcategory: Optional[str] = None
quantity: Decimal
unit_price: Decimal
total_price: Decimal
discount_amount: Decimal = Decimal("0")
tax_amount: Decimal = Decimal("0")
modifiers: Optional[Dict[str, Any]] = None
inventory_product_id: Optional[str] = None
is_mapped_to_inventory: bool = False
is_synced_to_sales: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
use_enum_values = True
json_encoders = {
datetime: lambda v: v.isoformat() if v else None,
Decimal: lambda v: float(v) if v else 0.0
}
@classmethod
def from_orm(cls, obj):
"""Convert ORM object to schema with proper UUID and Decimal handling"""
return cls(
id=str(obj.id),
transaction_id=str(obj.transaction_id),
tenant_id=str(obj.tenant_id),
external_item_id=obj.external_item_id,
sku=obj.sku,
product_name=obj.product_name,
product_category=obj.product_category,
product_subcategory=obj.product_subcategory,
quantity=obj.quantity,
unit_price=obj.unit_price,
total_price=obj.total_price,
discount_amount=obj.discount_amount,
tax_amount=obj.tax_amount,
modifiers=obj.modifiers,
inventory_product_id=str(obj.inventory_product_id) if obj.inventory_product_id else None,
is_mapped_to_inventory=obj.is_mapped_to_inventory,
is_synced_to_sales=obj.is_synced_to_sales,
created_at=obj.created_at,
updated_at=obj.updated_at
)
class POSTransactionResponse(BaseModel):
"""Schema for POS transaction response"""
id: str
tenant_id: str
pos_config_id: str
pos_system: str
external_transaction_id: str
external_order_id: Optional[str] = None
transaction_type: TransactionType
status: TransactionStatus
subtotal: Decimal
tax_amount: Decimal
tip_amount: Decimal
discount_amount: Decimal
total_amount: Decimal
currency: str = "EUR"
payment_method: Optional[PaymentMethod] = None
payment_status: Optional[str] = None
transaction_date: datetime
pos_created_at: datetime
pos_updated_at: Optional[datetime] = None
location_id: Optional[str] = None
location_name: Optional[str] = None
staff_id: Optional[str] = None
staff_name: Optional[str] = None
customer_id: Optional[str] = None
customer_email: Optional[str] = None
customer_phone: Optional[str] = None
order_type: Optional[OrderType] = None
table_number: Optional[str] = None
receipt_number: Optional[str] = None
is_synced_to_sales: bool = False
sales_record_id: Optional[str] = None
sync_attempted_at: Optional[datetime] = None
sync_completed_at: Optional[datetime] = None
sync_error: Optional[str] = None
sync_retry_count: int = 0
is_processed: bool = False
is_duplicate: bool = False
created_at: datetime
updated_at: datetime
items: List[POSTransactionItemResponse] = []
class Config:
from_attributes = True
use_enum_values = True
json_encoders = {
datetime: lambda v: v.isoformat() if v else None,
Decimal: lambda v: float(v) if v else 0.0
}
@classmethod
def from_orm(cls, obj):
"""Convert ORM object to schema with proper UUID and Decimal handling"""
return cls(
id=str(obj.id),
tenant_id=str(obj.tenant_id),
pos_config_id=str(obj.pos_config_id),
pos_system=obj.pos_system,
external_transaction_id=obj.external_transaction_id,
external_order_id=obj.external_order_id,
transaction_type=obj.transaction_type,
status=obj.status,
subtotal=obj.subtotal,
tax_amount=obj.tax_amount,
tip_amount=obj.tip_amount,
discount_amount=obj.discount_amount,
total_amount=obj.total_amount,
currency=obj.currency,
payment_method=obj.payment_method,
payment_status=obj.payment_status,
transaction_date=obj.transaction_date,
pos_created_at=obj.pos_created_at,
pos_updated_at=obj.pos_updated_at,
location_id=obj.location_id,
location_name=obj.location_name,
staff_id=obj.staff_id,
staff_name=obj.staff_name,
customer_id=obj.customer_id,
customer_email=obj.customer_email,
customer_phone=obj.customer_phone,
order_type=obj.order_type,
table_number=obj.table_number,
receipt_number=obj.receipt_number,
is_synced_to_sales=obj.is_synced_to_sales,
sales_record_id=str(obj.sales_record_id) if obj.sales_record_id else None,
sync_attempted_at=obj.sync_attempted_at,
sync_completed_at=obj.sync_completed_at,
sync_error=obj.sync_error,
sync_retry_count=obj.sync_retry_count,
is_processed=obj.is_processed,
is_duplicate=obj.is_duplicate,
created_at=obj.created_at,
updated_at=obj.updated_at,
items=[POSTransactionItemResponse.from_orm(item) for item in obj.items] if hasattr(obj, 'items') and obj.items else []
)
class POSTransactionSummary(BaseModel):
"""Summary information for a transaction (lightweight)"""
id: str
external_transaction_id: str
transaction_date: datetime
total_amount: Decimal
status: TransactionStatus
payment_method: Optional[PaymentMethod] = None
is_synced_to_sales: bool
item_count: int = 0
class Config:
from_attributes = True
use_enum_values = True
json_encoders = {
datetime: lambda v: v.isoformat() if v else None,
Decimal: lambda v: float(v) if v else 0.0
}
class POSTransactionListResponse(BaseModel):
"""Schema for paginated transaction list response"""
transactions: List[POSTransactionResponse]
total: int
has_more: bool = False
summary: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class POSTransactionDashboardSummary(BaseModel):
"""Dashboard summary for POS transactions"""
total_transactions_today: int = 0
total_transactions_this_week: int = 0
total_transactions_this_month: int = 0
revenue_today: Decimal = Decimal("0")
revenue_this_week: Decimal = Decimal("0")
revenue_this_month: Decimal = Decimal("0")
average_transaction_value: Decimal = Decimal("0")
status_breakdown: Dict[str, int] = {}
payment_method_breakdown: Dict[str, int] = {}
sync_status: Dict[str, Any] = {}
class Config:
from_attributes = True
json_encoders = {
Decimal: lambda v: float(v) if v else 0.0,
datetime: lambda v: v.isoformat() if v else None
}

View File

@@ -0,0 +1,76 @@
"""
POS Configuration Service - Business Logic Layer
"""
from typing import List, Optional
from uuid import UUID
import structlog
from app.repositories.pos_config_repository import POSConfigurationRepository
from app.schemas.pos_config import POSConfigurationResponse
from app.core.database import get_db_transaction
logger = structlog.get_logger()
class POSConfigurationService:
"""Service layer for POS configuration operations"""
def __init__(self):
pass
async def get_configurations_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
is_active: Optional[bool] = None,
skip: int = 0,
limit: int = 100
) -> List[POSConfigurationResponse]:
"""Get POS configurations for a tenant with filtering"""
try:
async with get_db_transaction() as db:
repository = POSConfigurationRepository(db)
configurations = await repository.get_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active,
skip=skip,
limit=limit
)
# Convert to response schemas using from_orm
responses = []
for config in configurations:
response = POSConfigurationResponse.from_orm(config)
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get configurations by tenant", error=str(e), tenant_id=tenant_id)
raise
async def count_configurations_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
is_active: Optional[bool] = None
) -> int:
"""Count POS configurations for a tenant with filtering"""
try:
async with get_db_transaction() as db:
repository = POSConfigurationRepository(db)
count = await repository.count_configurations_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
is_active=is_active
)
return count
except Exception as e:
logger.error("Failed to count configurations by tenant", error=str(e), tenant_id=tenant_id)
raise

View File

@@ -0,0 +1,239 @@
"""
POS Transaction Service - Business Logic Layer
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from decimal import Decimal
import structlog
from app.repositories.pos_transaction_repository import POSTransactionRepository
from app.repositories.pos_transaction_item_repository import POSTransactionItemRepository
from app.schemas.pos_transaction import (
POSTransactionResponse,
POSTransactionDashboardSummary
)
from app.core.database import get_db_transaction
logger = structlog.get_logger()
class POSTransactionService:
"""Service layer for POS transaction operations"""
def __init__(self):
pass
async def get_transactions_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
status: Optional[str] = None,
is_synced: Optional[bool] = None,
skip: int = 0,
limit: int = 50
) -> List[POSTransactionResponse]:
"""Get POS transactions for a tenant with filtering"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
transactions = await repository.get_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced,
skip=skip,
limit=limit
)
# Convert to response schemas
responses = []
for transaction in transactions:
response = POSTransactionResponse.from_orm(transaction)
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get transactions by tenant", error=str(e), tenant_id=tenant_id)
raise
async def count_transactions_by_tenant(
self,
tenant_id: UUID,
pos_system: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
status: Optional[str] = None,
is_synced: Optional[bool] = None
) -> int:
"""Count POS transactions for a tenant with filtering"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
count = await repository.count_transactions_by_tenant(
tenant_id=tenant_id,
pos_system=pos_system,
start_date=start_date,
end_date=end_date,
status=status,
is_synced=is_synced
)
return count
except Exception as e:
logger.error("Failed to count transactions by tenant", error=str(e), tenant_id=tenant_id)
raise
async def get_transaction_with_items(
self,
transaction_id: UUID,
tenant_id: UUID
) -> Optional[POSTransactionResponse]:
"""Get transaction with all its items"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
transaction = await repository.get_transaction_with_items(
transaction_id=transaction_id,
tenant_id=tenant_id
)
if not transaction:
return None
return POSTransactionResponse.from_orm(transaction)
except Exception as e:
logger.error("Failed to get transaction with items",
transaction_id=str(transaction_id),
error=str(e))
raise
async def get_dashboard_summary(
self,
tenant_id: UUID
) -> POSTransactionDashboardSummary:
"""Get dashboard summary for POS transactions"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
# Get metrics from repository
metrics = await repository.get_dashboard_metrics(tenant_id)
# Get sync status
sync_status = await repository.get_sync_status_summary(tenant_id)
# Construct dashboard summary
return POSTransactionDashboardSummary(
total_transactions_today=metrics["total_transactions_today"],
total_transactions_this_week=metrics["total_transactions_this_week"],
total_transactions_this_month=metrics["total_transactions_this_month"],
revenue_today=Decimal(str(metrics["revenue_today"])),
revenue_this_week=Decimal(str(metrics["revenue_this_week"])),
revenue_this_month=Decimal(str(metrics["revenue_this_month"])),
average_transaction_value=Decimal(str(metrics["average_transaction_value"])),
status_breakdown=metrics["status_breakdown"],
payment_method_breakdown=metrics["payment_method_breakdown"],
sync_status=sync_status
)
except Exception as e:
logger.error("Failed to get dashboard summary", error=str(e), tenant_id=tenant_id)
raise
async def get_sync_metrics(
self,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get sync metrics for transactions"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
sync_status = await repository.get_sync_status_summary(tenant_id)
# Calculate sync rate
total = sync_status["synced"] + sync_status["pending"] + sync_status["failed"]
sync_rate = (sync_status["synced"] / total * 100) if total > 0 else 0
return {
"sync_status": sync_status,
"sync_rate_percentage": round(sync_rate, 2),
"total_transactions": total
}
except Exception as e:
logger.error("Failed to get sync metrics", error=str(e), tenant_id=tenant_id)
raise
async def calculate_transaction_analytics(
self,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> Dict[str, Any]:
"""Calculate analytics for transactions within a date range"""
try:
async with get_db_transaction() as db:
repository = POSTransactionRepository(db)
transactions = await repository.get_transactions_by_date_range(
tenant_id=tenant_id,
start_date=start_date.date(),
end_date=end_date.date(),
skip=0,
limit=10000 # Large limit for analytics
)
# Calculate analytics
total_revenue = Decimal("0")
total_transactions = len(transactions)
payment_methods = {}
order_types = {}
hourly_distribution = {}
for transaction in transactions:
if transaction.status == "completed":
total_revenue += transaction.total_amount
# Payment method breakdown
pm = transaction.payment_method or "unknown"
payment_methods[pm] = payment_methods.get(pm, 0) + 1
# Order type breakdown
ot = transaction.order_type or "unknown"
order_types[ot] = order_types.get(ot, 0) + 1
# Hourly distribution
hour = transaction.transaction_date.hour
hourly_distribution[hour] = hourly_distribution.get(hour, 0) + 1
avg_transaction_value = (total_revenue / total_transactions) if total_transactions > 0 else Decimal("0")
return {
"period": {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
},
"total_revenue": float(total_revenue),
"total_transactions": total_transactions,
"average_transaction_value": float(avg_transaction_value),
"payment_methods": payment_methods,
"order_types": order_types,
"hourly_distribution": hourly_distribution
}
except Exception as e:
logger.error("Failed to calculate transaction analytics", error=str(e), tenant_id=tenant_id)
raise