ef15fd6247
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>
307 lines
11 KiB
C#
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);
|
|
}
|