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 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(false, null, new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId"))); if (!TryParseReportDate(date, out var reportDate)) return BadRequest(new ApiResponse(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(true, snapshot)); } [HttpGet("daily/range")] public async Task 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(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(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(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>(true, data)); } [HttpGet("summary")] public async Task 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(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(true, data)); } [HttpGet("daily/live")] public async Task 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(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(true, data)); } [HttpGet("monthly")] public async Task 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(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(true, data)); } [HttpGet("trend")] public async Task 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>(true, data)); } [HttpGet("export")] public async Task 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(false, null, new ApiError("VALIDATION_ERROR", "Only excel format is supported."))); if (!JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _)) return BadRequest(new ApiResponse(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(false, null, new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date"))); } }