using Meezi.API.Models.Reports; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace Meezi.API.Services; public interface IDailyReportService { Task GenerateReportAsync( string cafeId, string branchId, DateOnly date, CancellationToken cancellationToken = default); Task GetReportAsync( string cafeId, string branchId, DateOnly date, CancellationToken cancellationToken = default); Task> GetReportRangeAsync( string cafeId, string? branchId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default); Task GetSummaryAsync( string cafeId, int days, CancellationToken cancellationToken = default); } public class DailyReportService : IDailyReportService { private static readonly OrderStatus ClosedOrderStatus = OrderStatus.Delivered; private readonly AppDbContext _db; private readonly ILogger _logger; public DailyReportService(AppDbContext db, ILogger logger) { _db = db; _logger = logger; } public async Task GenerateReportAsync( string cafeId, string branchId, DateOnly date, CancellationToken cancellationToken = default) { await EnsureBranchAsync(cafeId, branchId, cancellationToken); var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date); var metrics = await ComputeMetricsAsync(cafeId, branchId, utcStart, utcEnd, cancellationToken); var existing = await _db.DailyReports.FirstOrDefaultAsync( r => r.CafeId == cafeId && r.BranchId == branchId && r.Date == date, cancellationToken); var now = DateTime.UtcNow; if (existing is null) { existing = new DailyReport { CafeId = cafeId, BranchId = branchId, Date = date, CreatedAt = now }; _db.DailyReports.Add(existing); } ApplyMetrics(existing, metrics, now); await _db.SaveChangesAsync(cancellationToken); _logger.LogInformation( "Daily report generated for cafe {CafeId} branch {BranchId} date {Date}", cafeId, branchId, date); return ToDto(existing); } public async Task GetReportAsync( string cafeId, string branchId, DateOnly date, CancellationToken cancellationToken = default) { var row = await _db.DailyReports.AsNoTracking() .FirstOrDefaultAsync( r => r.CafeId == cafeId && r.BranchId == branchId && r.Date == date, cancellationToken); return row is null ? null : ToDto(row); } public async Task> GetReportRangeAsync( string cafeId, string? branchId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) { var query = _db.DailyReports.AsNoTracking() .Where(r => r.CafeId == cafeId && r.Date >= startDate && r.Date <= endDate); if (!string.IsNullOrEmpty(branchId)) query = query.Where(r => r.BranchId == branchId); var rows = await query.OrderBy(r => r.Date).ThenBy(r => r.BranchId).ToListAsync(cancellationToken); return rows.Select(ToDto).ToList(); } public async Task GetSummaryAsync( string cafeId, int days, CancellationToken cancellationToken = default) { days = Math.Clamp(days, 1, 365); var today = IranCalendar.TodayInIran; var from = today.AddDays(-(days - 1)); var rows = await GetReportRangeAsync(cafeId, null, from, today, cancellationToken); if (rows.Count == 0) { return new DailyReportSummaryDto( days, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, []); } return new DailyReportSummaryDto( days, rows.Sum(r => r.TotalRevenue), rows.Sum(r => r.CashRevenue), rows.Sum(r => r.CardRevenue), rows.Sum(r => r.CreditRevenue), rows.Sum(r => r.TotalOrders), rows.Sum(r => r.TotalOrders) > 0 ? rows.Sum(r => r.TotalRevenue) / rows.Sum(r => r.TotalOrders) : 0, rows.Sum(r => r.TotalVoids), rows.Sum(r => r.VoidAmount), rows.Sum(r => r.TotalExpenses), rows.Sum(r => r.NetIncome), rows); } private async Task EnsureBranchAsync(string cafeId, string branchId, CancellationToken ct) { var exists = await _db.Branches.AnyAsync( b => b.Id == branchId && b.CafeId == cafeId && b.IsActive, ct); if (!exists) throw new InvalidOperationException("Branch not found."); } private async Task ComputeMetricsAsync( string cafeId, string branchId, DateTime utcStart, DateTime utcEnd, CancellationToken cancellationToken) { var closedOrders = await _db.Orders .Where(o => o.CafeId == cafeId && o.BranchId == branchId && o.Status == ClosedOrderStatus && o.CreatedAt >= utcStart && o.CreatedAt < utcEnd) .Select(o => o.Id) .ToListAsync(cancellationToken); var orderItems = await _db.OrderItems .Include(i => i.MenuItem) .Include(i => i.Order) .Where(i => i.Order.CafeId == cafeId && i.Order.BranchId == branchId && i.Order.Status == ClosedOrderStatus && i.Order.CreatedAt >= utcStart && i.Order.CreatedAt < utcEnd) .ToListAsync(cancellationToken); var activeLines = orderItems.Where(i => !i.IsVoided).ToList(); var voidedLines = orderItems.Where(i => i.IsVoided).ToList(); var totalRevenue = activeLines.Sum(i => i.UnitPrice * i.Quantity); var totalOrders = closedOrders.Count; var avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0; var cashTx = await _db.CashTransactions .Where(t => t.CafeId == cafeId && t.BranchId == branchId && t.CreatedAt >= utcStart && t.CreatedAt < utcEnd) .ToListAsync(cancellationToken); var payments = cashTx.Where(t => t.Type == CashTransactionType.OrderPayment).ToList(); var cashRevenue = payments.Where(t => t.Method == PaymentMethod.Cash).Sum(t => t.Amount); var cardRevenue = payments.Where(t => t.Method == PaymentMethod.Card).Sum(t => t.Amount); var creditRevenue = payments.Where(t => t.Method == PaymentMethod.Credit).Sum(t => t.Amount); if (payments.Count == 0 && closedOrders.Count > 0) { var orderPayments = await _db.Payments .Include(p => p.Order) .Where(p => p.Order.CafeId == cafeId && p.Order.BranchId == branchId && p.Order.Status == ClosedOrderStatus && p.Status == PaymentStatus.Completed && p.Order.CreatedAt >= utcStart && p.Order.CreatedAt < utcEnd) .ToListAsync(cancellationToken); cashRevenue = orderPayments.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount); cardRevenue = orderPayments.Where(p => p.Method == PaymentMethod.Card).Sum(p => p.Amount); creditRevenue = orderPayments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount); } var totalExpenses = await _db.Expenses .Where(e => e.CafeId == cafeId && e.BranchId == branchId && e.CreatedAt >= utcStart && e.CreatedAt < utcEnd) .SumAsync(e => e.Amount, cancellationToken); var voidAmount = voidedLines.Sum(i => i.UnitPrice * i.Quantity); var netIncome = totalRevenue - totalExpenses - voidAmount; var topProducts = activeLines .GroupBy(i => i.MenuItemId) .Select(g => new TopProductEntry { ProductId = g.Key, Name = g.First().MenuItem.Name, Quantity = g.Sum(x => x.Quantity), Revenue = g.Sum(x => x.UnitPrice * x.Quantity) }) .OrderByDescending(x => x.Revenue) .Take(10) .ToList(); return new DailyMetrics( totalRevenue, cashRevenue, cardRevenue, creditRevenue, totalOrders, avgOrderValue, voidedLines.Count, voidAmount, totalExpenses, netIncome, topProducts); } private static void ApplyMetrics(DailyReport entity, DailyMetrics metrics, DateTime generatedAt) { entity.TotalRevenue = metrics.TotalRevenue; entity.CashRevenue = metrics.CashRevenue; entity.CardRevenue = metrics.CardRevenue; entity.CreditRevenue = metrics.CreditRevenue; entity.TotalOrders = metrics.TotalOrders; entity.AvgOrderValue = metrics.AvgOrderValue; entity.TotalVoids = metrics.TotalVoids; entity.VoidAmount = metrics.VoidAmount; entity.TotalExpenses = metrics.TotalExpenses; entity.NetIncome = metrics.NetIncome; entity.TopProducts = metrics.TopProducts; entity.GeneratedAt = generatedAt; } private static DailyReportSnapshotDto ToDto(DailyReport r) => new( r.Id, r.CafeId, r.BranchId, r.Date.ToString("yyyy-MM-dd"), r.TotalRevenue, r.CashRevenue, r.CardRevenue, r.CreditRevenue, r.TotalOrders, r.AvgOrderValue, r.TotalVoids, r.VoidAmount, r.TotalExpenses, r.NetIncome, r.TopProducts.Select(p => new TopProductSnapshotDto( p.ProductId, p.Name, p.Quantity, p.Revenue)).ToList(), r.GeneratedAt); private sealed record DailyMetrics( decimal TotalRevenue, decimal CashRevenue, decimal CardRevenue, decimal CreditRevenue, int TotalOrders, decimal AvgOrderValue, int TotalVoids, decimal VoidAmount, decimal TotalExpenses, decimal NetIncome, List TopProducts); }