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,193 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Reports;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/reports")]
|
||||
public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReportService _reports;
|
||||
private readonly IDailyReportService _dailyReports;
|
||||
|
||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
||||
{
|
||||
_reports = reports;
|
||||
_dailyReports = dailyReports;
|
||||
}
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<IActionResult> GetDailySnapshot(
|
||||
string cafeId,
|
||||
[FromQuery] string branchId,
|
||||
[FromQuery] string? date,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||
|
||||
if (!TryParseReportDate(date, out var reportDate))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
||||
|
||||
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
|
||||
|
||||
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||
if (snapshot is null)
|
||||
snapshot = await _dailyReports.GenerateReportAsync(cafeId, branchId, reportDate, ct);
|
||||
|
||||
return Ok(new ApiResponse<DailyReportSnapshotDto>(true, snapshot));
|
||||
}
|
||||
|
||||
[HttpGet("daily/range")]
|
||||
public async Task<IActionResult> GetDailyRange(
|
||||
string cafeId,
|
||||
[FromQuery] string? branchId,
|
||||
[FromQuery] string from,
|
||||
[FromQuery] string to,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
|
||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
}
|
||||
|
||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
||||
if (clamped is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||
|
||||
var data = await _dailyReports.GetReportRangeAsync(
|
||||
cafeId, branchId, clamped.Value.From, clamped.Value.To, ct);
|
||||
|
||||
return Ok(new ApiResponse<IReadOnlyList<DailyReportSnapshotDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] int days = 30,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (days > maxDays && maxDays != int.MaxValue)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
|
||||
}
|
||||
|
||||
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||
var data = await _dailyReports.GetSummaryAsync(cafeId, days, ct);
|
||||
return Ok(new ApiResponse<DailyReportSummaryDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("daily/live")]
|
||||
public async Task<IActionResult> GetDailyLive(
|
||||
string cafeId,
|
||||
[FromQuery] string? date,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
||||
|
||||
var data = await _reports.GetDailyReportAsync(cafeId, date ?? string.Empty, ct);
|
||||
return Ok(new ApiResponse<DailyReportDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("monthly")]
|
||||
public async Task<IActionResult> GetMonthly(
|
||||
string cafeId,
|
||||
[FromQuery] string? month,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||
|
||||
var data = await _reports.GetMonthlyReportAsync(cafeId, month ?? string.Empty, ct);
|
||||
return Ok(new ApiResponse<MonthlyReportDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
public async Task<IActionResult> GetTrend(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] int days = 7,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> Export(
|
||||
string cafeId,
|
||||
[FromQuery] string month,
|
||||
ITenantContext tenant,
|
||||
[FromQuery] string format = "excel",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
||||
if (!JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||
|
||||
var bytes = await _reports.ExportExcelAsync(cafeId, month, ct);
|
||||
var fileName = $"meezi-report-{month}.xlsx";
|
||||
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
|
||||
}
|
||||
|
||||
private static bool TryParseReportDate(string? value, out DateOnly date)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
date = IranCalendar.TodayInIran;
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateOnly.TryParse(value, out date);
|
||||
}
|
||||
|
||||
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
|
||||
{
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var today = IranCalendar.TodayInIran;
|
||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
||||
return null;
|
||||
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user