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>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user