Files
meezi/src/Meezi.API/Services/DailyReportService.cs
T
soroush.asadi ef15fd6247 feat(api): .NET 10 multi-tenant REST API
Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:33:48 +03:30

307 lines
11 KiB
C#

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<DailyReportSnapshotDto> GenerateReportAsync(
string cafeId,
string branchId,
DateOnly date,
CancellationToken cancellationToken = default);
Task<DailyReportSnapshotDto?> GetReportAsync(
string cafeId,
string branchId,
DateOnly date,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<DailyReportSnapshotDto>> GetReportRangeAsync(
string cafeId,
string? branchId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
Task<DailyReportSummaryDto> 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<DailyReportService> _logger;
public DailyReportService(AppDbContext db, ILogger<DailyReportService> logger)
{
_db = db;
_logger = logger;
}
public async Task<DailyReportSnapshotDto> 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<DailyReportSnapshotDto?> 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<IReadOnlyList<DailyReportSnapshotDto>> 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<DailyReportSummaryDto> 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<DailyMetrics> 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<TopProductEntry> TopProducts);
}