483 lines
19 KiB
Python
483 lines
19 KiB
Python
"""
|
|
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
|
|
|
|
async def sync_transaction_to_sales(
|
|
self,
|
|
transaction_id: UUID,
|
|
tenant_id: UUID
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Sync a single POS transaction to the sales service
|
|
|
|
Args:
|
|
transaction_id: Transaction UUID
|
|
tenant_id: Tenant UUID
|
|
|
|
Returns:
|
|
Dict with sync status and details
|
|
"""
|
|
try:
|
|
from shared.clients.sales_client import SalesServiceClient
|
|
from app.core.config import settings
|
|
|
|
async with get_db_transaction() as db:
|
|
transaction_repo = POSTransactionRepository(db)
|
|
items_repo = POSTransactionItemRepository(db)
|
|
|
|
# Get transaction
|
|
transaction = await transaction_repo.get_by_id(transaction_id)
|
|
if not transaction or transaction.tenant_id != tenant_id:
|
|
return {
|
|
"success": False,
|
|
"error": "Transaction not found or unauthorized"
|
|
}
|
|
|
|
# Check if already synced
|
|
if transaction.is_synced_to_sales:
|
|
logger.info("Transaction already synced to sales",
|
|
transaction_id=transaction_id,
|
|
sales_record_id=transaction.sales_record_id)
|
|
return {
|
|
"success": True,
|
|
"already_synced": True,
|
|
"sales_record_id": str(transaction.sales_record_id)
|
|
}
|
|
|
|
# Get transaction items
|
|
items = await items_repo.get_by_transaction_id(transaction_id)
|
|
|
|
# Initialize sales client
|
|
sales_client = SalesServiceClient(settings, calling_service_name="pos")
|
|
|
|
# Create sales records for each item
|
|
sales_record_ids = []
|
|
failed_items = []
|
|
|
|
for item in items:
|
|
try:
|
|
sales_data = {
|
|
"inventory_product_id": str(item.product_id) if item.product_id else None,
|
|
"product_name": item.product_name,
|
|
"product_category": "finished_product",
|
|
"quantity_sold": float(item.quantity),
|
|
"unit_price": float(item.unit_price),
|
|
"total_amount": float(item.subtotal),
|
|
"sale_date": transaction.transaction_date.strftime("%Y-%m-%d"),
|
|
"sales_channel": "pos",
|
|
"source": f"pos_sync_{transaction.pos_system}",
|
|
"payment_method": transaction.payment_method or "unknown",
|
|
"notes": f"POS Transaction: {transaction.external_transaction_id or transaction_id}"
|
|
}
|
|
|
|
result = await sales_client.create_sales_record(
|
|
tenant_id=str(tenant_id),
|
|
sales_data=sales_data
|
|
)
|
|
|
|
if result and result.get("id"):
|
|
sales_record_ids.append(result["id"])
|
|
logger.info("Synced item to sales",
|
|
transaction_id=transaction_id,
|
|
item_id=item.id,
|
|
sales_record_id=result["id"])
|
|
else:
|
|
failed_items.append({
|
|
"item_id": str(item.id),
|
|
"product_name": item.product_name,
|
|
"error": "No sales record ID returned"
|
|
})
|
|
|
|
except Exception as item_error:
|
|
logger.error("Failed to sync item to sales",
|
|
error=str(item_error),
|
|
transaction_id=transaction_id,
|
|
item_id=item.id)
|
|
failed_items.append({
|
|
"item_id": str(item.id),
|
|
"product_name": item.product_name,
|
|
"error": str(item_error)
|
|
})
|
|
|
|
# Update transaction sync status
|
|
if sales_record_ids and len(failed_items) == 0:
|
|
# Full success
|
|
transaction.is_synced_to_sales = True
|
|
transaction.sales_record_id = UUID(sales_record_ids[0]) # Store first record ID
|
|
transaction.sync_completed_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
logger.info("Transaction fully synced to sales",
|
|
transaction_id=transaction_id,
|
|
items_synced=len(sales_record_ids))
|
|
|
|
return {
|
|
"success": True,
|
|
"items_synced": len(sales_record_ids),
|
|
"sales_record_ids": sales_record_ids,
|
|
"failed_items": []
|
|
}
|
|
|
|
elif sales_record_ids and len(failed_items) > 0:
|
|
# Partial success
|
|
transaction.sync_attempted_at = datetime.utcnow()
|
|
transaction.sync_error = f"Partial sync: {len(failed_items)} items failed"
|
|
transaction.sync_retry_count = (transaction.sync_retry_count or 0) + 1
|
|
await db.commit()
|
|
|
|
logger.warning("Transaction partially synced to sales",
|
|
transaction_id=transaction_id,
|
|
items_synced=len(sales_record_ids),
|
|
items_failed=len(failed_items))
|
|
|
|
return {
|
|
"success": False,
|
|
"partial_success": True,
|
|
"items_synced": len(sales_record_ids),
|
|
"sales_record_ids": sales_record_ids,
|
|
"failed_items": failed_items
|
|
}
|
|
|
|
else:
|
|
# Complete failure
|
|
transaction.sync_attempted_at = datetime.utcnow()
|
|
transaction.sync_error = "All items failed to sync"
|
|
transaction.sync_retry_count = (transaction.sync_retry_count or 0) + 1
|
|
await db.commit()
|
|
|
|
logger.error("Transaction sync failed completely",
|
|
transaction_id=transaction_id,
|
|
items_failed=len(failed_items))
|
|
|
|
return {
|
|
"success": False,
|
|
"items_synced": 0,
|
|
"failed_items": failed_items
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to sync transaction to sales",
|
|
error=str(e),
|
|
transaction_id=transaction_id,
|
|
tenant_id=tenant_id)
|
|
return {
|
|
"success": False,
|
|
"error": str(e)
|
|
}
|
|
|
|
async def sync_unsynced_transactions(
|
|
self,
|
|
tenant_id: UUID,
|
|
limit: int = 50
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Sync all unsynced transactions to the sales service
|
|
|
|
Args:
|
|
tenant_id: Tenant UUID
|
|
limit: Maximum number of transactions to sync in one batch
|
|
|
|
Returns:
|
|
Dict with sync summary
|
|
"""
|
|
try:
|
|
async with get_db_transaction() as db:
|
|
repository = POSTransactionRepository(db)
|
|
|
|
# Get unsynced transactions
|
|
unsynced_transactions = await repository.get_transactions_by_tenant(
|
|
tenant_id=tenant_id,
|
|
is_synced=False,
|
|
status="completed", # Only sync completed transactions
|
|
limit=limit
|
|
)
|
|
|
|
if not unsynced_transactions:
|
|
logger.info("No unsynced transactions found", tenant_id=tenant_id)
|
|
return {
|
|
"success": True,
|
|
"total_transactions": 0,
|
|
"synced": 0,
|
|
"failed": 0
|
|
}
|
|
|
|
synced_count = 0
|
|
failed_count = 0
|
|
results = []
|
|
|
|
for transaction in unsynced_transactions:
|
|
result = await self.sync_transaction_to_sales(
|
|
transaction.id,
|
|
tenant_id
|
|
)
|
|
|
|
if result.get("success"):
|
|
synced_count += 1
|
|
else:
|
|
failed_count += 1
|
|
|
|
results.append({
|
|
"transaction_id": str(transaction.id),
|
|
"external_id": transaction.external_transaction_id,
|
|
"result": result
|
|
})
|
|
|
|
logger.info("Batch sync completed",
|
|
tenant_id=tenant_id,
|
|
total=len(unsynced_transactions),
|
|
synced=synced_count,
|
|
failed=failed_count)
|
|
|
|
return {
|
|
"success": True,
|
|
"total_transactions": len(unsynced_transactions),
|
|
"synced": synced_count,
|
|
"failed": failed_count,
|
|
"results": results
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to batch sync transactions",
|
|
error=str(e),
|
|
tenant_id=tenant_id)
|
|
return {
|
|
"success": False,
|
|
"error": str(e)
|
|
}
|