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 @@
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using Meezi.API.Models.Auth;
|
||||
using Meezi.API.Security;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private const int OtpTtlSeconds = 300;
|
||||
private const int DefaultMaxOtpAttemptsPerHour = 5;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public AuthService(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
ISmsService smsService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
IConfiguration configuration,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http,
|
||||
ILogger<AuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_smsService = smsService;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_configuration = configuration;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var redis = _redis.GetDatabase();
|
||||
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
|
||||
|
||||
if (_http.HttpContext is not null)
|
||||
{
|
||||
var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
|
||||
var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
|
||||
if (!ipCheck.Allowed)
|
||||
return (false, null, ipCheck.ErrorCode, ipCheck.Message);
|
||||
}
|
||||
|
||||
var employeeExists = await _db.Employees
|
||||
.AnyAsync(e => e.Phone == phone && e.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (!employeeExists)
|
||||
return (false, null, "NOT_FOUND", "No account found for this phone number.");
|
||||
|
||||
var attemptsKey = $"otp:attempts:{phone}";
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var attempts = await redis.StringGetAsync(attemptsKey);
|
||||
if (attempts.HasValue && (int)attempts >= maxAttempts)
|
||||
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
|
||||
}
|
||||
|
||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
|
||||
_logger.LogWarning("DEV OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp);
|
||||
|
||||
try
|
||||
{
|
||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send OTP SMS");
|
||||
return (false, null, "SMS_FAILED", "Could not send verification code.");
|
||||
}
|
||||
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
|
||||
if (newAttempts == 1)
|
||||
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
_logger.LogInformation("OTP sent for phone ending {Suffix}", phone[^4..]);
|
||||
|
||||
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var code = OtpNormalizer.Normalize(request.Code);
|
||||
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var redis = _redis.GetDatabase();
|
||||
|
||||
var storedOtp = await redis.StringGetAsync($"otp:{phone}");
|
||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var query = _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.Where(e => e.Phone == phone && e.DeletedAt == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CafeId))
|
||||
query = query.Where(e => e.CafeId == request.CafeId);
|
||||
|
||||
var matches = await query.ToListAsync(cancellationToken);
|
||||
if (matches.Count == 0)
|
||||
return (false, null, "NOT_FOUND", "No account found for this phone number.");
|
||||
if (matches.Count > 1)
|
||||
return (false, null, "MULTIPLE_ACCOUNTS", "Multiple accounts use this phone. Contact your cafe owner.");
|
||||
|
||||
var employee = matches[0];
|
||||
if (employee.Cafe is null)
|
||||
return (false, null, "NOT_FOUND", "No account found for this phone number.");
|
||||
|
||||
await redis.KeyDeleteAsync($"otp:{phone}");
|
||||
|
||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (payload is null || payload.Actor == Meezi.Core.Constants.MeeziActorKinds.SystemAdmin)
|
||||
return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired.");
|
||||
|
||||
var employee = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.FirstOrDefaultAsync(e => e.Id == payload.UserId && e.CafeId == payload.CafeId && e.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (employee?.Cafe is null)
|
||||
return (false, null, "NOT_FOUND", "User no longer exists.");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
|
||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
|
||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
refreshToken,
|
||||
new RefreshTokenPayload(
|
||||
employee.Id,
|
||||
cafe.Id,
|
||||
employee.Role.ToString(),
|
||||
cafe.PlanTier.ToString(),
|
||||
cafe.PreferredLanguage,
|
||||
Meezi.Core.Constants.MeeziActorKinds.Merchant),
|
||||
TimeSpan.FromDays(refreshDays),
|
||||
cancellationToken);
|
||||
|
||||
return new AuthTokenResponse(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
_jwtTokenService.GetAccessTokenExpiry(),
|
||||
employee.Id,
|
||||
cafe.Id,
|
||||
employee.Role.ToString(),
|
||||
cafe.PlanTier.ToString(),
|
||||
cafe.PreferredLanguage,
|
||||
Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
||||
employee.BranchId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Meezi.API.Models.Billing;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record PaymentInitResult(bool Success, string? Authority, string? PaymentUrl, string? ErrorMessage);
|
||||
|
||||
public record PaymentVerifyResult(bool Success, string? RefId, string? ErrorMessage);
|
||||
|
||||
public interface IBillingPaymentOrchestrator
|
||||
{
|
||||
Task<IReadOnlyList<PaymentMethodDto>> GetEnabledMethodsAsync(CancellationToken cancellationToken = default);
|
||||
Task<PaymentInitResult> InitiateAsync(
|
||||
PaymentProvider provider,
|
||||
long amountRials,
|
||||
string paymentId,
|
||||
string description,
|
||||
string callbackUrl,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<PaymentVerifyResult> VerifyAsync(
|
||||
PaymentProvider provider,
|
||||
string externalId,
|
||||
long amountRials,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingPaymentOrchestrator : IBillingPaymentOrchestrator
|
||||
{
|
||||
private readonly IZarinPalGateway _zarinPal;
|
||||
private readonly ITaraPaymentGateway _tara;
|
||||
private readonly ISnappPayGateway _snappPay;
|
||||
private readonly IPlatformRuntimeConfig _platform;
|
||||
|
||||
private static readonly (string Id, string NameFa)[] AllMethods =
|
||||
[
|
||||
(PaymentProviderIds.ZarinPal, "زرینپال"),
|
||||
(PaymentProviderIds.Tara, "تارا (اعتباری)"),
|
||||
(PaymentProviderIds.SnappPay, "اسنپپی (اقساطی)")
|
||||
];
|
||||
|
||||
public BillingPaymentOrchestrator(
|
||||
IZarinPalGateway zarinPal,
|
||||
ITaraPaymentGateway tara,
|
||||
ISnappPayGateway snappPay,
|
||||
IPlatformRuntimeConfig platform)
|
||||
{
|
||||
_zarinPal = zarinPal;
|
||||
_tara = tara;
|
||||
_snappPay = snappPay;
|
||||
_platform = platform;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PaymentMethodDto>> GetEnabledMethodsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var defaultId = await _platform.GetAsync("payment.activeGateway", cancellationToken) ?? PaymentProviderIds.ZarinPal;
|
||||
var list = new List<PaymentMethodDto>();
|
||||
|
||||
if (await _zarinPal.IsEnabledAsync(cancellationToken))
|
||||
list.Add(new PaymentMethodDto(PaymentProviderIds.ZarinPal, "زرینپال", defaultId == PaymentProviderIds.ZarinPal));
|
||||
if (await _tara.IsEnabledAsync(cancellationToken))
|
||||
list.Add(new PaymentMethodDto(PaymentProviderIds.Tara, "تارا (اعتباری)", defaultId == PaymentProviderIds.Tara));
|
||||
if (await _snappPay.IsEnabledAsync(cancellationToken))
|
||||
list.Add(new PaymentMethodDto(PaymentProviderIds.SnappPay, "اسنپپی (اقساطی)", defaultId == PaymentProviderIds.SnappPay));
|
||||
|
||||
if (list.Count == 0)
|
||||
list.Add(new PaymentMethodDto(PaymentProviderIds.ZarinPal, "زرینپال", true));
|
||||
|
||||
if (list.All(m => !m.IsDefault))
|
||||
list[0] = list[0] with { IsDefault = true };
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<PaymentInitResult> InitiateAsync(
|
||||
PaymentProvider provider,
|
||||
long amountRials,
|
||||
string paymentId,
|
||||
string description,
|
||||
string callbackUrl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
PaymentProvider.Tara => await InitiateTaraAsync(amountRials, paymentId, callbackUrl, cancellationToken),
|
||||
PaymentProvider.SnappPay => await InitiateSnappPayAsync(amountRials, paymentId, callbackUrl, cancellationToken),
|
||||
_ => await InitiateZarinPalAsync(amountRials, description, callbackUrl, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PaymentVerifyResult> VerifyAsync(
|
||||
PaymentProvider provider,
|
||||
string externalId,
|
||||
long amountRials,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
PaymentProvider.Tara =>
|
||||
Map(await _tara.VerifyPaymentAsync(externalId, cancellationToken)),
|
||||
PaymentProvider.SnappPay =>
|
||||
Map(await _snappPay.VerifyAndSettleAsync(externalId, cancellationToken)),
|
||||
_ =>
|
||||
Map(await _zarinPal.VerifyPaymentAsync(externalId, amountRials, cancellationToken))
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<PaymentInitResult> InitiateZarinPalAsync(
|
||||
long amountRials,
|
||||
string description,
|
||||
string callbackUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _zarinPal.IsEnabledAsync(cancellationToken))
|
||||
return new PaymentInitResult(false, null, null, "ZarinPal is disabled.");
|
||||
|
||||
var r = await _zarinPal.RequestPaymentAsync(amountRials, description, callbackUrl, cancellationToken);
|
||||
return new PaymentInitResult(r.Success, r.Authority, r.PaymentUrl, r.ErrorMessage);
|
||||
}
|
||||
|
||||
private async Task<PaymentInitResult> InitiateTaraAsync(
|
||||
long amountRials,
|
||||
string paymentId,
|
||||
string callbackUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _tara.IsEnabledAsync(cancellationToken))
|
||||
return new PaymentInitResult(false, null, null, "Tara is disabled.");
|
||||
|
||||
var r = await _tara.RequestPaymentAsync(amountRials, paymentId, callbackUrl, cancellationToken);
|
||||
return new PaymentInitResult(r.Success, r.TraceNumber, r.PaymentUrl, r.ErrorMessage);
|
||||
}
|
||||
|
||||
private async Task<PaymentInitResult> InitiateSnappPayAsync(
|
||||
long amountRials,
|
||||
string paymentId,
|
||||
string callbackUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _snappPay.IsEnabledAsync(cancellationToken))
|
||||
return new PaymentInitResult(false, null, null, "Snapp Pay is disabled.");
|
||||
|
||||
var r = await _snappPay.RequestPaymentAsync(amountRials, paymentId, callbackUrl, cancellationToken);
|
||||
return new PaymentInitResult(r.Success, r.PaymentToken, r.PaymentUrl, r.ErrorMessage);
|
||||
}
|
||||
|
||||
private static PaymentVerifyResult Map(ZarinPalVerifyResult r) =>
|
||||
new(r.Success, r.RefId, r.ErrorMessage);
|
||||
|
||||
private static PaymentVerifyResult Map(TaraVerifyResult r) =>
|
||||
new(r.Success, r.RefId, r.ErrorMessage);
|
||||
|
||||
private static PaymentVerifyResult Map(SnappPayVerifyResult r) =>
|
||||
new(r.Success, r.RefId, r.ErrorMessage);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Meezi.API.Models.Billing;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IBillingService
|
||||
{
|
||||
Task<IReadOnlyList<PaymentMethodDto>> GetPaymentMethodsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(SubscribeResponse? Data, string? ErrorCode, string? Message)> InitiateSubscriptionAsync(
|
||||
string cafeId,
|
||||
SubscribeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifyZarinPalAsync(
|
||||
string authority,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifySnappPayAsync(
|
||||
string? paymentToken,
|
||||
string? state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifyTaraAsync(
|
||||
string? traceNumber,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IBillingPaymentOrchestrator _payments;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPlatformCatalogService _platformCatalog;
|
||||
private readonly ILogger<BillingService> _logger;
|
||||
|
||||
public BillingService(
|
||||
AppDbContext db,
|
||||
IBillingPaymentOrchestrator payments,
|
||||
ISmsService smsService,
|
||||
IConnectionMultiplexer redis,
|
||||
IConfiguration configuration,
|
||||
IPlatformCatalogService platformCatalog,
|
||||
ILogger<BillingService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_payments = payments;
|
||||
_smsService = smsService;
|
||||
_redis = redis;
|
||||
_configuration = configuration;
|
||||
_platformCatalog = platformCatalog;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private const string FeatureMenu3d = "menu_3d";
|
||||
private const string FeatureMenu3dAi = "menu_3d_ai";
|
||||
private const string FeatureDiscoverProfile = "discover_profile";
|
||||
|
||||
public async Task<(SubscribeResponse? Data, string? ErrorCode, string? Message)> InitiateSubscriptionAsync(
|
||||
string cafeId,
|
||||
SubscribeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
if (!await _platformCatalog.IsBillableOnlineAsync(request.PlanTier, cancellationToken))
|
||||
return (null, "NOT_BILLABLE", "This plan requires contacting sales.");
|
||||
|
||||
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(request.PlanTier, cancellationToken);
|
||||
if (monthly <= 0)
|
||||
return (null, "NOT_BILLABLE", "This plan has no online price.");
|
||||
|
||||
var amountToman = monthly * request.Months;
|
||||
var amountRials = PlanPricing.ToRials(amountToman);
|
||||
|
||||
var methods = await _payments.GetEnabledMethodsAsync(cancellationToken);
|
||||
var methodId = string.IsNullOrWhiteSpace(request.PaymentMethod)
|
||||
? methods.FirstOrDefault(m => m.IsDefault)?.Id ?? PaymentProviderIds.ZarinPal
|
||||
: request.PaymentMethod.Trim().ToLowerInvariant();
|
||||
|
||||
var provider = PaymentProviderIds.Parse(methodId);
|
||||
if (provider is null || methods.All(m => m.Id != methodId))
|
||||
return (null, "PAYMENT_METHOD_DISABLED", "Selected payment method is not available.");
|
||||
|
||||
var payment = new SubscriptionPayment
|
||||
{
|
||||
CafeId = cafeId,
|
||||
PlanTier = request.PlanTier,
|
||||
Months = request.Months,
|
||||
AmountToman = amountToman,
|
||||
AmountRials = amountRials,
|
||||
Provider = provider.Value,
|
||||
Status = SubscriptionPaymentStatus.Pending
|
||||
};
|
||||
|
||||
_db.SubscriptionPayments.Add(payment);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var apiBase = _configuration["App:PublicBaseUrl"]?.TrimEnd('/') ?? "http://localhost:5080";
|
||||
var callbackUrl = provider.Value switch
|
||||
{
|
||||
PaymentProvider.SnappPay => $"{apiBase}/api/billing/verify/snapppay",
|
||||
PaymentProvider.Tara => $"{apiBase}/api/billing/verify/tara",
|
||||
_ => $"{apiBase}/api/billing/verify"
|
||||
};
|
||||
|
||||
var description = $"میزی — اشتراک {request.PlanTier} ({request.Months} ماه)";
|
||||
var init = await _payments.InitiateAsync(
|
||||
provider.Value,
|
||||
amountRials,
|
||||
payment.Id,
|
||||
description,
|
||||
callbackUrl,
|
||||
cancellationToken);
|
||||
|
||||
if (!init.Success || string.IsNullOrEmpty(init.Authority) || string.IsNullOrEmpty(init.PaymentUrl))
|
||||
{
|
||||
payment.Status = SubscriptionPaymentStatus.Failed;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (null, "PAYMENT_FAILED", init.ErrorMessage ?? "Could not start payment.");
|
||||
}
|
||||
|
||||
payment.Authority = init.Authority;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (new SubscribeResponse(payment.Id, init.PaymentUrl), null, null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PaymentMethodDto>> GetPaymentMethodsAsync(CancellationToken cancellationToken = default) =>
|
||||
_payments.GetEnabledMethodsAsync(cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifyZarinPalAsync(
|
||||
string authority,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.ZarinPal,
|
||||
authority,
|
||||
status is null or "" or "OK",
|
||||
cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifySnappPayAsync(
|
||||
string? paymentToken,
|
||||
string? state,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.SnappPay,
|
||||
paymentToken,
|
||||
!string.IsNullOrWhiteSpace(paymentToken)
|
||||
&& (string.IsNullOrWhiteSpace(state) || state.Equals("OK", StringComparison.OrdinalIgnoreCase)),
|
||||
cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifyTaraAsync(
|
||||
string? traceNumber,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.Tara,
|
||||
traceNumber,
|
||||
status is null or "" or "OK",
|
||||
cancellationToken);
|
||||
|
||||
private async Task<BillingVerifyResult> CompletePaymentAsync(
|
||||
PaymentProvider provider,
|
||||
string? externalId,
|
||||
bool callbackOk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dashboardBase = _configuration["Billing:DashboardBaseUrl"]?.TrimEnd('/')
|
||||
?? _configuration.GetSection("Cors:Origins").Get<string[]>()?.FirstOrDefault()
|
||||
?? "http://localhost:3101";
|
||||
var failUrl = $"{dashboardBase}/fa/subscription?billing=failed";
|
||||
var successUrl = $"{dashboardBase}/fa/subscription?billing=success";
|
||||
|
||||
if (!callbackOk || string.IsNullOrWhiteSpace(externalId))
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.Include(p => p.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Authority == externalId && p.Provider == provider,
|
||||
cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
|
||||
if (payment.Status == SubscriptionPaymentStatus.Completed)
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
|
||||
var verify = await _payments.VerifyAsync(provider, externalId, payment.AmountRials, cancellationToken);
|
||||
if (!verify.Success)
|
||||
{
|
||||
payment.Status = SubscriptionPaymentStatus.Failed;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
}
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
var cafe = payment.Cafe;
|
||||
cafe.PlanTier = payment.PlanTier;
|
||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: DateTime.UtcNow;
|
||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||
string cafeId,
|
||||
PlanTier currentTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var ordersToday = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||
cancellationToken);
|
||||
|
||||
var customersCount = await _db.Customers.CountAsync(c => c.CafeId == cafeId, cancellationToken);
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var smsUsed = await redis.StringGetAsync(monthKey);
|
||||
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
|
||||
|
||||
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
|
||||
var menuAi3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3dAi, cancellationToken);
|
||||
var discoverProfile = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureDiscoverProfile, cancellationToken);
|
||||
var isExpired = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value < DateTime.UtcNow;
|
||||
|
||||
var ai3dKey = $"ai3d:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var ai3dUsed = await redis.StringGetAsync(ai3dKey);
|
||||
var ai3dUsedCount = ai3dUsed.HasValue && int.TryParse(ai3dUsed.ToString(), out var aiN) ? aiN : 0;
|
||||
var ai3dLimit = menuAi3d ? PlanLimits.MaxMenuAi3dPerMonth(cafe.PlanTier) : 0;
|
||||
|
||||
return new BillingStatusDto(
|
||||
cafe.PlanTier,
|
||||
cafe.PlanExpiresAt,
|
||||
ordersToday,
|
||||
maxOrders == int.MaxValue ? null : maxOrders,
|
||||
customersCount,
|
||||
maxCustomers == int.MaxValue ? null : maxCustomers,
|
||||
smsUsedCount,
|
||||
maxSms == int.MaxValue ? -1 : maxSms,
|
||||
menu3d,
|
||||
menuAi3d,
|
||||
ai3dUsedCount,
|
||||
ai3dLimit,
|
||||
discoverProfile,
|
||||
isExpired);
|
||||
}
|
||||
|
||||
private async Task TrySendConfirmationSmsAsync(
|
||||
Cafe cafe,
|
||||
SubscriptionPayment payment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerPhone = await _db.Employees
|
||||
.Where(e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner)
|
||||
.Select(e => e.Phone)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||
|
||||
var message =
|
||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send subscription confirmation SMS for cafe {CafeId}", cafe.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IBranchIdentityService
|
||||
{
|
||||
Task<BranchEffectiveIdentityDto?> GetEffectiveIdentityAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public record BranchEffectiveIdentityDto(
|
||||
string PrimaryColor,
|
||||
string SecondaryColor,
|
||||
string? FontFamily,
|
||||
string? LogoUrl,
|
||||
string? IconName,
|
||||
string WelcomeText,
|
||||
string? WifiPassword,
|
||||
string? Address,
|
||||
string? AccentColor);
|
||||
|
||||
public class BranchIdentityService : IBranchIdentityService
|
||||
{
|
||||
private const string DefaultPrimary = "#0F6E56";
|
||||
private const string DefaultSecondary = "#E1F5EE";
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public BranchIdentityService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<BranchEffectiveIdentityDto?> GetEffectiveIdentityAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branch = await _db.Branches
|
||||
.Include(b => b.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
b => b.Id == branchId && b.CafeId == cafeId && b.IsActive,
|
||||
cancellationToken);
|
||||
|
||||
if (branch?.Cafe is null) return null;
|
||||
|
||||
var theme = CafeThemeSerializer.Parse(branch.Cafe.ThemeJson);
|
||||
var primary = theme.Custom?.Primary?.Trim();
|
||||
if (string.IsNullOrEmpty(primary))
|
||||
primary = DefaultPrimary;
|
||||
|
||||
var secondary = theme.Custom?.Secondary?.Trim();
|
||||
if (string.IsNullOrEmpty(secondary))
|
||||
secondary = DefaultSecondary;
|
||||
|
||||
var logo = !string.IsNullOrWhiteSpace(branch.LogoUrl)
|
||||
? branch.LogoUrl
|
||||
: branch.Cafe.LogoUrl;
|
||||
|
||||
var welcome = !string.IsNullOrWhiteSpace(branch.WelcomeText)
|
||||
? branch.WelcomeText.Trim()
|
||||
: "خوش آمدید";
|
||||
|
||||
return new BranchEffectiveIdentityDto(
|
||||
primary,
|
||||
secondary,
|
||||
null,
|
||||
logo,
|
||||
"coffee",
|
||||
welcome,
|
||||
branch.WifiPassword,
|
||||
branch.Address ?? branch.Cafe.Address,
|
||||
branch.AccentColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IBranchLifecycleService
|
||||
{
|
||||
Task<(bool Ok, string? ErrorCode, string? Message)> ScheduleDeletionAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Ok, string? ErrorCode, string? Message)> RestoreAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> PurgeExpiredDeletionsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BranchLifecycleService : IBranchLifecycleService
|
||||
{
|
||||
public const int RecoveryDays = 7;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<BranchLifecycleService> _logger;
|
||||
|
||||
public BranchLifecycleService(AppDbContext db, ILogger<BranchLifecycleService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string? ErrorCode, string? Message)> ScheduleDeletionAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branch = await _db.Branches
|
||||
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, cancellationToken);
|
||||
if (branch is null)
|
||||
return (false, "NOT_FOUND", "Branch not found.");
|
||||
if (branch.DeletedAt is not null)
|
||||
return (false, "ALREADY_DELETED", "Branch is already scheduled for removal.");
|
||||
|
||||
var activeCount = await _db.Branches.CountAsync(
|
||||
b => b.CafeId == cafeId && b.DeletedAt == null,
|
||||
cancellationToken);
|
||||
if (activeCount <= 1)
|
||||
return (false, "LAST_BRANCH", "At least one active branch is required.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var purgeAt = now.AddDays(RecoveryDays);
|
||||
branch.DeletedAt = now;
|
||||
branch.ScheduledPermanentDeleteAt = purgeAt;
|
||||
branch.IsActive = false;
|
||||
branch.UpdatedAt = now;
|
||||
|
||||
await SoftDeleteBranchScopedDataAsync(cafeId, branchId, now, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string? ErrorCode, string? Message)> RestoreAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branch = await _db.Branches
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, cancellationToken);
|
||||
if (branch is null || branch.DeletedAt is null)
|
||||
return (false, "NOT_FOUND", "Branch is not pending deletion.");
|
||||
if (branch.ScheduledPermanentDeleteAt is not null
|
||||
&& branch.ScheduledPermanentDeleteAt <= DateTime.UtcNow)
|
||||
return (false, "PURGE_EXPIRED", "Recovery period has ended.");
|
||||
|
||||
var deletedAt = branch.DeletedAt.Value;
|
||||
branch.DeletedAt = null;
|
||||
branch.ScheduledPermanentDeleteAt = null;
|
||||
branch.IsActive = true;
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await RestoreBranchScopedDataAsync(cafeId, branchId, deletedAt, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
public async Task<int> PurgeExpiredDeletionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expired = await _db.Branches
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.DeletedAt != null && b.ScheduledPermanentDeleteAt != null && b.ScheduledPermanentDeleteAt <= now)
|
||||
.Select(b => new { b.Id, b.CafeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var purged = 0;
|
||||
foreach (var b in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HardDeleteBranchAsync(b.CafeId, b.Id, cancellationToken);
|
||||
purged++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to permanently delete branch {BranchId}", b.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return purged;
|
||||
}
|
||||
|
||||
private async Task SoftDeleteBranchScopedDataAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateTime deletedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _db.Tables
|
||||
.Where(t => t.CafeId == cafeId && t.BranchId == branchId && t.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.DeletedAt, deletedAt)
|
||||
.SetProperty(t => t.IsActive, false),
|
||||
cancellationToken);
|
||||
|
||||
await _db.TableSections
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId && s.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DeletedAt, deletedAt), cancellationToken);
|
||||
|
||||
await _db.Employees
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId && e.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, deletedAt), cancellationToken);
|
||||
|
||||
await _db.BranchMenuItemOverrides
|
||||
.Where(o => o.CafeId == cafeId && o.BranchId == branchId && o.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(o => o.DeletedAt, deletedAt), cancellationToken);
|
||||
|
||||
await _db.RegisterShifts
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId && s.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.DeletedAt, deletedAt), cancellationToken);
|
||||
|
||||
await _db.Expenses
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId && e.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, deletedAt), cancellationToken);
|
||||
|
||||
await _db.DailyReports
|
||||
.Where(r => r.CafeId == cafeId && r.BranchId == branchId && r.DeletedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(r => r.DeletedAt, deletedAt), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RestoreBranchScopedDataAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateTime deletedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _db.Tables
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.CafeId == cafeId && t.BranchId == branchId && t.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.DeletedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.IsActive, true),
|
||||
cancellationToken);
|
||||
|
||||
await _db.TableSections
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId && s.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
|
||||
await _db.Employees
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId && e.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
|
||||
await _db.BranchMenuItemOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.Where(o => o.CafeId == cafeId && o.BranchId == branchId && o.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(o => o.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
|
||||
await _db.RegisterShifts
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId && s.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
|
||||
await _db.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId && e.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
|
||||
await _db.DailyReports
|
||||
.IgnoreQueryFilters()
|
||||
.Where(r => r.CafeId == cafeId && r.BranchId == branchId && r.DeletedAt == deletedAt)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(r => r.DeletedAt, (DateTime?)null), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HardDeleteBranchAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _db.BranchMenuItemOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.Where(o => o.CafeId == cafeId && o.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.Tables
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.CafeId == cafeId && t.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.TableSections
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.RegisterShifts
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CafeId == cafeId && s.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.DailyReports
|
||||
.IgnoreQueryFilters()
|
||||
.Where(r => r.CafeId == cafeId && r.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.Employees
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CafeId == cafeId && e.BranchId == branchId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _db.Branches
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.Id == branchId && b.CafeId == cafeId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Permanently deleted branch {BranchId} for cafe {CafeId}", branchId, cafeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IBranchMenuService
|
||||
{
|
||||
Task<IReadOnlyList<BranchMenuItemDto>?> GetBranchMenuAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
bool includeUnavailable,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, BranchMenuOverrideDto? Data, string? ErrorCode, string? Message)> UpsertOverrideAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
UpsertBranchMenuOverrideRequest request,
|
||||
PlanTier planTier,
|
||||
EmployeeRole? role,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteOverrideAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BranchMenuService : IBranchMenuService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public BranchMenuService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BranchMenuItemDto>?> GetBranchMenuAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
bool includeUnavailable,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == branchId && b.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (!branchOk) return null;
|
||||
|
||||
var items = await _db.MenuItems
|
||||
.Include(i => i.Category)
|
||||
.Where(i => i.CafeId == cafeId && i.Category.IsActive)
|
||||
.OrderBy(i => i.Category.SortOrder)
|
||||
.ThenBy(i => i.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var overrides = await _db.BranchMenuItemOverrides
|
||||
.Where(o => o.CafeId == cafeId && o.BranchId == branchId)
|
||||
.ToDictionaryAsync(o => o.MenuItemId, cancellationToken);
|
||||
|
||||
var result = new List<BranchMenuItemDto>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
overrides.TryGetValue(item.Id, out var ov);
|
||||
var branchAvailable = ov?.IsAvailable ?? true;
|
||||
var catalogAvailable = item.IsAvailable;
|
||||
|
||||
if (!includeUnavailable)
|
||||
{
|
||||
if (!catalogAvailable || !branchAvailable) continue;
|
||||
}
|
||||
else if (!catalogAvailable && ov is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var masterPrice = item.Price;
|
||||
var effectivePrice = ov?.PriceOverride ?? masterPrice;
|
||||
var hasPriceOverride = ov?.PriceOverride is not null;
|
||||
|
||||
result.Add(new BranchMenuItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Name,
|
||||
item.NameAr,
|
||||
item.NameEn,
|
||||
item.Description,
|
||||
masterPrice,
|
||||
effectivePrice,
|
||||
item.DiscountPercent,
|
||||
item.ImageUrl,
|
||||
item.VideoUrl,
|
||||
item.Model3dUrl,
|
||||
branchAvailable && catalogAvailable,
|
||||
ov is not null,
|
||||
hasPriceOverride));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, BranchMenuOverrideDto? Data, string? ErrorCode, string? Message)> UpsertOverrideAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
UpsertBranchMenuOverrideRequest request,
|
||||
PlanTier planTier,
|
||||
EmployeeRole? role,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request.PriceOverride is not null && !CanSetPriceOverride(planTier, role))
|
||||
return (false, null, "PLAN_LIMIT_REACHED",
|
||||
"قیمتگذاری شعبهای نیاز به پلن Pro دارد");
|
||||
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == branchId && b.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (!branchOk) return (false, null, "BRANCH_NOT_FOUND", "Branch not found.");
|
||||
|
||||
var item = await _db.MenuItems.FirstOrDefaultAsync(
|
||||
i => i.Id == menuItemId && i.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (item is null) return (false, null, "NOT_FOUND", "Menu item not found.");
|
||||
|
||||
if (request.PriceOverride is < 0)
|
||||
return (false, null, "VALIDATION_ERROR", "Price override must be non-negative.");
|
||||
|
||||
var existing = await _db.BranchMenuItemOverrides.FirstOrDefaultAsync(
|
||||
o => o.BranchId == branchId && o.MenuItemId == menuItemId,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new BranchMenuItemOverride
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
MenuItemId = menuItemId,
|
||||
};
|
||||
_db.BranchMenuItemOverrides.Add(existing);
|
||||
}
|
||||
|
||||
existing.IsAvailable = request.IsAvailable;
|
||||
existing.PriceOverride = request.PriceOverride;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
existing.UpdatedByUserId = userId;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (true, new BranchMenuOverrideDto(
|
||||
menuItemId,
|
||||
branchId,
|
||||
existing.IsAvailable,
|
||||
existing.PriceOverride,
|
||||
existing.UpdatedAt), null, null);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteOverrideAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
string menuItemId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var row = await _db.BranchMenuItemOverrides.FirstOrDefaultAsync(
|
||||
o => o.CafeId == cafeId && o.BranchId == branchId && o.MenuItemId == menuItemId,
|
||||
cancellationToken);
|
||||
if (row is null) return false;
|
||||
|
||||
_db.BranchMenuItemOverrides.Remove(row);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool CanSetPriceOverride(PlanTier planTier, EmployeeRole? role) =>
|
||||
role is EmployeeRole.Owner || planTier is not PlanTier.Free;
|
||||
|
||||
internal static bool CanManageOverrides(EmployeeRole? role) =>
|
||||
role is EmployeeRole.Owner or EmployeeRole.Manager;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Meezi.API.Models.Discover;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Infrastructure.Discover;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public static class CafeDiscoverProfileMapping
|
||||
{
|
||||
public static CafeDiscoverProfileDto ToDto(CafeDiscoverProfile profile) =>
|
||||
new(
|
||||
profile.Themes,
|
||||
profile.Size,
|
||||
profile.Floors,
|
||||
profile.Vibes,
|
||||
profile.Occasions,
|
||||
profile.SpaceFeatures,
|
||||
profile.NoiseLevel,
|
||||
profile.PriceTier);
|
||||
|
||||
public static CafeDiscoverProfile FromRequest(UpsertCafeDiscoverProfileRequest request) =>
|
||||
CafeDiscoverProfileSerializer.Sanitize(new CafeDiscoverProfile
|
||||
{
|
||||
Themes = request.Themes?.ToList() ?? [],
|
||||
Size = request.Size,
|
||||
Floors = request.Floors,
|
||||
Vibes = request.Vibes?.ToList() ?? [],
|
||||
Occasions = request.Occasions?.ToList() ?? [],
|
||||
SpaceFeatures = request.SpaceFeatures?.ToList() ?? [],
|
||||
NoiseLevel = request.NoiseLevel,
|
||||
PriceTier = request.PriceTier
|
||||
});
|
||||
|
||||
public static DiscoverProfileTaxonomyDto Taxonomy() =>
|
||||
new(
|
||||
CafeDiscoverProfileKeys.Themes.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.Sizes.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.Floors.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.Vibes.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.Occasions.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.SpaceFeatures.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.NoiseLevels.OrderBy(x => x).ToList(),
|
||||
CafeDiscoverProfileKeys.PriceTiers.OrderBy(x => x).ToList());
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.Core.Branding;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public static class CafeThemeMapping
|
||||
{
|
||||
public static CafeThemeDto ToDto(CafeTheme theme) => new(
|
||||
theme.PaletteId,
|
||||
theme.PanelStyle,
|
||||
theme.MenuStyle,
|
||||
theme.MenuTexture,
|
||||
theme.Density,
|
||||
theme.Radius,
|
||||
theme.Custom is null
|
||||
? null
|
||||
: new CafeThemeCustomColorsDto(
|
||||
theme.Custom.Primary,
|
||||
theme.Custom.Secondary,
|
||||
theme.Custom.Accent,
|
||||
theme.Custom.Background,
|
||||
theme.Custom.Surface,
|
||||
theme.Custom.Text,
|
||||
theme.Custom.TextMuted,
|
||||
theme.Custom.Destructive,
|
||||
theme.Custom.Success,
|
||||
theme.Custom.PrimaryOpacity,
|
||||
theme.Custom.SecondaryOpacity,
|
||||
theme.Custom.AccentOpacity,
|
||||
theme.Custom.BackgroundOpacity,
|
||||
theme.Custom.SurfaceOpacity,
|
||||
theme.Custom.TextOpacity,
|
||||
theme.Custom.TextMutedOpacity,
|
||||
theme.Custom.DestructiveOpacity,
|
||||
theme.Custom.SuccessOpacity));
|
||||
|
||||
public static CafeTheme FromDto(CafeThemeDto dto)
|
||||
{
|
||||
var theme = new CafeTheme
|
||||
{
|
||||
PaletteId = dto.PaletteId,
|
||||
PanelStyle = dto.PanelStyle,
|
||||
MenuStyle = dto.MenuStyle,
|
||||
MenuTexture = dto.MenuTexture,
|
||||
Density = dto.Density,
|
||||
Radius = dto.Radius,
|
||||
Custom = dto.Custom is null
|
||||
? null
|
||||
: new CafeThemeCustomColors
|
||||
{
|
||||
Primary = dto.Custom.Primary,
|
||||
Secondary = dto.Custom.Secondary,
|
||||
Accent = dto.Custom.Accent,
|
||||
Background = dto.Custom.Background,
|
||||
Surface = dto.Custom.Surface,
|
||||
Text = dto.Custom.Text,
|
||||
TextMuted = dto.Custom.TextMuted,
|
||||
Destructive = dto.Custom.Destructive,
|
||||
Success = dto.Custom.Success,
|
||||
PrimaryOpacity = dto.Custom.PrimaryOpacity,
|
||||
SecondaryOpacity = dto.Custom.SecondaryOpacity,
|
||||
AccentOpacity = dto.Custom.AccentOpacity,
|
||||
BackgroundOpacity = dto.Custom.BackgroundOpacity,
|
||||
SurfaceOpacity = dto.Custom.SurfaceOpacity,
|
||||
TextOpacity = dto.Custom.TextOpacity,
|
||||
TextMutedOpacity = dto.Custom.TextMutedOpacity,
|
||||
DestructiveOpacity = dto.Custom.DestructiveOpacity,
|
||||
SuccessOpacity = dto.Custom.SuccessOpacity
|
||||
}
|
||||
};
|
||||
return CafeThemeSerializer.Normalize(theme);
|
||||
}
|
||||
|
||||
public static CafeThemeDto FromJson(string? json) => ToDto(CafeThemeSerializer.Parse(json));
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ICoffeeAdvisorService
|
||||
{
|
||||
Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync(
|
||||
CoffeeAdvisorRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class CoffeeAdvisorService : ICoffeeAdvisorService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
private readonly ILogger<CoffeeAdvisorService> _logger;
|
||||
|
||||
public CoffeeAdvisorService(
|
||||
AppDbContext db,
|
||||
IOpenAiChatService openAi,
|
||||
ILogger<CoffeeAdvisorService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync(
|
||||
CoffeeAdvisorRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var purpose = request.Purpose?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(purpose) || purpose.Length < 3)
|
||||
return (null, "INVALID_REQUEST", "Describe what you need (at least 3 characters).");
|
||||
|
||||
if (!await _openAi.IsConfiguredForCoffeeAdvisorAsync(cancellationToken))
|
||||
return (null, "AI_NOT_CONFIGURED", "Coffee advisor is not available right now.");
|
||||
|
||||
var menuLines = await LoadMenuContextAsync(request.CafeSlug, cancellationToken);
|
||||
var systemPrompt =
|
||||
"""
|
||||
You are a specialty coffee advisor for Iranian cafés. Respond ONLY with valid JSON (no markdown).
|
||||
Schema: { "summary": string (1-2 sentences in Persian), "picks": [ { "name": string, "reason": string (Persian), "menuItemId": string|null } ] }
|
||||
Rules: suggest 1-3 drinks; prefer items from the menu list when provided; match the guest's purpose (energy, relax, meeting, dessert pairing, etc.); be concise and friendly in Persian.
|
||||
""";
|
||||
var userPrompt = $"""
|
||||
Guest purpose: {purpose}
|
||||
{(menuLines.Count > 0 ? "Café menu (id | name | description):\n" + string.Join("\n", menuLines) : "No specific café menu — suggest classic café drinks.")}
|
||||
""";
|
||||
|
||||
string? json;
|
||||
try
|
||||
{
|
||||
json = await _openAi.CompleteJsonAsync(systemPrompt, userPrompt, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Coffee advisor OpenAI call failed");
|
||||
return (null, "AI_FAILED", "Could not get a recommendation. Try again later.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return (null, "AI_FAILED", "Could not get a recommendation. Try again later.");
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<AdvisorJson>(json, JsonOpts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Summary))
|
||||
return (null, "AI_FAILED", "Invalid advisor response.");
|
||||
|
||||
var picks = (parsed.Picks ?? [])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p.Name))
|
||||
.Take(3)
|
||||
.Select(p => new CoffeeAdvisorPickDto(
|
||||
p.Name!.Trim(),
|
||||
p.Reason?.Trim() ?? "",
|
||||
string.IsNullOrWhiteSpace(p.MenuItemId) ? null : p.MenuItemId.Trim()))
|
||||
.ToList();
|
||||
|
||||
return (new CoffeeAdvisorResultDto(parsed.Summary.Trim(), picks), null, null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Coffee advisor JSON parse failed");
|
||||
return (null, "AI_FAILED", "Could not parse advisor response.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> LoadMenuContextAsync(string? slug, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
return [];
|
||||
|
||||
var cafeId = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Slug == slug.Trim() && c.DeletedAt == null)
|
||||
.Select(c => c.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (cafeId is null)
|
||||
return [];
|
||||
|
||||
var items = await _db.MenuItems.AsNoTracking()
|
||||
.Where(i => i.CafeId == cafeId && i.IsAvailable && i.DeletedAt == null)
|
||||
.OrderBy(i => i.Name)
|
||||
.Take(40)
|
||||
.Select(i => new { i.Id, i.Name, i.Description })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return items
|
||||
.Select(i => $"{i.Id} | {i.Name} | {(string.IsNullOrWhiteSpace(i.Description) ? "-" : i.Description)}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed class AdvisorJson
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public List<AdvisorPickJson>? Picks { get; set; }
|
||||
}
|
||||
|
||||
private sealed class AdvisorPickJson
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? MenuItemId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Meezi.API.Models.Auth;
|
||||
using Meezi.API.Models.Consumer;
|
||||
using Meezi.API.Security;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IConsumerAuthService
|
||||
{
|
||||
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ConsumerAuthService : IConsumerAuthService
|
||||
{
|
||||
private const int OtpTtlSeconds = 300;
|
||||
private const int DefaultMaxOtpAttemptsPerHour = 5;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IJwtTokenService _jwtTokenService;
|
||||
private readonly IRefreshTokenStore _refreshTokenStore;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ConsumerAuthService> _logger;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public ConsumerAuthService(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
ISmsService smsService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenStore refreshTokenStore,
|
||||
IConfiguration configuration,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http,
|
||||
ILogger<ConsumerAuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_smsService = smsService;
|
||||
_jwtTokenService = jwtTokenService;
|
||||
_refreshTokenStore = refreshTokenStore;
|
||||
_configuration = configuration;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var redis = _redis.GetDatabase();
|
||||
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
|
||||
|
||||
if (_http.HttpContext is not null)
|
||||
{
|
||||
var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
|
||||
var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
|
||||
if (!ipCheck.Allowed)
|
||||
return (false, null, ipCheck.ErrorCode, ipCheck.Message);
|
||||
}
|
||||
|
||||
var attemptsKey = $"consumer-otp:attempts:{phone}";
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var attempts = await redis.StringGetAsync(attemptsKey);
|
||||
if (attempts.HasValue && (int)attempts >= maxAttempts)
|
||||
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
|
||||
}
|
||||
|
||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||
await redis.StringSetAsync($"consumer-otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
|
||||
_logger.LogWarning("DEV consumer OTP for {Phone}: {Otp}", phone, otp);
|
||||
|
||||
try
|
||||
{
|
||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send consumer OTP SMS");
|
||||
return (false, null, "SMS_FAILED", "Could not send verification code.");
|
||||
}
|
||||
|
||||
if (maxAttempts > 0)
|
||||
{
|
||||
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
|
||||
if (newAttempts == 1)
|
||||
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var code = OtpNormalizer.Normalize(request.Code);
|
||||
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var redis = _redis.GetDatabase();
|
||||
var storedOtp = await redis.StringGetAsync($"consumer-otp:{phone}");
|
||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||
|
||||
var account = await _db.ConsumerAccounts
|
||||
.FirstOrDefaultAsync(a => a.Phone == phone, cancellationToken);
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
account = new ConsumerAccount { Phone = phone };
|
||||
_db.ConsumerAccounts.Add(account);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await redis.KeyDeleteAsync($"consumer-otp:{phone}");
|
||||
|
||||
var tokens = await IssueTokensAsync(account, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, ConsumerAuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
|
||||
if (payload is null || payload.Actor != MeeziActorKinds.Consumer)
|
||||
return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired.");
|
||||
|
||||
var account = await _db.ConsumerAccounts
|
||||
.FirstOrDefaultAsync(a => a.Id == payload.UserId, cancellationToken);
|
||||
if (account is null)
|
||||
return (false, null, "NOT_FOUND", "Account no longer exists.");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
var tokens = await IssueTokensAsync(account, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<ConsumerAuthTokenResponse> IssueTokensAsync(
|
||||
ConsumerAccount account,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lang = "fa";
|
||||
var accessToken = _jwtTokenService.CreateConsumerAccessToken(account, lang);
|
||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
refreshToken,
|
||||
new RefreshTokenPayload(
|
||||
account.Id,
|
||||
string.Empty,
|
||||
MeeziRoles.Customer,
|
||||
string.Empty,
|
||||
lang,
|
||||
MeeziActorKinds.Consumer),
|
||||
TimeSpan.FromDays(refreshDays),
|
||||
cancellationToken);
|
||||
|
||||
return new ConsumerAuthTokenResponse(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
_jwtTokenService.GetAccessTokenExpiry(),
|
||||
account.Id,
|
||||
account.Phone,
|
||||
lang);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Meezi.API.Models.Consumer;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IConsumerOrdersService
|
||||
{
|
||||
Task<IReadOnlyList<ConsumerOrderHistoryDto>> GetMyOrdersAsync(
|
||||
string phone,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ConsumerOrdersService : IConsumerOrdersService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public ConsumerOrdersService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<ConsumerOrderHistoryDto>> GetMyOrdersAsync(
|
||||
string phone,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 50);
|
||||
|
||||
var orders = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Cafe)
|
||||
.Include(o => o.Table)
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.Where(o =>
|
||||
o.DeletedAt == null
|
||||
&& (o.GuestPhone == phone
|
||||
|| (o.Customer != null && o.Customer.Phone == phone)))
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return orders.Select(o => new ConsumerOrderHistoryDto(
|
||||
o.Id,
|
||||
o.CafeId,
|
||||
o.Cafe.Name,
|
||||
o.Cafe.Slug,
|
||||
o.Status,
|
||||
o.Total,
|
||||
o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id),
|
||||
o.CreatedAt,
|
||||
o.Table?.Number,
|
||||
o.Items.Count(i => !i.IsVoided))).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ICouponService
|
||||
{
|
||||
Task<IReadOnlyList<CouponDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> CreateAsync(string cafeId, CreateCouponRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> UpdateAsync(string cafeId, string id, UpdateCouponRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<(ValidateCouponResult? Data, ApiError? Error)> ValidateAsync(
|
||||
string cafeId,
|
||||
ValidateCouponRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class CouponService : ICouponService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public CouponService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CouponDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _db.Coupons
|
||||
.Where(c => c.CafeId == cafeId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
return list.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
return entity is null ? null : ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var codeExists = await _db.Coupons.AnyAsync(
|
||||
c => c.CafeId == cafeId && c.Code == request.Code.ToUpperInvariant(), cancellationToken);
|
||||
if (codeExists) return null;
|
||||
|
||||
var entity = new Coupon
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Code = request.Code.ToUpperInvariant(),
|
||||
Type = request.Type,
|
||||
Value = request.Value,
|
||||
MinOrderAmount = request.MinOrderAmount,
|
||||
MaxDiscount = request.MaxDiscount,
|
||||
UsageLimit = request.UsageLimit,
|
||||
TargetGroup = request.TargetGroup,
|
||||
StartsAt = request.StartsAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_db.Coupons.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string id,
|
||||
UpdateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Code is not null) entity.Code = request.Code.ToUpperInvariant();
|
||||
if (request.Type.HasValue) entity.Type = request.Type.Value;
|
||||
if (request.Value.HasValue) entity.Value = request.Value.Value;
|
||||
if (request.MinOrderAmount.HasValue) entity.MinOrderAmount = request.MinOrderAmount;
|
||||
if (request.MaxDiscount.HasValue) entity.MaxDiscount = request.MaxDiscount;
|
||||
if (request.UsageLimit.HasValue) entity.UsageLimit = request.UsageLimit;
|
||||
if (request.TargetGroup.HasValue) entity.TargetGroup = request.TargetGroup;
|
||||
if (request.StartsAt.HasValue) entity.StartsAt = request.StartsAt;
|
||||
if (request.ExpiresAt.HasValue) entity.ExpiresAt = request.ExpiresAt;
|
||||
if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
entity.IsActive = false;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(ValidateCouponResult? Data, ApiError? Error)> ValidateAsync(
|
||||
string cafeId,
|
||||
ValidateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var code = request.Code.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(code))
|
||||
return (null, new ApiError("COUPON_REQUIRED", "Coupon code is required."));
|
||||
|
||||
if (request.Subtotal <= 0)
|
||||
return (null, new ApiError("CART_EMPTY", "Add items before applying a coupon."));
|
||||
|
||||
var coupon = await _db.Coupons.FirstOrDefaultAsync(
|
||||
c => c.CafeId == cafeId && c.Code == code,
|
||||
cancellationToken);
|
||||
|
||||
if (coupon is null)
|
||||
return (null, new ApiError("COUPON_NOT_FOUND", "Coupon code is invalid."));
|
||||
|
||||
if (!coupon.IsActive || coupon.DeletedAt is not null)
|
||||
return (null, new ApiError("COUPON_INACTIVE", "This coupon is not active."));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (coupon.StartsAt is not null && coupon.StartsAt > now)
|
||||
return (null, new ApiError("COUPON_NOT_STARTED", "This coupon is not valid yet."));
|
||||
|
||||
if (coupon.ExpiresAt is not null && coupon.ExpiresAt < now)
|
||||
return (null, new ApiError("COUPON_EXPIRED", "This coupon has expired."));
|
||||
|
||||
if (coupon.UsageLimit is not null && coupon.UsedCount >= coupon.UsageLimit)
|
||||
return (null, new ApiError("COUPON_LIMIT_REACHED", "This coupon has reached its usage limit."));
|
||||
|
||||
if (coupon.MinOrderAmount is not null && request.Subtotal < coupon.MinOrderAmount)
|
||||
return (null, new ApiError(
|
||||
"COUPON_MIN_ORDER",
|
||||
$"Minimum order amount is {coupon.MinOrderAmount:N0} T."));
|
||||
|
||||
var discount = CalculateDiscount(coupon, request.Subtotal);
|
||||
if (discount <= 0)
|
||||
return (null, new ApiError("COUPON_NO_DISCOUNT", "This coupon does not apply to this order."));
|
||||
|
||||
return (new ValidateCouponResult(
|
||||
coupon.Id,
|
||||
coupon.Code,
|
||||
coupon.Type,
|
||||
coupon.Value,
|
||||
discount), null);
|
||||
}
|
||||
|
||||
internal static decimal CalculateDiscount(Coupon coupon, decimal subtotal)
|
||||
{
|
||||
return coupon.Type switch
|
||||
{
|
||||
CouponType.Percentage => Math.Min(
|
||||
Math.Round(subtotal * coupon.Value / 100m, 0),
|
||||
coupon.MaxDiscount ?? subtotal),
|
||||
CouponType.FixedAmount => Math.Min(coupon.Value, subtotal),
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
|
||||
private static CouponDto ToDto(Coupon c) => new(
|
||||
c.Id,
|
||||
c.Code,
|
||||
c.Type,
|
||||
c.Value,
|
||||
c.MinOrderAmount,
|
||||
c.MaxDiscount,
|
||||
c.UsageLimit,
|
||||
c.UsedCount,
|
||||
c.TargetGroup,
|
||||
c.StartsAt,
|
||||
c.ExpiresAt,
|
||||
c.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ICustomerService
|
||||
{
|
||||
Task<IReadOnlyList<CustomerDto>> SearchAsync(string cafeId, string? query, CancellationToken cancellationToken = default);
|
||||
Task<CustomerDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<CustomerDto?> CreateAsync(string cafeId, CreateCustomerRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CustomerDto?> UpdateAsync(string cafeId, string id, UpdateCustomerRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class CustomerService : ICustomerService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public CustomerService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CustomerDto>> SearchAsync(
|
||||
string cafeId,
|
||||
string? query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var q = _db.Customers.Where(c => c.CafeId == cafeId && c.DeletedAt == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var term = query.Trim();
|
||||
var normalizedPhone = PhoneNormalizer.Normalize(term);
|
||||
q = q.Where(c =>
|
||||
c.Name.Contains(term) ||
|
||||
c.Phone.Contains(term) ||
|
||||
(c.NationalId != null && c.NationalId.Contains(term)) ||
|
||||
(normalizedPhone.Length >= 10 && c.Phone.Contains(normalizedPhone)));
|
||||
}
|
||||
|
||||
var list = await q
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(cancellationToken);
|
||||
return list.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<CustomerDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
|
||||
return entity is null ? null : ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CustomerDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateCustomerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var exists = await _db.Customers.AnyAsync(
|
||||
c => c.CafeId == cafeId && c.Phone == phone, cancellationToken);
|
||||
if (exists) return null;
|
||||
|
||||
var entity = new Customer
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Name = request.Name,
|
||||
Phone = phone,
|
||||
NationalId = request.NationalId,
|
||||
BirthDateJalali = request.BirthDateJalali,
|
||||
Group = request.Group,
|
||||
ReferredBy = request.ReferredBy
|
||||
};
|
||||
|
||||
_db.Customers.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CustomerDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string id,
|
||||
UpdateCustomerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.Phone is not null)
|
||||
{
|
||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||
var phoneTaken = await _db.Customers.AnyAsync(
|
||||
c => c.CafeId == cafeId && c.Phone == phone && c.Id != id && c.DeletedAt == null,
|
||||
cancellationToken);
|
||||
if (phoneTaken) return null;
|
||||
entity.Phone = phone;
|
||||
}
|
||||
if (request.NationalId is not null) entity.NationalId = request.NationalId;
|
||||
if (request.BirthDateJalali is not null) entity.BirthDateJalali = request.BirthDateJalali;
|
||||
if (request.Group.HasValue) entity.Group = request.Group.Value;
|
||||
if (request.LoyaltyPoints.HasValue) entity.LoyaltyPoints = request.LoyaltyPoints.Value;
|
||||
if (request.ReferredBy is not null) entity.ReferredBy = request.ReferredBy;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CustomerDto ToDto(Customer c) => new(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Phone,
|
||||
c.NationalId,
|
||||
c.BirthDateJalali,
|
||||
c.Group,
|
||||
c.LoyaltyPoints,
|
||||
c.ReferredBy,
|
||||
c.CreatedAt);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface ICommissionCalculator
|
||||
{
|
||||
Task<decimal> ResolveRatePercentAsync(string cafeId, DeliveryPlatform platform, CancellationToken ct = default);
|
||||
decimal CalculateCommission(decimal grossTotal, decimal ratePercent);
|
||||
Task<decimal> CalculateForOrderAsync(
|
||||
string cafeId,
|
||||
UnifiedDeliveryOrder order,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CommissionCalculator : ICommissionCalculator
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly DeliveryPlatformsOptions _options;
|
||||
|
||||
public CommissionCalculator(AppDbContext db, IOptions<DeliveryPlatformsOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<decimal> ResolveRatePercentAsync(
|
||||
string cafeId,
|
||||
DeliveryPlatform platform,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var custom = await _db.DeliveryCommissionRates
|
||||
.Where(r => r.CafeId == cafeId && r.Platform == platform && r.IsActive)
|
||||
.Select(r => (decimal?)r.RatePercent)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (custom is > 0)
|
||||
return custom.Value;
|
||||
|
||||
return platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => _options.DefaultSnappfoodCommissionPercent,
|
||||
DeliveryPlatform.Tap30 => _options.DefaultTap30CommissionPercent,
|
||||
DeliveryPlatform.Digikala => _options.DefaultDigikalaCommissionPercent,
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
|
||||
public decimal CalculateCommission(decimal grossTotal, decimal ratePercent) =>
|
||||
grossTotal <= 0 || ratePercent <= 0
|
||||
? 0m
|
||||
: Math.Round(grossTotal * ratePercent / 100m, 0);
|
||||
|
||||
public async Task<decimal> CalculateForOrderAsync(
|
||||
string cafeId,
|
||||
UnifiedDeliveryOrder order,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (order.Payment.Commission is decimal fromPlatform)
|
||||
return fromPlatform;
|
||||
|
||||
var rate = await ResolveRatePercentAsync(cafeId, order.Platform, ct);
|
||||
var gross = order.Payment.Total > 0
|
||||
? order.Payment.Total
|
||||
: order.Items.Sum(i => i.UnitPrice * i.Quantity);
|
||||
return CalculateCommission(gross, rate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Meezi.API.Models.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IDeliveryFinanceReportService
|
||||
{
|
||||
Task<DeliveryRevenueReportDto> GetRevenueByPlatformAsync(
|
||||
string cafeId,
|
||||
DateTime utcFrom,
|
||||
DateTime utcTo,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryFinanceReportService : IDeliveryFinanceReportService
|
||||
{
|
||||
private static readonly OrderStatus[] RevenueStatuses =
|
||||
[
|
||||
OrderStatus.Confirmed,
|
||||
OrderStatus.Preparing,
|
||||
OrderStatus.Ready,
|
||||
OrderStatus.Delivered
|
||||
];
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public DeliveryFinanceReportService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<DeliveryRevenueReportDto> GetRevenueByPlatformAsync(
|
||||
string cafeId,
|
||||
DateTime utcFrom,
|
||||
DateTime utcTo,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var orders = await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId
|
||||
&& o.DeliveryPlatform != null
|
||||
&& o.CreatedAt >= utcFrom
|
||||
&& o.CreatedAt < utcTo
|
||||
&& RevenueStatuses.Contains(o.Status))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var platforms = Enum.GetValues<DeliveryPlatform>()
|
||||
.Where(p => p != DeliveryPlatform.Direct)
|
||||
.Select(platform =>
|
||||
{
|
||||
var subset = orders.Where(o => o.DeliveryPlatform == platform).ToList();
|
||||
var gross = subset.Sum(o => o.Total);
|
||||
var commission = subset.Sum(o => o.PlatformCommission);
|
||||
return new PlatformRevenueDto(
|
||||
platform,
|
||||
PlatformLabel(platform),
|
||||
subset.Count,
|
||||
gross,
|
||||
commission,
|
||||
gross - commission);
|
||||
})
|
||||
.Where(p => p.OrderCount > 0)
|
||||
.ToList();
|
||||
|
||||
return new DeliveryRevenueReportDto(
|
||||
$"{utcFrom:yyyy-MM-dd} — {utcTo:yyyy-MM-dd}",
|
||||
utcFrom,
|
||||
utcTo,
|
||||
platforms,
|
||||
platforms.Sum(p => p.GrossRevenue),
|
||||
platforms.Sum(p => p.Commission),
|
||||
platforms.Sum(p => p.NetRevenue));
|
||||
}
|
||||
|
||||
private static string PlatformLabel(DeliveryPlatform platform) => platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => "اسنپفود",
|
||||
DeliveryPlatform.Tap30 => "تپسی",
|
||||
DeliveryPlatform.Digikala => "دیجیکالا",
|
||||
_ => platform.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public record DeliveryProcessResult(bool Success, string? MeeziOrderId, string? ErrorCode, string? Message);
|
||||
|
||||
public interface IDeliveryOrderProcessor
|
||||
{
|
||||
Task<DeliveryProcessResult> ProcessAsync(
|
||||
string webhookLogId,
|
||||
UnifiedDeliveryOrder unified,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kds;
|
||||
private readonly ICommissionCalculator _commission;
|
||||
private readonly IInventoryService _inventory;
|
||||
private readonly ISnappfoodClient _snappfood;
|
||||
private readonly ITap30Client _tap30;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DeliveryOrderProcessor> _logger;
|
||||
|
||||
public DeliveryOrderProcessor(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kds,
|
||||
ICommissionCalculator commission,
|
||||
IInventoryService inventory,
|
||||
ISnappfoodClient snappfood,
|
||||
ITap30Client tap30,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DeliveryOrderProcessor> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kds = kds;
|
||||
_commission = commission;
|
||||
_inventory = inventory;
|
||||
_snappfood = snappfood;
|
||||
_tap30 = tap30;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DeliveryProcessResult> ProcessAsync(
|
||||
string webhookLogId,
|
||||
UnifiedDeliveryOrder unified,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var log = await _db.WebhookLogs.FirstOrDefaultAsync(w => w.Id == webhookLogId, ct);
|
||||
if (log is null)
|
||||
return new DeliveryProcessResult(false, null, "LOG_NOT_FOUND", "Webhook log missing.");
|
||||
|
||||
log.AttemptCount++;
|
||||
try
|
||||
{
|
||||
var cafe = await ResolveCafeAsync(unified, ct);
|
||||
if (cafe is null)
|
||||
{
|
||||
await FailLogAsync(log, "Unknown vendor.", ct);
|
||||
return new DeliveryProcessResult(false, null, "VENDOR_NOT_FOUND", "Unknown vendor.");
|
||||
}
|
||||
|
||||
log.CafeId = cafe.Id;
|
||||
log.ExternalOrderId = unified.ExternalId;
|
||||
|
||||
var duplicate = await _db.Orders.AnyAsync(
|
||||
o => o.CafeId == cafe.Id
|
||||
&& o.DeliveryPlatform == unified.Platform
|
||||
&& o.ExternalOrderId == unified.ExternalId,
|
||||
ct);
|
||||
|
||||
if (duplicate)
|
||||
{
|
||||
await CompleteLogAsync(log, null, success: true, error: null, ct);
|
||||
return new DeliveryProcessResult(true, null, null, "Duplicate ignored.");
|
||||
}
|
||||
|
||||
var branchId = await _db.Branches
|
||||
.Where(b => b.CafeId == cafe.Id && b.IsActive)
|
||||
.OrderBy(b => b.Name)
|
||||
.Select(b => b.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var menuItems = await _db.MenuItems
|
||||
.Where(m => m.CafeId == cafe.Id && m.IsAvailable)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var orderItems = new List<OrderItem>();
|
||||
decimal subtotal = 0;
|
||||
|
||||
foreach (var line in unified.Items)
|
||||
{
|
||||
var menuItem = menuItems.FirstOrDefault(m =>
|
||||
(!string.IsNullOrEmpty(line.Sku) && m.Id == line.Sku)
|
||||
|| m.Name.Equals(line.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| (m.NameEn != null && m.NameEn.Equals(line.Name, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (menuItem is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Delivery {Platform} item {Name} not matched for cafe {CafeId}",
|
||||
unified.Platform,
|
||||
line.Name,
|
||||
cafe.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
subtotal += line.UnitPrice * line.Quantity;
|
||||
orderItems.Add(new OrderItem
|
||||
{
|
||||
MenuItemId = menuItem.Id,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Notes = line.Notes ?? unified.Platform.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
if (orderItems.Count == 0)
|
||||
{
|
||||
await FailLogAsync(log, "No menu items matched.", ct);
|
||||
return new DeliveryProcessResult(false, null, "INVALID_MENU_ITEMS", "No menu items matched.");
|
||||
}
|
||||
|
||||
var platformCommission = await _commission.CalculateForOrderAsync(cafe.Id, unified, ct);
|
||||
|
||||
var taxRate = cafe.DefaultTaxRate > 0
|
||||
? cafe.DefaultTaxRate
|
||||
: await _db.Taxes.Where(t => t.CafeId == cafe.Id && t.IsDefault)
|
||||
.Select(t => t.Rate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (taxRate == 0) taxRate = 9m;
|
||||
|
||||
var gross = unified.Payment.Total > 0 ? unified.Payment.Total : subtotal;
|
||||
var taxTotal = Math.Round((gross - platformCommission) * taxRate / 100m, 0);
|
||||
var total = gross;
|
||||
|
||||
var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, ct);
|
||||
var order = new Order
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
BranchId = branchId,
|
||||
OrderType = unified.Delivery.Type == "pickup" ? OrderType.Takeaway : OrderType.Delivery,
|
||||
Source = MapSource(unified.Platform),
|
||||
Status = MapStatus(unified.Status),
|
||||
DisplayNumber = displayNumber,
|
||||
ExternalOrderId = unified.ExternalId,
|
||||
DeliveryPlatform = unified.Platform,
|
||||
PlatformCommission = platformCommission,
|
||||
DeliveryMetaJson = JsonSerializer.Serialize(unified.Delivery),
|
||||
Subtotal = subtotal,
|
||||
TaxTotal = taxTotal,
|
||||
Total = total,
|
||||
Items = orderItems
|
||||
};
|
||||
|
||||
if (unified.Platform == DeliveryPlatform.Snappfood)
|
||||
order.SnappfoodOrderId = unified.ExternalId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(unified.Customer.Phone))
|
||||
{
|
||||
var phone = unified.Customer.Phone.Trim();
|
||||
var customer = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == phone, ct);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
Name = unified.Customer.Name,
|
||||
Phone = phone,
|
||||
Group = CustomerGroup.New
|
||||
};
|
||||
_db.Customers.Add(customer);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
order.CustomerId = customer.Id;
|
||||
order.GuestName = unified.Customer.Name;
|
||||
order.GuestPhone = phone;
|
||||
}
|
||||
else
|
||||
{
|
||||
order.GuestName = unified.Customer.Name;
|
||||
}
|
||||
|
||||
if (unified.Payment.IsPaid && total > 0)
|
||||
{
|
||||
order.Payments.Add(new Payment
|
||||
{
|
||||
Method = unified.Payment.Method.Equals("cash", StringComparison.OrdinalIgnoreCase)
|
||||
? PaymentMethod.Cash
|
||||
: PaymentMethod.Card,
|
||||
Amount = total,
|
||||
Status = PaymentStatus.Completed
|
||||
});
|
||||
}
|
||||
|
||||
_db.Orders.Add(order);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await TryDeductInventoryAsync(cafe.Id, orderItems, ct);
|
||||
|
||||
var loaded = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Table)
|
||||
.FirstAsync(o => o.Id == order.Id, ct);
|
||||
|
||||
await _kds.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), ct);
|
||||
PrinterBackgroundJobs.QueueKitchenPrint(_scopeFactory, cafe.Id, order.Id);
|
||||
|
||||
await AcknowledgePlatformAsync(unified, ct);
|
||||
|
||||
await CompleteLogAsync(log, order.Id, success: true, error: null, ct);
|
||||
return new DeliveryProcessResult(true, order.Id, null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Delivery order processing failed for log {LogId}", webhookLogId);
|
||||
await FailLogAsync(log, ex.Message, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Cafe?> ResolveCafeAsync(UnifiedDeliveryOrder unified, CancellationToken ct) =>
|
||||
unified.Platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.SnappfoodVendorId == unified.VendorId, ct),
|
||||
DeliveryPlatform.Tap30 => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.Tap30VendorId == unified.VendorId, ct),
|
||||
DeliveryPlatform.Digikala => await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.DigikalaVendorId == unified.VendorId, ct),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task AcknowledgePlatformAsync(UnifiedDeliveryOrder unified, CancellationToken ct)
|
||||
{
|
||||
switch (unified.Platform)
|
||||
{
|
||||
case DeliveryPlatform.Snappfood:
|
||||
await _snappfood.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
||||
break;
|
||||
case DeliveryPlatform.Tap30:
|
||||
await _tap30.AcknowledgeOrderAsync(unified.ExternalId, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryDeductInventoryAsync(
|
||||
string cafeId,
|
||||
List<OrderItem> items,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
var orderId = items[0].OrderId;
|
||||
await _inventory.DeductForOrderAsync(
|
||||
cafeId,
|
||||
orderId,
|
||||
items.Select(i => (i.MenuItemId, i.Quantity)).ToList(),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static OrderSource MapSource(DeliveryPlatform platform) => platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => OrderSource.SnappFood,
|
||||
DeliveryPlatform.Tap30 => OrderSource.Tap30,
|
||||
DeliveryPlatform.Digikala => OrderSource.Digikala,
|
||||
_ => OrderSource.Pos
|
||||
};
|
||||
|
||||
private static OrderStatus MapStatus(UnifiedDeliveryStatus status) => status switch
|
||||
{
|
||||
UnifiedDeliveryStatus.Pending => OrderStatus.Pending,
|
||||
UnifiedDeliveryStatus.Confirmed => OrderStatus.Confirmed,
|
||||
UnifiedDeliveryStatus.Preparing => OrderStatus.Preparing,
|
||||
UnifiedDeliveryStatus.Ready => OrderStatus.Ready,
|
||||
UnifiedDeliveryStatus.Delivered => OrderStatus.Delivered,
|
||||
UnifiedDeliveryStatus.Cancelled => OrderStatus.Cancelled,
|
||||
_ => OrderStatus.Confirmed
|
||||
};
|
||||
|
||||
private async Task FailLogAsync(WebhookLog log, string error, CancellationToken ct)
|
||||
{
|
||||
log.Success = false;
|
||||
log.Processed = true;
|
||||
log.ErrorMessage = error.Length > 2000 ? error[..2000] : error;
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (log.AttemptCount >= 3)
|
||||
_logger.LogError(
|
||||
"Delivery webhook dead-letter: platform {Platform} external {ExternalId} — {Error}",
|
||||
log.Platform,
|
||||
log.ExternalOrderId,
|
||||
error);
|
||||
}
|
||||
|
||||
private async Task CompleteLogAsync(
|
||||
WebhookLog log,
|
||||
string? meeziOrderId,
|
||||
bool success,
|
||||
string? error,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log.Success = success;
|
||||
log.Processed = true;
|
||||
log.MeeziOrderId = meeziOrderId;
|
||||
log.ErrorMessage = error;
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
private async Task<int> AllocateDisplayNumberAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var max = await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId)
|
||||
.MaxAsync(o => (int?)o.DisplayNumber, ct);
|
||||
return (max ?? 0) + 1;
|
||||
} private static LiveOrderDto MapLive(Order o) => new(
|
||||
o.Id,
|
||||
o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id),
|
||||
o.Status,
|
||||
o.Table?.Number,
|
||||
o.OrderType,
|
||||
o.Total,
|
||||
o.CreatedAt,
|
||||
o.Items.Select(i => new OrderItemDto(
|
||||
i.Id,
|
||||
i.MenuItemId,
|
||||
i.MenuItem?.Name ?? "",
|
||||
i.Quantity,
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IDeliveryStatusSyncService
|
||||
{
|
||||
Task<bool> SyncInternalStatusAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
OrderStatus newStatus,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<bool> ApplyPlatformStatusAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string platformStatus,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryStatusSyncService : IDeliveryStatusSyncService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kds;
|
||||
private readonly ISnappfoodClient _snappfood;
|
||||
private readonly ITap30Client _tap30;
|
||||
private readonly IInventoryService _inventory;
|
||||
private readonly ILogger<DeliveryStatusSyncService> _logger;
|
||||
|
||||
public DeliveryStatusSyncService(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kds,
|
||||
ISnappfoodClient snappfood,
|
||||
ITap30Client tap30,
|
||||
IInventoryService inventory,
|
||||
ILogger<DeliveryStatusSyncService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kds = kds;
|
||||
_snappfood = snappfood;
|
||||
_tap30 = tap30;
|
||||
_inventory = inventory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncInternalStatusAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
OrderStatus newStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var order = await _db.Orders
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, ct);
|
||||
|
||||
if (order?.DeliveryPlatform is null || string.IsNullOrEmpty(order.ExternalOrderId))
|
||||
return false;
|
||||
|
||||
var platformStatus = MapToPlatformStatus(newStatus);
|
||||
await NotifyPlatformAsync(order.DeliveryPlatform.Value, order.ExternalOrderId, platformStatus, ct);
|
||||
|
||||
if (newStatus == OrderStatus.Delivered && !string.IsNullOrEmpty(order.SnappfoodOrderId))
|
||||
await _snappfood.NotifyOrderDeliveredAsync(order.SnappfoodOrderId, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyPlatformStatusAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string platformStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var order = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.DeliveryPlatform == platform && o.ExternalOrderId == externalOrderId,
|
||||
ct);
|
||||
|
||||
if (order is null)
|
||||
return false;
|
||||
|
||||
var mapped = MapFromPlatformStatus(platformStatus);
|
||||
if (order.Status == mapped)
|
||||
return true;
|
||||
|
||||
if (mapped == OrderStatus.Cancelled)
|
||||
await RollbackInventoryPlaceholderAsync(order, ct);
|
||||
|
||||
order.Status = mapped;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _kds.NotifyOrderStatusChangedAsync(order.CafeId, order.Id, mapped, ct);
|
||||
if (!string.IsNullOrEmpty(order.TableId))
|
||||
await _kds.NotifyTableStatusChangedAsync(order.CafeId, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task NotifyPlatformAsync(
|
||||
DeliveryPlatform platform,
|
||||
string externalOrderId,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
switch (platform)
|
||||
{
|
||||
case DeliveryPlatform.Snappfood:
|
||||
await _snappfood.NotifyOrderStatusAsync(externalOrderId, status, ct);
|
||||
break;
|
||||
case DeliveryPlatform.Tap30:
|
||||
await _tap30.NotifyOrderStatusAsync(externalOrderId, status, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task RollbackInventoryPlaceholderAsync(Order order, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Delivery order {OrderId} cancelled — inventory rollback pending BOM linkage",
|
||||
order.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string MapToPlatformStatus(OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Pending => "pending",
|
||||
OrderStatus.Confirmed => "confirmed",
|
||||
OrderStatus.Preparing => "preparing",
|
||||
OrderStatus.Ready => "ready",
|
||||
OrderStatus.Delivered => "delivered",
|
||||
OrderStatus.Cancelled => "cancelled",
|
||||
_ => "confirmed"
|
||||
};
|
||||
|
||||
private static OrderStatus MapFromPlatformStatus(string platformStatus) =>
|
||||
platformStatus.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => OrderStatus.Pending,
|
||||
"confirmed" => OrderStatus.Confirmed,
|
||||
"preparing" or "in_progress" => OrderStatus.Preparing,
|
||||
"ready" => OrderStatus.Ready,
|
||||
"delivered" or "completed" => OrderStatus.Delivered,
|
||||
"cancelled" or "canceled" => OrderStatus.Cancelled,
|
||||
_ => OrderStatus.Confirmed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Hangfire;
|
||||
using Meezi.API.Jobs;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public record WebhookIngressResult(bool Accepted, string? WebhookLogId, string? ErrorCode, string? Message);
|
||||
|
||||
public interface IDeliveryWebhookIngressService
|
||||
{
|
||||
Task<WebhookIngressResult> ReceiveAsync(
|
||||
DeliveryPlatform platform,
|
||||
string rawBody,
|
||||
string? signatureHeader,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DeliveryWebhookIngressService : IDeliveryWebhookIngressService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IWebhookSignatureService _signatures;
|
||||
private readonly IOrderNormalizer _normalizer;
|
||||
|
||||
public DeliveryWebhookIngressService(
|
||||
AppDbContext db,
|
||||
IWebhookSignatureService signatures,
|
||||
IOrderNormalizer normalizer)
|
||||
{
|
||||
_db = db;
|
||||
_signatures = signatures;
|
||||
_normalizer = normalizer;
|
||||
}
|
||||
|
||||
public async Task<WebhookIngressResult> ReceiveAsync(
|
||||
DeliveryPlatform platform,
|
||||
string rawBody,
|
||||
string? signatureHeader,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var signatureValid = _signatures.Verify(platform, rawBody, signatureHeader);
|
||||
|
||||
var log = new WebhookLog
|
||||
{
|
||||
Id = $"wh_{Guid.NewGuid():N}"[..24],
|
||||
Platform = platform,
|
||||
RawBody = rawBody,
|
||||
SignatureHeader = signatureHeader,
|
||||
SignatureValid = signatureValid,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.WebhookLogs.Add(log);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (!signatureValid)
|
||||
return new WebhookIngressResult(false, log.Id, "UNAUTHORIZED", "Invalid signature.");
|
||||
|
||||
var unified = _normalizer.FromJson(platform, rawBody);
|
||||
if (unified is null)
|
||||
{
|
||||
log.Success = false;
|
||||
log.Processed = true;
|
||||
log.ErrorMessage = "Could not normalize payload.";
|
||||
log.ProcessedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new WebhookIngressResult(false, log.Id, "VALIDATION_ERROR", log.ErrorMessage);
|
||||
}
|
||||
|
||||
BackgroundJob.Enqueue<ProcessDeliveryOrderJob>(job =>
|
||||
job.ExecuteAsync(log.Id, unified, CancellationToken.None));
|
||||
|
||||
return new WebhookIngressResult(true, log.Id, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Snappfood;
|
||||
using Meezi.API.Models.Tap30;
|
||||
using Meezi.Core.Delivery;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IOrderNormalizer
|
||||
{
|
||||
UnifiedDeliveryOrder? FromSnappfood(SnappfoodWebhookOrder payload);
|
||||
UnifiedDeliveryOrder? FromTap30(Tap30WebhookOrder payload);
|
||||
UnifiedDeliveryOrder? FromJson(DeliveryPlatform platform, string rawJson);
|
||||
}
|
||||
|
||||
public class OrderNormalizer : IOrderNormalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public UnifiedDeliveryOrder? FromSnappfood(SnappfoodWebhookOrder payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.OrderId) || string.IsNullOrWhiteSpace(payload.VendorId))
|
||||
return null;
|
||||
|
||||
var items = payload.Items.Select(i => new UnifiedDeliveryItem(
|
||||
Sku: i.Name,
|
||||
Name: i.Name,
|
||||
Quantity: i.Quantity,
|
||||
UnitPrice: i.UnitPrice,
|
||||
Notes: "Snappfood")).ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
return new UnifiedDeliveryOrder(
|
||||
payload.OrderId.Trim(),
|
||||
DeliveryPlatform.Snappfood,
|
||||
payload.VendorId.Trim(),
|
||||
DateTime.UtcNow,
|
||||
new UnifiedDeliveryCustomer(
|
||||
payload.CustomerName ?? "Snappfood",
|
||||
payload.CustomerPhone ?? ""),
|
||||
items,
|
||||
new UnifiedDeliveryPayment(
|
||||
payload.Total,
|
||||
"online",
|
||||
true,
|
||||
null),
|
||||
new UnifiedDeliveryInfo("delivery"),
|
||||
UnifiedDeliveryStatus.Confirmed);
|
||||
}
|
||||
|
||||
public UnifiedDeliveryOrder? FromTap30(Tap30WebhookOrder payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.OrderId) || string.IsNullOrWhiteSpace(payload.VendorId))
|
||||
return null;
|
||||
|
||||
var items = (payload.Items ?? [])
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i.Name) && i.Quantity > 0)
|
||||
.Select(i => new UnifiedDeliveryItem(
|
||||
Sku: i.Sku ?? i.Name,
|
||||
Name: i.Name,
|
||||
Quantity: i.Quantity,
|
||||
UnitPrice: i.UnitPrice,
|
||||
i.Notes))
|
||||
.ToList();
|
||||
|
||||
if (items.Count == 0)
|
||||
return null;
|
||||
|
||||
var customer = payload.Customer ?? new Tap30Customer(null, null, null, null, null);
|
||||
var deliveryType = string.IsNullOrWhiteSpace(payload.DeliveryType)
|
||||
? "delivery"
|
||||
: payload.DeliveryType.Trim().ToLowerInvariant();
|
||||
|
||||
return new UnifiedDeliveryOrder(
|
||||
payload.OrderId.Trim(),
|
||||
DeliveryPlatform.Tap30,
|
||||
payload.VendorId.Trim(),
|
||||
DateTime.UtcNow,
|
||||
new UnifiedDeliveryCustomer(
|
||||
customer.Name ?? "Tap30",
|
||||
customer.Phone ?? "",
|
||||
customer.Address,
|
||||
customer.Lat,
|
||||
customer.Lng),
|
||||
items,
|
||||
new UnifiedDeliveryPayment(
|
||||
payload.Total,
|
||||
payload.PaymentMethod ?? "online",
|
||||
payload.IsPaid ?? true,
|
||||
payload.Commission),
|
||||
new UnifiedDeliveryInfo(
|
||||
deliveryType,
|
||||
payload.EstimatedMinutes,
|
||||
payload.DriverName,
|
||||
payload.DriverPhone),
|
||||
MapTap30Status(payload.Status));
|
||||
}
|
||||
|
||||
public UnifiedDeliveryOrder? FromJson(DeliveryPlatform platform, string rawJson)
|
||||
{
|
||||
return platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => FromSnappfood(
|
||||
JsonSerializer.Deserialize<SnappfoodWebhookOrder>(rawJson, JsonOptions)!),
|
||||
DeliveryPlatform.Tap30 => FromTap30(
|
||||
JsonSerializer.Deserialize<Tap30WebhookOrder>(rawJson, JsonOptions)!),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static UnifiedDeliveryStatus MapTap30Status(string? status) =>
|
||||
status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"pending" => UnifiedDeliveryStatus.Pending,
|
||||
"confirmed" => UnifiedDeliveryStatus.Confirmed,
|
||||
"preparing" or "in_progress" => UnifiedDeliveryStatus.Preparing,
|
||||
"ready" => UnifiedDeliveryStatus.Ready,
|
||||
"delivered" or "completed" => UnifiedDeliveryStatus.Delivered,
|
||||
"cancelled" or "canceled" => UnifiedDeliveryStatus.Cancelled,
|
||||
_ => UnifiedDeliveryStatus.Confirmed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.Core.Enums;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Delivery;
|
||||
|
||||
public interface IWebhookSignatureService
|
||||
{
|
||||
bool Verify(DeliveryPlatform platform, string rawBody, string? signatureHeader);
|
||||
}
|
||||
|
||||
public class WebhookSignatureService : IWebhookSignatureService
|
||||
{
|
||||
private readonly DeliveryPlatformsOptions _options;
|
||||
|
||||
public WebhookSignatureService(IOptions<DeliveryPlatformsOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public bool Verify(DeliveryPlatform platform, string rawBody, string? signatureHeader)
|
||||
{
|
||||
var secret = platform switch
|
||||
{
|
||||
DeliveryPlatform.Snappfood => _options.Snappfood.WebhookSecret,
|
||||
DeliveryPlatform.Tap30 => _options.Tap30.WebhookSecret,
|
||||
DeliveryPlatform.Digikala => _options.Digikala.WebhookSecret,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
return false;
|
||||
|
||||
var provided = signatureHeader.Trim();
|
||||
if (provided.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
|
||||
provided = provided["sha256=".Length..];
|
||||
|
||||
var expected = ComputeHmacSha256Hex(rawBody, secret);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(provided));
|
||||
}
|
||||
|
||||
public static string ComputeHmacSha256Hex(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using Meezi.Core.Utilities;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a free-text Persian (or mixed) query into structured discover filter hints.
|
||||
/// The results are merged into <see cref="DiscoverFilterParams"/> before matching,
|
||||
/// so the AI text box silently pre-populates filters without the user having to pick chips.
|
||||
/// </summary>
|
||||
public static class DiscoverNlpParser
|
||||
{
|
||||
public record NlpHints(
|
||||
IReadOnlyList<string> Themes,
|
||||
IReadOnlyList<string> Vibes,
|
||||
IReadOnlyList<string> Occasions,
|
||||
IReadOnlyList<string> SpaceFeatures,
|
||||
string? NoiseLevel,
|
||||
string? PriceTier,
|
||||
string? Size);
|
||||
|
||||
public static NlpHints Parse(string rawQuery)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawQuery))
|
||||
return new NlpHints([], [], [], [], null, null, null);
|
||||
|
||||
// Normalize: lower-case + Arabic glyph unification
|
||||
var q = PersianSearchNormalizer.Normalize(rawQuery.ToLowerInvariant());
|
||||
|
||||
var themes = new HashSet<string>();
|
||||
var vibes = new HashSet<string>();
|
||||
var occasions = new HashSet<string>();
|
||||
var spaceFeatures = new HashSet<string>();
|
||||
string? noise = null;
|
||||
string? price = null;
|
||||
string? size = null;
|
||||
|
||||
// ── Themes ──────────────────────────────────────────────────────────
|
||||
if (Any(q, "کتاب", "کتابخانه", "book cafe", "book"))
|
||||
themes.Add("book_cafe");
|
||||
|
||||
if (Any(q, "قهوه تخصصی", "رست", "اسپشلتی", "specialty", "singl origin", "سینگل اوریجین"))
|
||||
themes.Add("specialty_coffee");
|
||||
|
||||
if (Any(q, "چایی", "چای", "tea house", "tea"))
|
||||
themes.Add("tea_house");
|
||||
|
||||
if (Any(q, "گالری", "هنر", "نقاشی", "art gallery", "art"))
|
||||
themes.Add("art_gallery");
|
||||
|
||||
if (Any(q, "ورزشی", "sport", "بازی", "game"))
|
||||
themes.Add("sport_cafe");
|
||||
|
||||
if (Any(q, "گیمینگ", "gaming", "گیم"))
|
||||
themes.Add("gaming_cafe");
|
||||
|
||||
if (Any(q, "سنتی", "قدیمی", "ایرانی", "اصیل", "persian", "ایران"))
|
||||
themes.Add("persian_traditional");
|
||||
|
||||
if (Any(q, "رترو", "vintage", "کلاسیک"))
|
||||
themes.Add("vintage");
|
||||
|
||||
if (Any(q, "مدرن", "شیک", "مینیمال", "modern", "minimal"))
|
||||
themes.Add("modern");
|
||||
|
||||
if (Any(q, "صبحانه", "برانچ", "brunch"))
|
||||
themes.Add("brunch");
|
||||
|
||||
if (Any(q, "شب", "دیروقت", "late night", "شبانه"))
|
||||
themes.Add("late_night");
|
||||
|
||||
if (Any(q, "گیاه", "طبیعت", "سبز", "plants", "گلخانه"))
|
||||
themes.Add("plants_heavy");
|
||||
|
||||
if (Any(q, "اینستا", "دکور", "عکس", "فتوگنیک", "insta"))
|
||||
themes.Add("instagrammable");
|
||||
|
||||
if (Any(q, "کافه رستوران", "رستوران", "رستو"))
|
||||
themes.Add("brunch");
|
||||
|
||||
// ── Vibes ────────────────────────────────────────────────────────────
|
||||
if (Any(q, "ارام", "ساکت", "بی سر و صدا", "آروم", "quiet"))
|
||||
{
|
||||
vibes.Add("quiet");
|
||||
noise ??= "quiet";
|
||||
}
|
||||
|
||||
if (Any(q, "رمانتیک", "عاشقانه", "دوتایی", "romantic"))
|
||||
vibes.Add("romantic");
|
||||
|
||||
if (Any(q, "دنج", "صمیمی", "گرم", "cozy", "کوزی"))
|
||||
vibes.Add("cozy");
|
||||
|
||||
if (Any(q, "هنری", "خلاق", "artistic"))
|
||||
vibes.Add("artistic");
|
||||
|
||||
if (Any(q, "شلوغ", "شاد", "پر انرژی", "lively"))
|
||||
{
|
||||
vibes.Add("lively");
|
||||
noise ??= "lively";
|
||||
}
|
||||
|
||||
if (Any(q, "درس", "مطالعه", "کار", "لپتاپ", "study"))
|
||||
vibes.Add("study_friendly");
|
||||
|
||||
if (Any(q, "لوکس", "لاکچری", "لوکژری", "luxury"))
|
||||
vibes.Add("luxury");
|
||||
|
||||
if (Any(q, "ترند", "مد", "تازه", "trendy"))
|
||||
vibes.Add("trendy");
|
||||
|
||||
// ── Occasions ────────────────────────────────────────────────────────
|
||||
if (Any(q, "قرار", "دیت", "دوتایی", "date"))
|
||||
occasions.Add("date");
|
||||
|
||||
if (Any(q, "خانواده", "بچه", "کودک", "family"))
|
||||
occasions.Add("family");
|
||||
|
||||
if (Any(q, "دوستانه", "دوستا", "جمعی", "friends", "گروهی"))
|
||||
occasions.Add("friends");
|
||||
|
||||
if (Any(q, "مطالعه", "درس خوندن", "کار کردن", "study", "لپتاپ"))
|
||||
occasions.Add("study_work");
|
||||
|
||||
if (Any(q, "جلسه", "بیزنس", "کاری", "business meeting", "میتینگ"))
|
||||
occasions.Add("business_meeting");
|
||||
|
||||
if (Any(q, "صبحانه", "breakfast"))
|
||||
occasions.Add("breakfast");
|
||||
|
||||
if (Any(q, "برانچ", "brunch"))
|
||||
occasions.Add("brunch");
|
||||
|
||||
if (Any(q, "تولد", "جشن", "celebration", "مهمونی"))
|
||||
occasions.Add("celebration");
|
||||
|
||||
if (Any(q, "تنها", "solo", "تک نفره", "یه نفره"))
|
||||
occasions.Add("solo");
|
||||
|
||||
if (Any(q, "بعد شام", "after dinner", "شام"))
|
||||
occasions.Add("after_dinner");
|
||||
|
||||
if (Any(q, "گروه بزرگ", "خیلی ها", "group", "گروه زیاد"))
|
||||
occasions.Add("group_large");
|
||||
|
||||
if (Any(q, "آشنایی", "دوست پیدا", "finding someone"))
|
||||
occasions.Add("finding_someone");
|
||||
|
||||
if (Any(q, "سریع", "quick", "یه قهوه سریع"))
|
||||
occasions.Add("quick_coffee");
|
||||
|
||||
// ── Space Features ───────────────────────────────────────────────────
|
||||
if (Any(q, "وایفای", "وای فای", "wifi", "اینترنت"))
|
||||
spaceFeatures.Add("wifi");
|
||||
|
||||
if (Any(q, "پارکینگ", "parking"))
|
||||
spaceFeatures.Add("parking");
|
||||
|
||||
if (Any(q, "فضای باز", "بیرون", "خارج از ساختمان", "outdoor"))
|
||||
spaceFeatures.Add("outdoor");
|
||||
|
||||
if (Any(q, "تراس", "terrace"))
|
||||
spaceFeatures.Add("terrace");
|
||||
|
||||
if (Any(q, "روفتاپ", "روف تاپ", "بام", "rooftop", "پشت بام"))
|
||||
spaceFeatures.Add("rooftop");
|
||||
|
||||
if (Any(q, "باغ", "گاردن", "garden"))
|
||||
spaceFeatures.Add("garden");
|
||||
|
||||
if (Any(q, "سگ", "گربه", "حیوان خانگی", "pet", "پت فرندلی"))
|
||||
spaceFeatures.Add("pet_friendly");
|
||||
|
||||
if (Any(q, "کودک", "بچه دار", "kids friendly", "بازی بچه"))
|
||||
spaceFeatures.Add("kids_friendly");
|
||||
|
||||
if (Any(q, "موسیقی زنده", "live music", "کنسرت کوچک"))
|
||||
spaceFeatures.Add("live_music");
|
||||
|
||||
if (Any(q, "اتاق خصوصی", "خصوصی", "private room"))
|
||||
spaceFeatures.Add("private_room");
|
||||
|
||||
if (Any(q, "قلیان", "hookah", "نارگیله"))
|
||||
spaceFeatures.Add("hookah");
|
||||
|
||||
if (Any(q, "بازی رومیزی", "بازی فکری", "board game"))
|
||||
spaceFeatures.Add("board_games");
|
||||
|
||||
if (Any(q, "بدون دود", "سیگار ممنوع", "non smoking", "no smoking", "دود نه"))
|
||||
spaceFeatures.Add("no_smoking");
|
||||
|
||||
if (Any(q, "نمازخانه", "نماز", "prayer"))
|
||||
spaceFeatures.Add("prayer_room");
|
||||
|
||||
if (Any(q, "بیرون بر", "تیک اوی", "takeaway", "تیک اوت"))
|
||||
spaceFeatures.Add("takeaway");
|
||||
|
||||
if (Any(q, "ویلچر", "معلول", "دسترسی", "wheelchair"))
|
||||
spaceFeatures.Add("wheelchair");
|
||||
|
||||
// ── Price ────────────────────────────────────────────────────────────
|
||||
if (Any(q, "ارزون", "ارزان", "مقرون به صرفه", "budget", "اقتصادی", "کم هزینه"))
|
||||
price = "budget";
|
||||
else if (Any(q, "لوکس", "گرون", "لاکچری", "premium", "پریمیوم", "گران قیمت"))
|
||||
price = "premium";
|
||||
|
||||
// ── Size ─────────────────────────────────────────────────────────────
|
||||
if (Any(q, "کوچیک", "کوچک", "tiny", "کوچولو", "مینی"))
|
||||
size = "tiny";
|
||||
else if (Any(q, "دنج", "cozy", "صمیمی") && size is null)
|
||||
size = "cozy";
|
||||
else if (Any(q, "بزرگ", "وسیع", "large", "spacious", "گنجایش بالا"))
|
||||
size = "large";
|
||||
|
||||
return new NlpHints(
|
||||
[.. themes],
|
||||
[.. vibes],
|
||||
[.. occasions],
|
||||
[.. spaceFeatures],
|
||||
noise,
|
||||
price,
|
||||
size);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool Any(string q, params string[] terms) =>
|
||||
terms.Any(q.Contains);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Meezi.Core.Discover;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record DiscoverFilterParams(
|
||||
string? City = null,
|
||||
string? Q = null,
|
||||
double? MinRating = null,
|
||||
string? Sort = null,
|
||||
IReadOnlyList<string>? Themes = null,
|
||||
IReadOnlyList<string>? Vibes = null,
|
||||
IReadOnlyList<string>? Occasions = null,
|
||||
IReadOnlyList<string>? SpaceFeatures = null,
|
||||
string? NoiseLevel = null,
|
||||
string? PriceTier = null,
|
||||
string? Size = null,
|
||||
bool RequireProfile = false,
|
||||
bool OpenNow = false)
|
||||
{
|
||||
public static DiscoverFilterParams FromQuery(
|
||||
string? city,
|
||||
string? q,
|
||||
double? minRating,
|
||||
string? sort,
|
||||
string? themes,
|
||||
string? vibes,
|
||||
string? occasions,
|
||||
string? spaceFeatures,
|
||||
string? noise,
|
||||
string? priceTier,
|
||||
string? size,
|
||||
bool requireProfile,
|
||||
bool openNow)
|
||||
{
|
||||
// Parse explicit CSV filter chips
|
||||
var explicitThemes = SplitCsv(themes);
|
||||
var explicitVibes = SplitCsv(vibes);
|
||||
var explicitOcc = SplitCsv(occasions);
|
||||
var explicitSpace = SplitCsv(spaceFeatures);
|
||||
var explicitNoise = NormalizeToken(noise);
|
||||
var explicitPrice = NormalizeToken(priceTier);
|
||||
var explicitSize = NormalizeToken(size);
|
||||
|
||||
// AI-parse the free-text query and merge hints (explicit chips take priority)
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var hints = DiscoverNlpParser.Parse(q);
|
||||
explicitThemes = Merge(explicitThemes, hints.Themes);
|
||||
explicitVibes = Merge(explicitVibes, hints.Vibes);
|
||||
explicitOcc = Merge(explicitOcc, hints.Occasions);
|
||||
explicitSpace = Merge(explicitSpace, hints.SpaceFeatures);
|
||||
explicitNoise ??= hints.NoiseLevel;
|
||||
explicitPrice ??= hints.PriceTier;
|
||||
explicitSize ??= hints.Size;
|
||||
}
|
||||
|
||||
return new(
|
||||
city,
|
||||
q,
|
||||
minRating,
|
||||
sort,
|
||||
explicitThemes,
|
||||
explicitVibes,
|
||||
explicitOcc,
|
||||
explicitSpace,
|
||||
explicitNoise,
|
||||
explicitPrice,
|
||||
explicitSize,
|
||||
requireProfile,
|
||||
openNow);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? SplitCsv(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var parts = value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(NormalizeToken)
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.Select(p => p!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return parts.Count == 0 ? null : parts;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? Merge(IReadOnlyList<string>? existing, IReadOnlyList<string> hints)
|
||||
{
|
||||
if (hints.Count == 0) return existing;
|
||||
if (existing is null or { Count: 0 }) return hints.Count > 0 ? hints : null;
|
||||
var merged = existing.Union(hints, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
return merged.Count > 0 ? merged : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeToken(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static class DiscoverProfileMatcher
|
||||
{
|
||||
public static bool HasMeaningfulProfile(CafeDiscoverProfile profile) =>
|
||||
profile.Themes.Count > 0
|
||||
|| profile.Vibes.Count > 0
|
||||
|| profile.Occasions.Count > 0
|
||||
|| profile.SpaceFeatures.Count > 0
|
||||
|| !string.IsNullOrWhiteSpace(profile.Size)
|
||||
|| !string.IsNullOrWhiteSpace(profile.NoiseLevel)
|
||||
|| !string.IsNullOrWhiteSpace(profile.PriceTier);
|
||||
|
||||
/// <summary>
|
||||
/// Relevance score in [0, 1]. Returns 1.0 when no profile filters are set (text-only search).
|
||||
/// Each filter dimension that matches contributes 1 / totalDimensions to the score.
|
||||
/// Score ≥ <see cref="MinScoreThreshold"/> passes the soft-filter.
|
||||
/// </summary>
|
||||
public const double MinScoreThreshold = 0.25;
|
||||
|
||||
public static double Score(CafeDiscoverProfile profile, DiscoverFilterParams filters)
|
||||
{
|
||||
int total = 0;
|
||||
int matched = 0;
|
||||
|
||||
if (filters.Themes is { Count: > 0 } themes)
|
||||
{
|
||||
total++;
|
||||
if (themes.Any(t => profile.Themes.Contains(t, StringComparer.OrdinalIgnoreCase)))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (filters.Vibes is { Count: > 0 } vibes)
|
||||
{
|
||||
total++;
|
||||
if (vibes.Any(v => profile.Vibes.Contains(v, StringComparer.OrdinalIgnoreCase)))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (filters.Occasions is { Count: > 0 } occasions)
|
||||
{
|
||||
total++;
|
||||
if (occasions.Any(o => profile.Occasions.Contains(o, StringComparer.OrdinalIgnoreCase)))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (filters.SpaceFeatures is { Count: > 0 } space)
|
||||
{
|
||||
total++;
|
||||
if (space.Any(s => profile.SpaceFeatures.Contains(s, StringComparer.OrdinalIgnoreCase)))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.NoiseLevel))
|
||||
{
|
||||
total++;
|
||||
if (string.Equals(profile.NoiseLevel, filters.NoiseLevel, StringComparison.OrdinalIgnoreCase))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.PriceTier))
|
||||
{
|
||||
total++;
|
||||
if (string.Equals(profile.PriceTier, filters.PriceTier, StringComparison.OrdinalIgnoreCase))
|
||||
matched++;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.Size))
|
||||
{
|
||||
total++;
|
||||
if (string.Equals(profile.Size, filters.Size, StringComparison.OrdinalIgnoreCase))
|
||||
matched++;
|
||||
}
|
||||
|
||||
return total == 0 ? 1.0 : (double)matched / total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy hard-AND match still used for explicit chip-only searches (no free text).
|
||||
/// Free-text searches use <see cref="Score"/> ≥ threshold instead.
|
||||
/// </summary>
|
||||
public static bool Matches(CafeDiscoverProfile profile, DiscoverFilterParams filters)
|
||||
{
|
||||
if (filters.RequireProfile && !HasMeaningfulProfile(profile))
|
||||
return false;
|
||||
|
||||
if (filters.Themes is { Count: > 0 } themes
|
||||
&& !themes.Any(t => profile.Themes.Contains(t, StringComparer.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
if (filters.Vibes is { Count: > 0 } vibes
|
||||
&& !vibes.Any(v => profile.Vibes.Contains(v, StringComparer.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
if (filters.Occasions is { Count: > 0 } occasions
|
||||
&& !occasions.Any(o => profile.Occasions.Contains(o, StringComparer.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
if (filters.SpaceFeatures is { Count: > 0 } space
|
||||
&& !space.Any(s => profile.SpaceFeatures.Contains(s, StringComparer.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.NoiseLevel)
|
||||
&& !string.Equals(profile.NoiseLevel, filters.NoiseLevel, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.PriceTier)
|
||||
&& !string.Equals(profile.PriceTier, filters.PriceTier, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(filters.Size)
|
||||
&& !string.Equals(profile.Size, filters.Size, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Meezi.API.Models.Expenses;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record ExpenseServiceResult<T>(bool Success, T? Data, string? ErrorCode = null, string? Field = null);
|
||||
|
||||
public interface IExpenseService
|
||||
{
|
||||
Task<ExpenseServiceResult<ExpenseDto>> CreateExpenseAsync(
|
||||
string cafeId,
|
||||
CreateExpenseRequest request,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ExpenseListResult> GetExpensesAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ExpenseServiceResult<bool>> DeleteExpenseAsync(
|
||||
string cafeId,
|
||||
string expenseId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ExpenseService : IExpenseService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IShiftService _shifts;
|
||||
|
||||
public ExpenseService(AppDbContext db, IShiftService shifts)
|
||||
{
|
||||
_db = db;
|
||||
_shifts = shifts;
|
||||
}
|
||||
|
||||
public async Task<ExpenseServiceResult<ExpenseDto>> CreateExpenseAsync(
|
||||
string cafeId,
|
||||
CreateExpenseRequest request,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branch = await _db.Branches.FirstOrDefaultAsync(
|
||||
b => b.Id == request.BranchId && b.CafeId == cafeId && b.IsActive,
|
||||
cancellationToken);
|
||||
if (branch is null)
|
||||
return new ExpenseServiceResult<ExpenseDto>(false, null, "BRANCH_NOT_FOUND", "branchId");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ShiftId))
|
||||
{
|
||||
var shift = await _db.RegisterShifts.FirstOrDefaultAsync(
|
||||
s => s.Id == request.ShiftId && s.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (shift is null)
|
||||
return new ExpenseServiceResult<ExpenseDto>(false, null, "SHIFT_NOT_FOUND", "shiftId");
|
||||
if (shift.BranchId != request.BranchId)
|
||||
return new ExpenseServiceResult<ExpenseDto>(false, null, "SHIFT_BRANCH_MISMATCH", "shiftId");
|
||||
if (shift.Status != ShiftStatus.Open)
|
||||
return new ExpenseServiceResult<ExpenseDto>(false, null, "SHIFT_ALREADY_CLOSED", "shiftId");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expense = new Expense
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = request.BranchId,
|
||||
ShiftId = request.ShiftId,
|
||||
Category = request.Category,
|
||||
Amount = request.Amount,
|
||||
Note = request.Note,
|
||||
ReceiptImageUrl = request.ReceiptImageUrl,
|
||||
CreatedByUserId = userId,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Expenses.Add(expense);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ShiftId))
|
||||
{
|
||||
var withdrawalNote = $"expense:{expense.Category}";
|
||||
if (!string.IsNullOrWhiteSpace(request.Note))
|
||||
withdrawalNote += $" — {request.Note}";
|
||||
|
||||
var tx = await _shifts.RecordTransactionAsync(
|
||||
cafeId,
|
||||
request.ShiftId,
|
||||
CashTransactionType.Withdrawal,
|
||||
PaymentMethod.Cash,
|
||||
request.Amount,
|
||||
userId,
|
||||
referenceId: expense.Id,
|
||||
note: withdrawalNote,
|
||||
cancellationToken);
|
||||
|
||||
if (!tx.Success)
|
||||
{
|
||||
_db.Expenses.Remove(expense);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new ExpenseServiceResult<ExpenseDto>(
|
||||
false, null, tx.ErrorCode ?? "WITHDRAWAL_FAILED", tx.Field);
|
||||
}
|
||||
}
|
||||
|
||||
return new ExpenseServiceResult<ExpenseDto>(true, ToDto(expense));
|
||||
}
|
||||
|
||||
public async Task<ExpenseListResult> GetExpensesAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (utcStart, _) = IranCalendar.GetUtcRangeForIranDay(from);
|
||||
var (_, utcEnd) = IranCalendar.GetUtcRangeForIranDay(to);
|
||||
|
||||
var query = _db.Expenses.AsNoTracking()
|
||||
.Where(e => e.CafeId == cafeId
|
||||
&& e.BranchId == branchId
|
||||
&& e.CreatedAt >= utcStart
|
||||
&& e.CreatedAt < utcEnd);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new ExpenseListResult(rows.Select(ToDto).ToList(), total);
|
||||
}
|
||||
|
||||
public async Task<ExpenseServiceResult<bool>> DeleteExpenseAsync(
|
||||
string cafeId,
|
||||
string expenseId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expense = await _db.Expenses.FirstOrDefaultAsync(
|
||||
e => e.Id == expenseId && e.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
|
||||
if (expense is null)
|
||||
return new ExpenseServiceResult<bool>(false, false, "NOT_FOUND");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
expense.DeletedAt = now;
|
||||
|
||||
var withdrawal = await _db.CashTransactions.FirstOrDefaultAsync(
|
||||
t => t.CafeId == cafeId
|
||||
&& t.ReferenceId == expenseId
|
||||
&& t.Type == CashTransactionType.Withdrawal,
|
||||
cancellationToken);
|
||||
if (withdrawal is not null)
|
||||
withdrawal.DeletedAt = now;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new ExpenseServiceResult<bool>(true, true);
|
||||
}
|
||||
|
||||
private static ExpenseDto ToDto(Expense e) => new(
|
||||
e.Id,
|
||||
e.CafeId,
|
||||
e.BranchId,
|
||||
e.ShiftId,
|
||||
e.Category,
|
||||
e.Amount,
|
||||
e.Note,
|
||||
e.ReceiptImageUrl,
|
||||
e.CreatedByUserId,
|
||||
e.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IHrService
|
||||
{
|
||||
Task<IReadOnlyList<EmployeeSummaryDto>> GetEmployeesAsync(
|
||||
string cafeId,
|
||||
string? branchId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<EmployeeSummaryDto?> GetEmployeeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
Task<bool> EmployeeBelongsToCafeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TodayShiftDto?> GetTodayShiftAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
Task<AttendanceDto?> ClockInAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
Task<AttendanceDto?> ClockOutAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<AttendanceDto>> GetAttendanceAsync(string cafeId, string? employeeId, DateOnly? from, DateOnly? to, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ShiftDto>> GetShiftsAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ShiftDto>> UpsertShiftsAsync(string cafeId, string employeeId, UpsertShiftsRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<LeaveRequestDto>> GetLeaveRequestsAsync(string cafeId, LeaveStatus? status, CancellationToken cancellationToken = default);
|
||||
Task<LeaveRequestDto?> CreateLeaveRequestAsync(string cafeId, string employeeId, CreateLeaveRequest request, CancellationToken cancellationToken = default);
|
||||
Task<LeaveRequestDto?> ReviewLeaveRequestAsync(string cafeId, string leaveId, string reviewerId, ReviewLeaveRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<EmployeeSalaryDto>> GetSalariesAsync(string cafeId, string? monthYear, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeSalaryDto?> CreateSalaryAsync(string cafeId, CreateSalaryRequest request, CancellationToken cancellationToken = default);
|
||||
Task<EmployeeSalaryDto?> MarkSalaryPaidAsync(string cafeId, string salaryId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class HrService : IHrService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public HrService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmployeeSummaryDto>> GetEmployeesAsync(
|
||||
string cafeId,
|
||||
string? branchId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Employees.Where(e => e.CafeId == cafeId);
|
||||
if (!string.IsNullOrEmpty(branchId))
|
||||
query = query.Where(e => e.BranchId == branchId);
|
||||
var list = await query.OrderBy(e => e.Name).ToListAsync(cancellationToken);
|
||||
return list.Select(e => new EmployeeSummaryDto(e.Id, e.Name, e.Phone, e.Role, e.BaseSalary)).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmployeeSummaryDto?> GetEmployeeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == employeeId && x.CafeId == cafeId, cancellationToken);
|
||||
return e is null ? null : new EmployeeSummaryDto(e.Id, e.Name, e.Phone, e.Role, e.BaseSalary);
|
||||
}
|
||||
|
||||
public Task<bool> EmployeeBelongsToCafeAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default) =>
|
||||
_db.Employees.AnyAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||||
|
||||
public async Task<TodayShiftDto?> GetTodayShiftAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||||
return null;
|
||||
|
||||
var day = (int)DateTime.UtcNow.DayOfWeek;
|
||||
var shift = await _db.EmployeeSchedules
|
||||
.FirstOrDefaultAsync(s => s.EmployeeId == employeeId && s.DayOfWeek == day, cancellationToken);
|
||||
|
||||
var type = shift?.ShiftType ?? ShiftType.DayOff;
|
||||
return new TodayShiftDto(type, ShiftLabel(type));
|
||||
}
|
||||
|
||||
public async Task<AttendanceDto?> ClockInAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _db.Employees
|
||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||||
if (employee is null) return null;
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var attendance = await _db.Attendances
|
||||
.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.Date == today, cancellationToken);
|
||||
|
||||
if (attendance is null)
|
||||
{
|
||||
attendance = new Attendance
|
||||
{
|
||||
EmployeeId = employeeId,
|
||||
Date = today,
|
||||
ClockIn = DateTime.UtcNow
|
||||
};
|
||||
_db.Attendances.Add(attendance);
|
||||
}
|
||||
else if (attendance.ClockIn is null)
|
||||
{
|
||||
attendance.ClockIn = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ToAttendanceDto(attendance, employee.Name);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToAttendanceDto(attendance, employee.Name);
|
||||
}
|
||||
|
||||
public async Task<AttendanceDto?> ClockOutAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _db.Employees
|
||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId, cancellationToken);
|
||||
if (employee is null) return null;
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var attendance = await _db.Attendances
|
||||
.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.Date == today, cancellationToken);
|
||||
|
||||
if (attendance?.ClockIn is null) return null;
|
||||
|
||||
attendance.ClockOut = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToAttendanceDto(attendance, employee.Name);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttendanceDto>> GetAttendanceAsync(
|
||||
string cafeId,
|
||||
string? employeeId,
|
||||
DateOnly? from,
|
||||
DateOnly? to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Attendances
|
||||
.Include(a => a.Employee)
|
||||
.Where(a => a.Employee.CafeId == cafeId);
|
||||
|
||||
if (!string.IsNullOrEmpty(employeeId))
|
||||
query = query.Where(a => a.EmployeeId == employeeId);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.Date >= from.Value);
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.Date <= to.Value);
|
||||
|
||||
var list = await query.OrderByDescending(a => a.Date).Take(100).ToListAsync(cancellationToken);
|
||||
return list.Select(a => ToAttendanceDto(a, a.Employee.Name)).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ShiftDto>> GetShiftsAsync(string cafeId, string employeeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||||
return [];
|
||||
|
||||
var shifts = await _db.EmployeeSchedules
|
||||
.Where(s => s.EmployeeId == employeeId)
|
||||
.OrderBy(s => s.DayOfWeek)
|
||||
.ToListAsync(cancellationToken);
|
||||
return shifts.Select(s => new ShiftDto(s.DayOfWeek, s.ShiftType)).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ShiftDto>> UpsertShiftsAsync(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
UpsertShiftsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||||
return [];
|
||||
|
||||
var existing = await _db.EmployeeSchedules.Where(s => s.EmployeeId == employeeId).ToListAsync(cancellationToken);
|
||||
_db.EmployeeSchedules.RemoveRange(existing);
|
||||
|
||||
foreach (var s in request.Shifts)
|
||||
{
|
||||
_db.EmployeeSchedules.Add(new EmployeeSchedule
|
||||
{
|
||||
EmployeeId = employeeId,
|
||||
DayOfWeek = s.DayOfWeek,
|
||||
ShiftType = s.ShiftType
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return request.Shifts.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LeaveRequestDto>> GetLeaveRequestsAsync(
|
||||
string cafeId,
|
||||
LeaveStatus? status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.LeaveRequests
|
||||
.Include(l => l.Employee)
|
||||
.Where(l => l.Employee.CafeId == cafeId);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(l => l.Status == status.Value);
|
||||
|
||||
var list = await query.OrderByDescending(l => l.CreatedAt).ToListAsync(cancellationToken);
|
||||
return list.Select(l => ToLeaveDto(l)).ToList();
|
||||
}
|
||||
|
||||
public async Task<LeaveRequestDto?> CreateLeaveRequestAsync(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
CreateLeaveRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EmployeeBelongsToCafeAsync(cafeId, employeeId, cancellationToken))
|
||||
return null;
|
||||
|
||||
var employee = await _db.Employees.FindAsync([employeeId], cancellationToken);
|
||||
var entity = new LeaveRequest
|
||||
{
|
||||
EmployeeId = employeeId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Reason = request.Reason,
|
||||
Status = LeaveStatus.Pending
|
||||
};
|
||||
|
||||
_db.LeaveRequests.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToLeaveDto(entity, employee!.Name);
|
||||
}
|
||||
|
||||
public async Task<LeaveRequestDto?> ReviewLeaveRequestAsync(
|
||||
string cafeId,
|
||||
string leaveId,
|
||||
string reviewerId,
|
||||
ReviewLeaveRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.LeaveRequests
|
||||
.Include(l => l.Employee)
|
||||
.FirstOrDefaultAsync(l => l.Id == leaveId && l.Employee.CafeId == cafeId, cancellationToken);
|
||||
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.Status = request.Status;
|
||||
entity.ReviewedBy = reviewerId;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToLeaveDto(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmployeeSalaryDto>> GetSalariesAsync(
|
||||
string cafeId,
|
||||
string? monthYear,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.EmployeeSalaries
|
||||
.Include(s => s.Employee)
|
||||
.Where(s => s.Employee.CafeId == cafeId);
|
||||
|
||||
if (!string.IsNullOrEmpty(monthYear))
|
||||
query = query.Where(s => s.MonthYear == monthYear);
|
||||
|
||||
var list = await query.OrderByDescending(s => s.MonthYear).ToListAsync(cancellationToken);
|
||||
return list.Select(s => ToSalaryDto(s)).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmployeeSalaryDto?> CreateSalaryAsync(
|
||||
string cafeId,
|
||||
CreateSalaryRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await EmployeeBelongsToCafeAsync(cafeId, request.EmployeeId, cancellationToken))
|
||||
return null;
|
||||
|
||||
var employee = await _db.Employees.FindAsync([request.EmployeeId], cancellationToken);
|
||||
var net = request.BaseSalary + request.OvertimePay - request.Deductions;
|
||||
|
||||
var entity = new EmployeeSalary
|
||||
{
|
||||
EmployeeId = request.EmployeeId,
|
||||
MonthYear = request.MonthYear,
|
||||
BaseSalary = request.BaseSalary,
|
||||
OvertimePay = request.OvertimePay,
|
||||
Deductions = request.Deductions,
|
||||
NetSalary = net,
|
||||
IsPaid = false
|
||||
};
|
||||
|
||||
_db.EmployeeSalaries.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToSalaryDto(entity, employee!.Name);
|
||||
}
|
||||
|
||||
public async Task<EmployeeSalaryDto?> MarkSalaryPaidAsync(string cafeId, string salaryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.EmployeeSalaries
|
||||
.Include(s => s.Employee)
|
||||
.FirstOrDefaultAsync(s => s.Id == salaryId && s.Employee.CafeId == cafeId, cancellationToken);
|
||||
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.IsPaid = true;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToSalaryDto(entity);
|
||||
}
|
||||
|
||||
private static string ShiftLabel(ShiftType type) => type switch
|
||||
{
|
||||
ShiftType.Morning => "08:00 - 16:00",
|
||||
ShiftType.Evening => "16:00 - 00:00",
|
||||
_ => "Day off"
|
||||
};
|
||||
|
||||
private static AttendanceDto ToAttendanceDto(Attendance a, string name) => new(
|
||||
a.Id, a.EmployeeId, name, a.Date, a.ClockIn, a.ClockOut, a.Notes);
|
||||
|
||||
private static LeaveRequestDto ToLeaveDto(LeaveRequest l, string? name = null) => new(
|
||||
l.Id,
|
||||
l.EmployeeId,
|
||||
name ?? l.Employee?.Name ?? "",
|
||||
l.StartDate,
|
||||
l.EndDate,
|
||||
l.Reason,
|
||||
l.Status,
|
||||
l.ReviewedBy,
|
||||
l.CreatedAt);
|
||||
|
||||
private static EmployeeSalaryDto ToSalaryDto(EmployeeSalary s, string? name = null) => new(
|
||||
s.Id,
|
||||
s.EmployeeId,
|
||||
name ?? s.Employee?.Name ?? "",
|
||||
s.MonthYear,
|
||||
s.BaseSalary,
|
||||
s.OvertimePay,
|
||||
s.Deductions,
|
||||
s.NetSalary,
|
||||
s.IsPaid);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Meezi.API.Models.Auth;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
|
||||
SendOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
string CreateAccessToken(Employee employee, Cafe cafe);
|
||||
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
|
||||
string CreateRefreshToken();
|
||||
DateTime GetAccessTokenExpiry();
|
||||
}
|
||||
|
||||
public record TokenPair(string AccessToken, string RefreshToken, DateTime AccessTokenExpiresAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IKdsNotifier
|
||||
{
|
||||
Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default);
|
||||
Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, Core.Enums.OrderStatus status, CancellationToken cancellationToken = default);
|
||||
Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IOrderNotificationService
|
||||
{
|
||||
Task NotifyGuestOrderPlacedAsync(Order order, LiveOrderDto live, CancellationToken ct = default);
|
||||
Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default);
|
||||
Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.API.Hubs;
|
||||
using Meezi.API.Models.Notifications;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record IngredientDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string Unit,
|
||||
decimal QuantityOnHand,
|
||||
decimal ReorderLevel,
|
||||
decimal UnitCost,
|
||||
decimal ParLevel,
|
||||
decimal LowStockWarningPercent,
|
||||
decimal WarningThreshold,
|
||||
decimal StockValueToman,
|
||||
bool IsLowStock);
|
||||
|
||||
public record CreateIngredientRequest(
|
||||
string Name,
|
||||
string Unit,
|
||||
decimal QuantityOnHand,
|
||||
decimal ReorderLevel,
|
||||
decimal UnitCost,
|
||||
decimal ParLevel,
|
||||
decimal LowStockWarningPercent,
|
||||
decimal? TotalPaidToman = null,
|
||||
string? BranchId = null);
|
||||
|
||||
public record UpdateIngredientRequest(
|
||||
string? Name,
|
||||
string? Unit,
|
||||
decimal? ReorderLevel,
|
||||
decimal? UnitCost,
|
||||
decimal? ParLevel,
|
||||
decimal? LowStockWarningPercent);
|
||||
|
||||
public record AdjustStockRequest(
|
||||
decimal Delta,
|
||||
string? Note,
|
||||
decimal? TotalPaidToman = null,
|
||||
string? BranchId = null);
|
||||
|
||||
public record InventoryPurchaseDto(
|
||||
string Id,
|
||||
string IngredientId,
|
||||
string IngredientName,
|
||||
decimal Delta,
|
||||
string Unit,
|
||||
decimal TotalPaidToman,
|
||||
decimal UnitCostAfter,
|
||||
DateTime CreatedAt,
|
||||
string? ExpenseId);
|
||||
|
||||
public record InventoryPurchasesSummaryDto(
|
||||
decimal TotalPaidToman,
|
||||
int PurchaseCount,
|
||||
IReadOnlyList<InventoryPurchaseDto> Recent);
|
||||
|
||||
public record RecipeLineDto(
|
||||
string Id,
|
||||
string IngredientId,
|
||||
string IngredientName,
|
||||
string Unit,
|
||||
decimal QuantityPerUnit);
|
||||
|
||||
public record MenuItemRecipeDto(
|
||||
string MenuItemId,
|
||||
string MenuItemName,
|
||||
IReadOnlyList<RecipeLineDto> Lines,
|
||||
decimal MaterialCostPerUnitToman);
|
||||
|
||||
public record SetRecipeLineRequest(string IngredientId, decimal QuantityPerUnit);
|
||||
|
||||
public record SetMenuItemRecipeRequest(IReadOnlyList<SetRecipeLineRequest> Lines);
|
||||
|
||||
public record OrderDeductionResult(
|
||||
bool Applied,
|
||||
IReadOnlyList<string> LowStockIngredientNames);
|
||||
|
||||
public interface IInventoryService
|
||||
{
|
||||
Task<IReadOnlyList<IngredientDto>> ListAsync(string cafeId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
|
||||
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
AdjustStockRequest request,
|
||||
string? userId,
|
||||
CancellationToken ct = default);
|
||||
Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
CancellationToken ct = default);
|
||||
Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default);
|
||||
Task<MenuItemRecipeDto?> SetRecipeAsync(string cafeId, string menuItemId, SetMenuItemRecipeRequest request, CancellationToken ct = default);
|
||||
Task<OrderDeductionResult> DeductForOrderAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
IReadOnlyList<(string MenuItemId, int Quantity)> lines,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class InventoryService : IInventoryService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHubContext<KdsHub> _kdsHub;
|
||||
|
||||
public InventoryService(AppDbContext db, IHubContext<KdsHub> kdsHub)
|
||||
{
|
||||
_db = db;
|
||||
_kdsHub = kdsHub;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IngredientDto>> ListAsync(string cafeId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.Ingredients.AsNoTracking()
|
||||
.Where(i => i.CafeId == cafeId)
|
||||
.OrderBy(i => i.Name)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.Ingredients.Where(i => i.CafeId == cafeId).ToListAsync(ct);
|
||||
return rows.Where(IsLowStock).Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var par = request.ParLevel > 0 ? request.ParLevel : request.QuantityOnHand;
|
||||
var unitCost = ResolveUnitCost(request.QuantityOnHand, request.UnitCost, request.TotalPaidToman);
|
||||
var entity = new Ingredient
|
||||
{
|
||||
Id = $"ing_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
Name = request.Name.Trim(),
|
||||
Unit = string.IsNullOrWhiteSpace(request.Unit) ? "عدد" : request.Unit.Trim(),
|
||||
QuantityOnHand = request.QuantityOnHand,
|
||||
ReorderLevel = request.ReorderLevel,
|
||||
UnitCost = unitCost,
|
||||
ParLevel = par,
|
||||
LowStockWarningPercent = ClampPercent(request.LowStockWarningPercent)
|
||||
};
|
||||
_db.Ingredients.Add(entity);
|
||||
if (request.QuantityOnHand != 0)
|
||||
{
|
||||
var movement = NewMovement(
|
||||
cafeId,
|
||||
entity.Id,
|
||||
request.QuantityOnHand,
|
||||
request.TotalPaidToman > 0 ? StockMovementKind.Purchase : StockMovementKind.Manual,
|
||||
null,
|
||||
request.TotalPaidToman > 0 ? "خرید اولیه" : "موجودی اولیه",
|
||||
request.TotalPaidToman,
|
||||
request.BranchId);
|
||||
_db.StockMovements.Add(movement);
|
||||
|
||||
if (request.TotalPaidToman > 0 && !string.IsNullOrWhiteSpace(request.BranchId))
|
||||
{
|
||||
var expense = await TryCreatePurchaseExpenseAsync(
|
||||
cafeId,
|
||||
request.BranchId,
|
||||
request.TotalPaidToman.Value,
|
||||
$"خرید انبار: {entity.Name} ({request.QuantityOnHand:N0} {entity.Unit})",
|
||||
userId: null,
|
||||
ct);
|
||||
if (expense is not null)
|
||||
movement.ExpenseId = expense.Id;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<IngredientDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
UpdateIngredientRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) entity.Name = request.Name.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Unit)) entity.Unit = request.Unit.Trim();
|
||||
if (request.ReorderLevel.HasValue) entity.ReorderLevel = request.ReorderLevel.Value;
|
||||
if (request.UnitCost.HasValue) entity.UnitCost = request.UnitCost.Value;
|
||||
if (request.ParLevel.HasValue) entity.ParLevel = request.ParLevel.Value;
|
||||
if (request.LowStockWarningPercent.HasValue)
|
||||
entity.LowStockWarningPercent = ClampPercent(request.LowStockWarningPercent.Value);
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
AdjustStockRequest request,
|
||||
string? userId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Delta > 0)
|
||||
{
|
||||
if (request.TotalPaidToman is null or <= 0)
|
||||
throw new InvalidOperationException("TOTAL_PAID_REQUIRED");
|
||||
if (string.IsNullOrWhiteSpace(request.BranchId))
|
||||
throw new InvalidOperationException("BRANCH_ID_REQUIRED");
|
||||
|
||||
var oldQty = entity.QuantityOnHand;
|
||||
var oldValue = oldQty * entity.UnitCost;
|
||||
entity.QuantityOnHand += request.Delta;
|
||||
entity.UnitCost = entity.QuantityOnHand > 0
|
||||
? (oldValue + request.TotalPaidToman.Value) / entity.QuantityOnHand
|
||||
: request.TotalPaidToman.Value / request.Delta;
|
||||
|
||||
var movement = NewMovement(
|
||||
cafeId,
|
||||
ingredientId,
|
||||
request.Delta,
|
||||
StockMovementKind.Purchase,
|
||||
null,
|
||||
request.Note?.Trim() ?? "خرید / ورود به انبار",
|
||||
request.TotalPaidToman,
|
||||
request.BranchId);
|
||||
_db.StockMovements.Add(movement);
|
||||
|
||||
var expense = await TryCreatePurchaseExpenseAsync(
|
||||
cafeId,
|
||||
request.BranchId!,
|
||||
request.TotalPaidToman.Value,
|
||||
$"خرید انبار: {entity.Name} ({request.Delta:N0} {entity.Unit})",
|
||||
userId,
|
||||
ct);
|
||||
if (expense is not null)
|
||||
movement.ExpenseId = expense.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.QuantityOnHand += request.Delta;
|
||||
_db.StockMovements.Add(NewMovement(
|
||||
cafeId,
|
||||
ingredientId,
|
||||
request.Delta,
|
||||
StockMovementKind.Manual,
|
||||
null,
|
||||
request.Note?.Trim() ?? "تنظیم دستی",
|
||||
null));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (IsLowStock(entity))
|
||||
await NotifyLowStockAsync(cafeId, [entity], ct);
|
||||
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var utcStart = from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var utcEnd = to.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
|
||||
var movements = await _db.StockMovements.AsNoTracking()
|
||||
.Include(m => m.Ingredient)
|
||||
.Where(m => m.CafeId == cafeId
|
||||
&& m.BranchId == branchId
|
||||
&& m.TotalCostToman != null
|
||||
&& m.TotalCostToman > 0
|
||||
&& m.CreatedAt >= utcStart
|
||||
&& m.CreatedAt < utcEnd)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(50)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var total = movements.Sum(m => m.TotalCostToman ?? 0);
|
||||
var recent = movements.Select(m => new InventoryPurchaseDto(
|
||||
m.Id,
|
||||
m.IngredientId,
|
||||
m.Ingredient.Name,
|
||||
m.Delta,
|
||||
m.Ingredient.Unit,
|
||||
m.TotalCostToman ?? 0,
|
||||
m.Ingredient.UnitCost,
|
||||
m.CreatedAt,
|
||||
m.ExpenseId)).ToList();
|
||||
|
||||
return new InventoryPurchasesSummaryDto(total, recent.Count, recent);
|
||||
}
|
||||
|
||||
public async Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default)
|
||||
{
|
||||
var item = await _db.MenuItems.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.Id == menuItemId && m.CafeId == cafeId, ct);
|
||||
if (item is null) return null;
|
||||
|
||||
var lines = await _db.MenuItemIngredients.AsNoTracking()
|
||||
.Include(r => r.Ingredient)
|
||||
.Where(r => r.CafeId == cafeId && r.MenuItemId == menuItemId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return BuildRecipeDto(item, lines);
|
||||
}
|
||||
|
||||
public async Task<MenuItemRecipeDto?> SetRecipeAsync(
|
||||
string cafeId,
|
||||
string menuItemId,
|
||||
SetMenuItemRecipeRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var item = await _db.MenuItems.FirstOrDefaultAsync(m => m.Id == menuItemId && m.CafeId == cafeId, ct);
|
||||
if (item is null) return null;
|
||||
|
||||
var existing = await _db.MenuItemIngredients
|
||||
.Where(r => r.CafeId == cafeId && r.MenuItemId == menuItemId)
|
||||
.ToListAsync(ct);
|
||||
_db.MenuItemIngredients.RemoveRange(existing);
|
||||
|
||||
foreach (var line in request.Lines.Where(l => l.QuantityPerUnit > 0))
|
||||
{
|
||||
var ingOk = await _db.Ingredients.AnyAsync(i => i.Id == line.IngredientId && i.CafeId == cafeId, ct);
|
||||
if (!ingOk) continue;
|
||||
|
||||
_db.MenuItemIngredients.Add(new MenuItemIngredient
|
||||
{
|
||||
Id = $"mii_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
MenuItemId = menuItemId,
|
||||
IngredientId = line.IngredientId,
|
||||
QuantityPerUnit = line.QuantityPerUnit
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await GetRecipeAsync(cafeId, menuItemId, ct);
|
||||
}
|
||||
|
||||
public async Task<OrderDeductionResult> DeductForOrderAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
IReadOnlyList<(string MenuItemId, int Quantity)> lines,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
return new OrderDeductionResult(false, []);
|
||||
|
||||
var menuItemIds = lines.Select(l => l.MenuItemId).Distinct().ToList();
|
||||
var recipes = await _db.MenuItemIngredients
|
||||
.Where(r => r.CafeId == cafeId && menuItemIds.Contains(r.MenuItemId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (recipes.Count == 0)
|
||||
return new OrderDeductionResult(false, []);
|
||||
|
||||
var usage = new Dictionary<string, decimal>(StringComparer.Ordinal);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
foreach (var recipe in recipes.Where(r => r.MenuItemId == line.MenuItemId))
|
||||
{
|
||||
var amount = recipe.QuantityPerUnit * line.Quantity;
|
||||
usage[recipe.IngredientId] = usage.GetValueOrDefault(recipe.IngredientId) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.Count == 0)
|
||||
return new OrderDeductionResult(false, []);
|
||||
|
||||
var ingredientIds = usage.Keys.ToList();
|
||||
var ingredients = await _db.Ingredients
|
||||
.Where(i => i.CafeId == cafeId && ingredientIds.Contains(i.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var ing in ingredients)
|
||||
{
|
||||
if (!usage.TryGetValue(ing.Id, out var deduct)) continue;
|
||||
ing.QuantityOnHand -= deduct;
|
||||
_db.StockMovements.Add(NewMovement(
|
||||
cafeId,
|
||||
ing.Id,
|
||||
-deduct,
|
||||
StockMovementKind.OrderDeduction,
|
||||
orderId,
|
||||
$"سفارش {orderId[..Math.Min(8, orderId.Length)]}",
|
||||
null));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var lowStock = ingredients.Where(IsLowStock).ToList();
|
||||
if (lowStock.Count > 0)
|
||||
await NotifyLowStockAsync(cafeId, lowStock, ct);
|
||||
|
||||
return new OrderDeductionResult(
|
||||
true,
|
||||
lowStock.Select(i => i.Name).ToList());
|
||||
}
|
||||
|
||||
private async Task NotifyLowStockAsync(string cafeId, IReadOnlyList<Ingredient> items, CancellationToken ct)
|
||||
{
|
||||
if (items.Count == 0) return;
|
||||
|
||||
var names = string.Join("، ", items.Select(i => $"{i.Name} ({FormatQty(i)})"));
|
||||
var notification = new CafeNotification
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Type = "inventory_low_stock",
|
||||
Title = "کمبود مواد اولیه",
|
||||
Body = names
|
||||
};
|
||||
_db.CafeNotifications.Add(notification);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var dto = new CafeNotificationDto(
|
||||
notification.Id,
|
||||
notification.Type,
|
||||
notification.Title,
|
||||
notification.Body,
|
||||
notification.ReferenceId,
|
||||
notification.TableNumber,
|
||||
notification.IsRead,
|
||||
notification.CreatedAt);
|
||||
|
||||
await _kdsHub.Clients.Group(KdsHub.GroupName(cafeId))
|
||||
.SendAsync("NotificationReceived", dto, ct);
|
||||
}
|
||||
|
||||
private async Task<Expense?> TryCreatePurchaseExpenseAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
decimal amount,
|
||||
string note,
|
||||
string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var branch = await _db.Branches.FirstOrDefaultAsync(
|
||||
b => b.Id == branchId && b.CafeId == cafeId && b.IsActive,
|
||||
ct);
|
||||
if (branch is null) return null;
|
||||
|
||||
var expense = new Expense
|
||||
{
|
||||
Id = $"exp_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Category = ExpenseCategory.Supplies,
|
||||
Amount = amount,
|
||||
Note = note,
|
||||
CreatedByUserId = userId ?? "system",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.Expenses.Add(expense);
|
||||
return expense;
|
||||
}
|
||||
|
||||
private static decimal ResolveUnitCost(decimal qty, decimal unitCost, decimal? totalPaid)
|
||||
{
|
||||
if (totalPaid is > 0 && qty > 0)
|
||||
return totalPaid.Value / qty;
|
||||
return unitCost;
|
||||
}
|
||||
|
||||
private static StockMovement NewMovement(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
decimal delta,
|
||||
StockMovementKind kind,
|
||||
string? orderId,
|
||||
string? note,
|
||||
decimal? totalCostToman,
|
||||
string? branchId = null) => new()
|
||||
{
|
||||
Id = $"stk_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
IngredientId = ingredientId,
|
||||
BranchId = branchId,
|
||||
Delta = delta,
|
||||
Kind = kind,
|
||||
OrderId = orderId,
|
||||
Note = note,
|
||||
TotalCostToman = totalCostToman > 0 ? totalCostToman : null
|
||||
};
|
||||
|
||||
private static MenuItemRecipeDto BuildRecipeDto(MenuItem item, List<MenuItemIngredient> lines)
|
||||
{
|
||||
var recipeLines = lines.Select(r => new RecipeLineDto(
|
||||
r.Id,
|
||||
r.IngredientId,
|
||||
r.Ingredient.Name,
|
||||
r.Ingredient.Unit,
|
||||
r.QuantityPerUnit)).ToList();
|
||||
|
||||
var cost = lines.Sum(r => r.QuantityPerUnit * r.Ingredient.UnitCost);
|
||||
return new MenuItemRecipeDto(item.Id, item.Name, recipeLines, cost);
|
||||
}
|
||||
|
||||
private static bool IsLowStock(Ingredient i)
|
||||
{
|
||||
var threshold = WarningThreshold(i);
|
||||
return i.QuantityOnHand <= threshold;
|
||||
}
|
||||
|
||||
private static decimal WarningThreshold(Ingredient i)
|
||||
{
|
||||
if (i.ParLevel > 0 && i.LowStockWarningPercent > 0)
|
||||
return i.ParLevel * (i.LowStockWarningPercent / 100m);
|
||||
return i.ReorderLevel;
|
||||
}
|
||||
|
||||
private static IngredientDto ToDto(Ingredient i)
|
||||
{
|
||||
var threshold = WarningThreshold(i);
|
||||
return new IngredientDto(
|
||||
i.Id,
|
||||
i.Name,
|
||||
i.Unit,
|
||||
i.QuantityOnHand,
|
||||
i.ReorderLevel,
|
||||
i.UnitCost,
|
||||
i.ParLevel,
|
||||
i.LowStockWarningPercent,
|
||||
threshold,
|
||||
i.QuantityOnHand * i.UnitCost,
|
||||
i.QuantityOnHand <= threshold);
|
||||
}
|
||||
|
||||
private static decimal ClampPercent(decimal p) => Math.Clamp(p, 1m, 100m);
|
||||
|
||||
private static string FormatQty(Ingredient i) =>
|
||||
$"{i.QuantityOnHand:N0} {i.Unit}";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public static class IranCalendar
|
||||
{
|
||||
private static readonly TimeZoneInfo IranTz = TimeZoneInfo.FindSystemTimeZoneById(
|
||||
OperatingSystem.IsWindows() ? "Iran Standard Time" : "Asia/Tehran");
|
||||
|
||||
public static DateOnly TodayInIran =>
|
||||
DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, IranTz));
|
||||
|
||||
public static DateTime NowInIran =>
|
||||
TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, IranTz);
|
||||
|
||||
public static TimeZoneInfo TimeZone => IranTz;
|
||||
|
||||
public static (DateTime UtcStart, DateTime UtcEndExclusive) GetUtcRangeForIranDay(DateOnly date)
|
||||
{
|
||||
var localStart = date.ToDateTime(TimeOnly.MinValue);
|
||||
var localEnd = date.AddDays(1).ToDateTime(TimeOnly.MinValue);
|
||||
var utcStart = TimeZoneInfo.ConvertTimeToUtc(
|
||||
DateTime.SpecifyKind(localStart, DateTimeKind.Unspecified), IranTz);
|
||||
var utcEnd = TimeZoneInfo.ConvertTimeToUtc(
|
||||
DateTime.SpecifyKind(localEnd, DateTimeKind.Unspecified), IranTz);
|
||||
return (utcStart, utcEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public class JwtTokenService : IJwtTokenService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public JwtTokenService(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public string CreateAccessToken(Employee employee, Cafe cafe)
|
||||
{
|
||||
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
|
||||
var audience = _configuration["Jwt:Audience"] ?? "meezi";
|
||||
var expiryDays = _configuration.GetValue("Jwt:AccessTokenExpiryDays", 7);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, employee.Id),
|
||||
new(MeeziClaimTypes.CafeId, cafe.Id),
|
||||
new(MeeziClaimTypes.Role, employee.Role.ToString()),
|
||||
new(MeeziClaimTypes.PlanTier, cafe.PlanTier.ToString()),
|
||||
new(MeeziClaimTypes.Language, cafe.PreferredLanguage),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(employee.BranchId))
|
||||
claims.Add(new Claim(MeeziClaimTypes.BranchId, employee.BranchId));
|
||||
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer,
|
||||
audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddDays(expiryDays),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa")
|
||||
{
|
||||
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
|
||||
var audience = _configuration["Jwt:Audience"] ?? "meezi";
|
||||
var expiryDays = _configuration.GetValue("Jwt:AccessTokenExpiryDays", 7);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, account.Id),
|
||||
new(MeeziClaimTypes.Role, MeeziRoles.Customer),
|
||||
new(MeeziClaimTypes.Actor, MeeziActorKinds.Consumer),
|
||||
new(MeeziClaimTypes.Phone, account.Phone),
|
||||
new(MeeziClaimTypes.Language, language),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
|
||||
};
|
||||
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer,
|
||||
audience,
|
||||
claims,
|
||||
expires: DateTime.UtcNow.AddDays(expiryDays),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public string CreateRefreshToken() => Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
||||
|
||||
public DateTime GetAccessTokenExpiry()
|
||||
{
|
||||
var expiryDays = _configuration.GetValue("Jwt:AccessTokenExpiryDays", 7);
|
||||
return DateTime.UtcNow.AddDays(expiryDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.API.Hubs;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public class KdsNotifier : IKdsNotifier
|
||||
{
|
||||
private readonly IHubContext<KdsHub> _hubContext;
|
||||
|
||||
public KdsNotifier(IHubContext<KdsHub> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public Task NotifyOrderCreatedAsync(string cafeId, LiveOrderDto order, CancellationToken cancellationToken = default) =>
|
||||
_hubContext.Clients.Group(KdsHub.GroupName(cafeId)).SendAsync("OrderCreated", order, cancellationToken);
|
||||
|
||||
public Task NotifyOrderStatusChangedAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default) =>
|
||||
_hubContext.Clients.Group(KdsHub.GroupName(cafeId)).SendAsync("OrderStatusChanged", new { orderId, status }, cancellationToken);
|
||||
|
||||
public Task NotifyTableStatusChangedAsync(string cafeId, CancellationToken cancellationToken = default) =>
|
||||
_hubContext.Clients.Group(KdsHub.GroupName(cafeId)).SendAsync("TableStatusChanged", null, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Meezi.API.Models.Kitchen;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IKitchenStationService
|
||||
{
|
||||
Task<IReadOnlyList<KitchenStationDto>> ListAsync(string cafeId, CancellationToken ct = default);
|
||||
Task<KitchenStationDto?> CreateAsync(string cafeId, CreateKitchenStationRequest request, CancellationToken ct = default);
|
||||
Task<KitchenStationDto?> UpdateAsync(string cafeId, string id, UpdateKitchenStationRequest request, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class KitchenStationService : IKitchenStationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public KitchenStationService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<KitchenStationDto>> ListAsync(string cafeId, CancellationToken ct = default)
|
||||
{
|
||||
var stations = await _db.KitchenStations
|
||||
.Where(s => s.CafeId == cafeId)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ThenBy(s => s.Name)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.BranchId,
|
||||
s.Name,
|
||||
s.PrinterIp,
|
||||
s.PrinterPort,
|
||||
s.SortOrder,
|
||||
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return stations.Select(s => new KitchenStationDto(
|
||||
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList();
|
||||
}
|
||||
|
||||
public async Task<KitchenStationDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateKitchenStationRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.BranchId))
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == request.BranchId && b.CafeId == cafeId, ct);
|
||||
if (!branchOk) return null;
|
||||
}
|
||||
|
||||
var entity = new KitchenStation
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = request.BranchId,
|
||||
Name = request.Name.Trim(),
|
||||
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
||||
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
_db.KitchenStations.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapAsync(cafeId, entity.Id, ct);
|
||||
}
|
||||
|
||||
public async Task<KitchenStationDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string id,
|
||||
UpdateKitchenStationRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.KitchenStations
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.CafeId == cafeId, ct);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name.Trim();
|
||||
if (request.BranchId is not null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.BranchId))
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == request.BranchId && b.CafeId == cafeId, ct);
|
||||
if (!branchOk) return null;
|
||||
}
|
||||
|
||||
entity.BranchId = string.IsNullOrEmpty(request.BranchId) ? null : request.BranchId;
|
||||
}
|
||||
|
||||
if (request.PrinterIp is not null)
|
||||
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
||||
if (request.PrinterPort.HasValue)
|
||||
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
|
||||
if (request.SortOrder.HasValue)
|
||||
entity.SortOrder = request.SortOrder.Value;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapAsync(cafeId, id, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.KitchenStations
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.CafeId == cafeId, ct);
|
||||
if (entity is null) return false;
|
||||
|
||||
var categories = await _db.MenuCategories
|
||||
.Where(c => c.KitchenStationId == id && c.CafeId == cafeId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var cat in categories)
|
||||
cat.KitchenStationId = null;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<KitchenStationDto?> MapAsync(string cafeId, string id, CancellationToken ct)
|
||||
{
|
||||
var s = await _db.KitchenStations
|
||||
.Where(x => x.Id == id && x.CafeId == cafeId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.BranchId,
|
||||
x.Name,
|
||||
x.PrinterIp,
|
||||
x.PrinterPort,
|
||||
x.SortOrder,
|
||||
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return s is null
|
||||
? null
|
||||
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public sealed record LoyaltyRedeemResult(int PointsUsed, decimal DiscountToman);
|
||||
|
||||
public interface ILoyaltyService
|
||||
{
|
||||
/// <summary>Earn points when an order is fully paid (1 point per 10,000 ت paid).</summary>
|
||||
Task ApplyEarnOnOrderPaidAsync(string cafeId, string? customerId, decimal paidAmount, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Redeem loyalty points before payment (1 point = 100 ت discount).</summary>
|
||||
Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
|
||||
string cafeId,
|
||||
Order order,
|
||||
int pointsRequested,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class LoyaltyService : ILoyaltyService
|
||||
{
|
||||
public const decimal TomanPerPointEarn = 10_000m;
|
||||
public const decimal TomanPerPointRedeem = 100m;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public LoyaltyService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task ApplyEarnOnOrderPaidAsync(
|
||||
string cafeId,
|
||||
string? customerId,
|
||||
decimal paidAmount,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(customerId) || paidAmount <= 0)
|
||||
return;
|
||||
|
||||
var points = (int)Math.Floor(paidAmount / TomanPerPointEarn);
|
||||
if (points <= 0)
|
||||
return;
|
||||
|
||||
var customer = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.Id == customerId && c.CafeId == cafeId, ct);
|
||||
if (customer is null)
|
||||
return;
|
||||
|
||||
customer.LoyaltyPoints += points;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
|
||||
string cafeId,
|
||||
Order order,
|
||||
int pointsRequested,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (pointsRequested <= 0)
|
||||
return (true, new LoyaltyRedeemResult(0, 0), null);
|
||||
|
||||
if (string.IsNullOrEmpty(order.CustomerId))
|
||||
return (false, null, "LOYALTY_NO_CUSTOMER");
|
||||
|
||||
var customer = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.Id == order.CustomerId && c.CafeId == cafeId, ct);
|
||||
if (customer is null)
|
||||
return (false, null, "LOYALTY_NO_CUSTOMER");
|
||||
|
||||
var paidSoFar = order.Payments
|
||||
.Where(p => p.Status == Core.Enums.PaymentStatus.Completed)
|
||||
.Sum(p => p.Amount);
|
||||
var amountDue = Math.Max(0, order.Total - paidSoFar);
|
||||
if (amountDue <= 0)
|
||||
return (false, null, "LOYALTY_NOTHING_DUE");
|
||||
|
||||
var maxByDue = (int)Math.Floor(amountDue / TomanPerPointRedeem);
|
||||
var points = Math.Min(pointsRequested, Math.Min(customer.LoyaltyPoints, maxByDue));
|
||||
if (points <= 0)
|
||||
return (false, null, "LOYALTY_INSUFFICIENT_POINTS");
|
||||
|
||||
var discount = points * TomanPerPointRedeem;
|
||||
customer.LoyaltyPoints -= points;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return (true, new LoyaltyRedeemResult(points, discount), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMediaStorageService
|
||||
{
|
||||
Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MediaStorageService : IMediaStorageService
|
||||
{
|
||||
private static readonly HashSet<string> ImageMime = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/webp"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> VideoMime = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"video/mp4", "video/webm", "video/quicktime"
|
||||
};
|
||||
|
||||
private const long MaxImageBytes = 5 * 1024 * 1024;
|
||||
private const long MaxVideoBytes = 25 * 1024 * 1024;
|
||||
private const long MaxModel3dBytes = 8 * 1024 * 1024;
|
||||
|
||||
private static readonly HashSet<string> Model3dMime = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"model/gltf-binary", "application/octet-stream"
|
||||
};
|
||||
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<MediaStorageService> _logger;
|
||||
|
||||
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
|
||||
{
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "menu_img", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "menu_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "table_img", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "table_vid", VideoMime, MaxVideoBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "logo", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "cover", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "review", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveAsync(cafeId, file, "gallery", ImageMime, MaxImageBytes, cancellationToken);
|
||||
|
||||
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
=> SaveModel3dAsync(cafeId, file, cancellationToken);
|
||||
|
||||
public async Task<string?> SaveMenuModel3dFromBytesAsync(
|
||||
string cafeId,
|
||||
byte[] glbBytes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (glbBytes.Length == 0 || glbBytes.Length > MaxModel3dBytes) return null;
|
||||
|
||||
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||
Directory.CreateDirectory(dir);
|
||||
var savedName = $"menu_3d_ai_{Guid.NewGuid():N}.glb";
|
||||
var path = Path.Combine(dir, savedName);
|
||||
|
||||
await File.WriteAllBytesAsync(path, glbBytes, cancellationToken);
|
||||
_logger.LogInformation("Saved AI 3D model for cafe {CafeId}", cafeId);
|
||||
return $"/uploads/{cafeId}/{savedName}";
|
||||
}
|
||||
|
||||
private async Task<string?> SaveModel3dAsync(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.Length == 0 || file.Length > MaxModel3dBytes) return null;
|
||||
|
||||
var fileName = file.FileName.ToLowerInvariant();
|
||||
var isGlb = fileName.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
|
||||
|| Model3dMime.Contains(file.ContentType);
|
||||
if (!isGlb) return null;
|
||||
|
||||
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||
Directory.CreateDirectory(dir);
|
||||
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
||||
var path = Path.Combine(dir, savedName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
||||
return $"/uploads/{cafeId}/{savedName}";
|
||||
}
|
||||
|
||||
private async Task<string?> SaveAsync(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
string prefix,
|
||||
HashSet<string> allowedMime,
|
||||
long maxBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.Length == 0 || file.Length > maxBytes) return null;
|
||||
if (!allowedMime.Contains(file.ContentType)) return null;
|
||||
|
||||
var ext = file.ContentType.ToLowerInvariant() switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
"image/webp" => ".webp",
|
||||
"video/webm" => ".webm",
|
||||
"video/quicktime" => ".mov",
|
||||
"video/mp4" => ".mp4",
|
||||
_ => ".jpg"
|
||||
};
|
||||
|
||||
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||
Directory.CreateDirectory(dir);
|
||||
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(dir, fileName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
|
||||
return $"/uploads/{cafeId}/{fileName}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Meezi.API.Configuration;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Platform;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record MenuAi3dUsageDto(int Used, int Limit, string Period);
|
||||
|
||||
public record MenuAi3dGenerateResultDto(string Model3dUrl, int Used, int Limit);
|
||||
|
||||
public interface IMenuAi3dGenerationService
|
||||
{
|
||||
Task<MenuAi3dUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
|
||||
Task<(MenuAi3dGenerateResultDto? Data, string? ErrorCode, string? Message)> GenerateFromItemImageAsync(
|
||||
string cafeId,
|
||||
string itemId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MenuAi3dGenerationService : IMenuAi3dGenerationService
|
||||
{
|
||||
private const string FeatureMenu3d = "menu_3d";
|
||||
private const string FeatureMenu3dAi = "menu_3d_ai";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
private readonly IMediaStorageService _media;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPlatformRuntimeConfig _platform;
|
||||
private readonly MenuAi3dOptions _options;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<MenuAi3dGenerationService> _logger;
|
||||
|
||||
public MenuAi3dGenerationService(
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog,
|
||||
IMediaStorageService media,
|
||||
IConnectionMultiplexer redis,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPlatformRuntimeConfig platform,
|
||||
IOptions<MenuAi3dOptions> options,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment env,
|
||||
ILogger<MenuAi3dGenerationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_media = media;
|
||||
_redis = redis;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_platform = platform;
|
||||
_options = options.Value;
|
||||
_configuration = configuration;
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<MenuAi3dUsageDto> GetUsageAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var limit = await ResolveLimitAsync(cafeId, planTier, cancellationToken);
|
||||
var used = await GetUsedCountAsync(cafeId);
|
||||
return new MenuAi3dUsageDto(used, limit, DateTime.UtcNow.ToString("yyyy-MM"));
|
||||
}
|
||||
|
||||
public async Task<(MenuAi3dGenerateResultDto? Data, string? ErrorCode, string? Message)> GenerateFromItemImageAsync(
|
||||
string cafeId,
|
||||
string itemId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3d, cancellationToken))
|
||||
return (null, "PLAN_FEATURE_DISABLED", "3D menu is not included in your plan.");
|
||||
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
||||
return (null, "PLAN_FEATURE_DISABLED", "AI 3D generation requires Business plan or higher.");
|
||||
|
||||
var limit = await ResolveLimitAsync(cafeId, planTier, cancellationToken);
|
||||
if (limit <= 0)
|
||||
return (null, "PLAN_FEATURE_DISABLED", "AI 3D generation is not available on your plan.");
|
||||
|
||||
var used = await GetUsedCountAsync(cafeId);
|
||||
if (used >= limit)
|
||||
return (null, "PLAN_LIMIT_REACHED", "Monthly AI 3D generation limit reached (100).");
|
||||
|
||||
var item = await _db.MenuItems.FirstOrDefaultAsync(
|
||||
i => i.CafeId == cafeId && i.Id == itemId,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
return (null, "NOT_FOUND", "Menu item not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(item.ImageUrl))
|
||||
return (null, "NO_IMAGE", "Upload a product photo before generating a 3D model.");
|
||||
|
||||
var imageUrl = ResolvePublicUrl(item.ImageUrl.Trim());
|
||||
byte[] glbBytes;
|
||||
try
|
||||
{
|
||||
glbBytes = await GenerateGlbBytesAsync(imageUrl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI 3D generation failed for cafe {CafeId} item {ItemId}", cafeId, itemId);
|
||||
return (null, "AI_GENERATION_FAILED", "Could not generate 3D model. Try again later.");
|
||||
}
|
||||
|
||||
var modelUrl = await _media.SaveMenuModel3dFromBytesAsync(cafeId, glbBytes, cancellationToken);
|
||||
if (modelUrl is null)
|
||||
return (null, "INVALID_FILE", "Generated model could not be saved.");
|
||||
|
||||
item.Model3dUrl = modelUrl;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newUsed = await IncrementUsageAsync(cafeId);
|
||||
return (new MenuAi3dGenerateResultDto(modelUrl, newUsed, limit), null, null);
|
||||
}
|
||||
|
||||
private async Task<int> ResolveLimitAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
||||
return 0;
|
||||
return PlanLimits.MaxMenuAi3dPerMonth(planTier);
|
||||
}
|
||||
|
||||
private static string UsageKey(string cafeId) =>
|
||||
$"ai3d:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
|
||||
private async Task<int> GetUsedCountAsync(string cafeId)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var val = await redis.StringGetAsync(UsageKey(cafeId));
|
||||
return val.HasValue && int.TryParse(val.ToString(), out var n) ? n : 0;
|
||||
}
|
||||
|
||||
private async Task<int> IncrementUsageAsync(string cafeId)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var key = UsageKey(cafeId);
|
||||
var next = (int)await redis.StringIncrementAsync(key);
|
||||
if (next == 1)
|
||||
{
|
||||
var endOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
.AddMonths(1);
|
||||
await redis.KeyExpireAsync(key, endOfMonth - DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private string ResolvePublicUrl(string url)
|
||||
{
|
||||
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
var baseUrl = _configuration["App:PublicBaseUrl"]?.TrimEnd('/') ?? "http://localhost:5080";
|
||||
return url.StartsWith('/') ? $"{baseUrl}{url}" : $"{baseUrl}/{url}";
|
||||
}
|
||||
|
||||
private async Task<byte[]> GenerateGlbBytesAsync(string imageUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var apiKey = await ResolveMeshyApiKeyAsync(cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
return await GenerateViaMeshyAsync(imageUrl, apiKey, cancellationToken);
|
||||
|
||||
if (_options.AllowDevStub && _env.IsDevelopment())
|
||||
return DevStubGlbBytes();
|
||||
|
||||
throw new InvalidOperationException("AI 3D provider is not configured.");
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveMeshyApiKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var menu3dOn = await _platform.GetAsync(PlatformIntegrationKeys.MeshyMenu3dEnabled, cancellationToken);
|
||||
if (menu3dOn is "false")
|
||||
return null;
|
||||
|
||||
var enabled = await _platform.GetAsync(PlatformIntegrationKeys.MeshyEnabled, cancellationToken);
|
||||
if (enabled is "false")
|
||||
return null;
|
||||
|
||||
var fromDb = await _platform.GetAsync(PlatformIntegrationKeys.MeshyApiKey, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
||||
return fromDb.Trim();
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.ApiKey) ? null : _options.ApiKey.Trim();
|
||||
}
|
||||
|
||||
private async Task<byte[]> GenerateViaMeshyAsync(
|
||||
string imageUrl,
|
||||
string apiKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("MenuAi3d");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
var createPath = _options.ImageTo3dPath.TrimStart('/');
|
||||
var createBody = JsonSerializer.Serialize(new { image_url = imageUrl }, JsonOpts);
|
||||
using var createReq = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/{createPath}")
|
||||
{
|
||||
Content = new StringContent(createBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
using var createRes = await client.SendAsync(createReq, cancellationToken);
|
||||
createRes.EnsureSuccessStatusCode();
|
||||
await using var createStream = await createRes.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var createDoc = await JsonDocument.ParseAsync(createStream, cancellationToken: cancellationToken);
|
||||
var taskId = createDoc.RootElement.TryGetProperty("result", out var resultEl)
|
||||
? resultEl.GetString()
|
||||
: createDoc.RootElement.TryGetProperty("id", out var idEl)
|
||||
? idEl.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taskId))
|
||||
throw new InvalidOperationException("AI provider did not return a task id.");
|
||||
|
||||
var pollUrl = $"{baseUrl}/{createPath}/{taskId}";
|
||||
var deadline = DateTime.UtcNow.AddSeconds(Math.Max(30, _options.PollTimeoutSeconds));
|
||||
var interval = TimeSpan.FromSeconds(Math.Clamp(_options.PollIntervalSeconds, 2, 30));
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken);
|
||||
using var pollRes = await client.GetAsync(pollUrl, cancellationToken);
|
||||
pollRes.EnsureSuccessStatusCode();
|
||||
await using var pollStream = await pollRes.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var pollDoc = await JsonDocument.ParseAsync(pollStream, cancellationToken: cancellationToken);
|
||||
var status = pollDoc.RootElement.TryGetProperty("status", out var statusEl)
|
||||
? statusEl.GetString()
|
||||
: null;
|
||||
|
||||
if (string.Equals(status, "SUCCEEDED", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, "COMPLETED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var glbUrl = ExtractGlbUrl(pollDoc.RootElement);
|
||||
if (string.IsNullOrWhiteSpace(glbUrl))
|
||||
throw new InvalidOperationException("AI provider succeeded but returned no GLB URL.");
|
||||
|
||||
using var downloadRes = await client.GetAsync(glbUrl, cancellationToken);
|
||||
downloadRes.EnsureSuccessStatusCode();
|
||||
return await downloadRes.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (string.Equals(status, "FAILED", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, "CANCELED", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("AI provider task failed.");
|
||||
}
|
||||
|
||||
throw new TimeoutException("AI 3D generation timed out.");
|
||||
}
|
||||
|
||||
private static string? ExtractGlbUrl(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("model_urls", out var urls)
|
||||
&& urls.TryGetProperty("glb", out var glb)
|
||||
&& glb.ValueKind == JsonValueKind.String)
|
||||
return glb.GetString();
|
||||
|
||||
if (root.TryGetProperty("model_url", out var single) && single.ValueKind == JsonValueKind.String)
|
||||
return single.GetString();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Minimal valid GLB (empty scene) for local development without Meshy API key.</summary>
|
||||
private static byte[] DevStubGlbBytes() =>
|
||||
Convert.FromBase64String(
|
||||
"Z2xURgIAAACI3gAQAwEAAFBLQVRGT1JNUwBCeHAEAgAqBUZsAE1BVEhQAgAgAAAAAO4AAABKQwAAAAAAAJAAAAA=");
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMenuService
|
||||
{
|
||||
Task<IReadOnlyList<MenuCategoryDto>> GetCategoriesAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<MenuCategoryDto?> CreateCategoryAsync(string cafeId, CreateMenuCategoryRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuCategoryDto?> UpdateCategoryAsync(string cafeId, string id, UpdateMenuCategoryRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteCategoryAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<MenuItemDto>> GetItemsAsync(string cafeId, string? categoryId, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MenuService : IMenuService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public MenuService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MenuCategoryDto>> GetCategoriesAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _db.MenuCategories
|
||||
.Where(c => c.CafeId == cafeId)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => ToCategoryDto(c))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MenuCategoryDto?> CreateCategoryAsync(string cafeId, CreateMenuCategoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = new MenuCategory
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Name = request.Name,
|
||||
NameAr = request.NameAr,
|
||||
NameEn = request.NameEn,
|
||||
SortOrder = request.SortOrder,
|
||||
TaxId = request.TaxId,
|
||||
DiscountPercent = request.DiscountPercent,
|
||||
Icon = NormalizeOptionalText(request.Icon),
|
||||
IconPresetId = NormalizeIconPreset(request.IconPresetId),
|
||||
IconStyle = NormalizeIconStyle(request.IconStyle)
|
||||
?? (NormalizeIconPreset(request.IconPresetId) is not null
|
||||
? CategoryIconPresets.IconStyle.Flat
|
||||
: null),
|
||||
ImageUrl = NormalizeOptionalText(request.ImageUrl),
|
||||
IsActive = request.IsActive,
|
||||
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId)
|
||||
? null
|
||||
: request.KitchenStationId
|
||||
};
|
||||
|
||||
_db.MenuCategories.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToCategoryDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuCategoryDto?> UpdateCategoryAsync(string cafeId, string id, UpdateMenuCategoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuCategories.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.NameAr is not null) entity.NameAr = request.NameAr;
|
||||
if (request.NameEn is not null) entity.NameEn = request.NameEn;
|
||||
if (request.SortOrder.HasValue) entity.SortOrder = request.SortOrder.Value;
|
||||
if (request.TaxId is not null) entity.TaxId = request.TaxId;
|
||||
if (request.DiscountPercent.HasValue) entity.DiscountPercent = request.DiscountPercent.Value;
|
||||
if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value;
|
||||
if (request.Icon is not null)
|
||||
entity.Icon = NormalizeOptionalText(request.Icon);
|
||||
if (request.IconPresetId is not null)
|
||||
entity.IconPresetId = NormalizeIconPreset(request.IconPresetId);
|
||||
if (request.IconStyle is not null)
|
||||
entity.IconStyle = NormalizeIconStyle(request.IconStyle);
|
||||
if (request.ImageUrl is not null)
|
||||
entity.ImageUrl = NormalizeOptionalText(request.ImageUrl);
|
||||
if (request.KitchenStationId is not null)
|
||||
entity.KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId)
|
||||
? null
|
||||
: request.KitchenStationId;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToCategoryDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCategoryAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuCategories.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MenuItemDto>> GetItemsAsync(string cafeId, string? categoryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.MenuItems.Where(i => i.CafeId == cafeId);
|
||||
if (!string.IsNullOrEmpty(categoryId))
|
||||
query = query.Where(i => i.CategoryId == categoryId);
|
||||
|
||||
var items = await query.Include(i => i.Category).OrderBy(i => i.Name).ToListAsync(cancellationToken);
|
||||
return items.Select(ToItemDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var category = await _db.MenuCategories
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.CafeId == cafeId, cancellationToken);
|
||||
if (category is null) return null;
|
||||
|
||||
var imageUrl = string.IsNullOrWhiteSpace(request.ImageUrl)
|
||||
? MenuItemImageDefaults.GetDefaultImageUrl(
|
||||
MenuItemImageDefaults.InferKind(request.CategoryId, category.Name))
|
||||
: request.ImageUrl;
|
||||
|
||||
var entity = new MenuItem
|
||||
{
|
||||
CafeId = cafeId,
|
||||
CategoryId = request.CategoryId,
|
||||
Name = request.Name,
|
||||
NameAr = request.NameAr,
|
||||
NameEn = request.NameEn,
|
||||
Description = request.Description,
|
||||
Price = request.Price,
|
||||
DiscountPercent = request.DiscountPercent,
|
||||
ImageUrl = imageUrl,
|
||||
VideoUrl = request.VideoUrl,
|
||||
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
|
||||
IsAvailable = request.IsAvailable
|
||||
};
|
||||
|
||||
_db.MenuItems.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems
|
||||
.Include(i => i.Category)
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.CategoryId is not null)
|
||||
{
|
||||
var categoryExists = await _db.MenuCategories.AnyAsync(c => c.Id == request.CategoryId && c.CafeId == cafeId, cancellationToken);
|
||||
if (!categoryExists) return null;
|
||||
entity.CategoryId = request.CategoryId;
|
||||
}
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.NameAr is not null) entity.NameAr = request.NameAr;
|
||||
if (request.NameEn is not null) entity.NameEn = request.NameEn;
|
||||
if (request.Description is not null) entity.Description = request.Description;
|
||||
if (request.Price.HasValue) entity.Price = request.Price.Value;
|
||||
if (request.DiscountPercent.HasValue) entity.DiscountPercent = request.DiscountPercent.Value;
|
||||
if (request.ImageUrl is not null)
|
||||
{
|
||||
entity.ImageUrl = string.IsNullOrWhiteSpace(request.ImageUrl)
|
||||
? MenuItemImageDefaults.ResolveImageUrl(entity.Id, entity.CategoryId, entity.Category?.Name)
|
||||
: request.ImageUrl;
|
||||
}
|
||||
if (request.VideoUrl is not null)
|
||||
entity.VideoUrl = string.IsNullOrWhiteSpace(request.VideoUrl) ? null : request.VideoUrl;
|
||||
if (request.Model3dUrl is not null)
|
||||
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
|
||||
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.IsAvailable = isAvailable;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? NormalizeIconPreset(string? value)
|
||||
{
|
||||
var normalized = CategoryIconPresets.NormalizePreset(value);
|
||||
if (normalized is null) return null;
|
||||
return CategoryIconPresets.IsValidPreset(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeIconStyle(string? value)
|
||||
{
|
||||
var normalized = CategoryIconPresets.NormalizeStyle(value);
|
||||
if (normalized is null) return null;
|
||||
return CategoryIconPresets.IsValidStyle(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
private static MenuCategoryDto ToCategoryDto(MenuCategory c) => new(
|
||||
c.Id, c.Name, c.NameAr, c.NameEn, c.SortOrder, c.TaxId, c.DiscountPercent,
|
||||
c.Icon, c.IconPresetId, c.IconStyle, c.ImageUrl, c.IsActive, c.KitchenStationId);
|
||||
|
||||
private static MenuItemDto ToItemDto(MenuItem i) => new(
|
||||
i.Id,
|
||||
i.CategoryId,
|
||||
i.Name,
|
||||
i.NameAr,
|
||||
i.NameEn,
|
||||
i.Description,
|
||||
i.Price,
|
||||
i.DiscountPercent,
|
||||
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||||
i.VideoUrl,
|
||||
i.Model3dUrl,
|
||||
i.IsAvailable);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Meezi.API.Models.Notifications;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface INotificationInboxService
|
||||
{
|
||||
Task<NotificationListDto> ListAsync(
|
||||
string cafeId,
|
||||
bool unreadOnly,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<int> GetUnreadCountAsync(string cafeId, CancellationToken ct = default);
|
||||
|
||||
Task MarkReadAsync(string cafeId, MarkNotificationsReadRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class NotificationInboxService : INotificationInboxService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public NotificationInboxService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<NotificationListDto> ListAsync(
|
||||
string cafeId,
|
||||
bool unreadOnly,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
var q = _db.CafeNotifications.AsNoTracking().Where(n => n.CafeId == cafeId);
|
||||
if (unreadOnly)
|
||||
q = q.Where(n => !n.IsRead);
|
||||
|
||||
var unread = await q.CountAsync(n => !n.IsRead, ct);
|
||||
var items = await q
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(n => new CafeNotificationDto(
|
||||
n.Id,
|
||||
n.Type,
|
||||
n.Title,
|
||||
n.Body,
|
||||
n.ReferenceId,
|
||||
n.TableNumber,
|
||||
n.IsRead,
|
||||
n.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new NotificationListDto(items, unread);
|
||||
}
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string cafeId, CancellationToken ct = default) =>
|
||||
_db.CafeNotifications.CountAsync(n => n.CafeId == cafeId && !n.IsRead, ct);
|
||||
|
||||
public async Task MarkReadAsync(
|
||||
string cafeId,
|
||||
MarkNotificationsReadRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.CafeNotifications.Where(n => n.CafeId == cafeId && !n.IsRead);
|
||||
if (!request.All && request.Ids is { Count: > 0 })
|
||||
q = q.Where(n => request.Ids.Contains(n.Id));
|
||||
|
||||
var rows = await q.ToListAsync(ct);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.IsRead = true;
|
||||
row.ReadAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Platform;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IOpenAiChatService
|
||||
{
|
||||
Task<bool> IsConfiguredForCoffeeAdvisorAsync(CancellationToken cancellationToken = default);
|
||||
Task<string?> CompleteJsonAsync(string systemPrompt, string userPrompt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class OpenAiChatService : IOpenAiChatService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPlatformRuntimeConfig _platform;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<OpenAiChatService> _logger;
|
||||
|
||||
public OpenAiChatService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPlatformRuntimeConfig platform,
|
||||
IConfiguration configuration,
|
||||
ILogger<OpenAiChatService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_platform = platform;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> IsConfiguredForCoffeeAdvisorAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await IsCoffeeAdvisorEnabledAsync(cancellationToken))
|
||||
return false;
|
||||
return !string.IsNullOrWhiteSpace(await GetApiKeyAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<string?> CompleteJsonAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await GetApiKeyAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
return null;
|
||||
|
||||
var model = await GetModelAsync(cancellationToken);
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
model,
|
||||
temperature = 0.4,
|
||||
response_format = new { type = "json_object" },
|
||||
messages = new object[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt }
|
||||
}
|
||||
}, JsonOpts);
|
||||
|
||||
var client = _httpClientFactory.CreateClient("OpenAi");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
using var res = await client.SendAsync(req, cancellationToken);
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("OpenAI chat failed with status {Status}", res.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString();
|
||||
}
|
||||
|
||||
private async Task<bool> IsCoffeeAdvisorEnabledAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var enabled = await _platform.GetAsync(PlatformIntegrationKeys.OpenAiEnabled, cancellationToken);
|
||||
if (enabled is "false")
|
||||
return false;
|
||||
var feature = await _platform.GetAsync(PlatformIntegrationKeys.OpenAiCoffeeAdvisorEnabled, cancellationToken);
|
||||
return feature is not "false";
|
||||
}
|
||||
|
||||
private async Task<string?> GetApiKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var fromDb = await _platform.GetAsync(PlatformIntegrationKeys.OpenAiApiKey, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
||||
return fromDb.Trim();
|
||||
return _configuration["OpenAI:ApiKey"]?.Trim();
|
||||
}
|
||||
|
||||
private async Task<string> GetModelAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var fromDb = await _platform.GetAsync(PlatformIntegrationKeys.OpenAiModel, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
||||
return fromDb.Trim();
|
||||
return _configuration["OpenAI:Model"]?.Trim() ?? "gpt-4o-mini";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Hubs;
|
||||
using Meezi.API.Models.Notifications;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public class OrderNotificationService : IOrderNotificationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHubContext<KdsHub> _kdsHub;
|
||||
private readonly IHubContext<GuestOrderHub> _guestHub;
|
||||
private readonly ISmsService _sms;
|
||||
private readonly ILogger<OrderNotificationService> _logger;
|
||||
|
||||
public OrderNotificationService(
|
||||
AppDbContext db,
|
||||
IHubContext<KdsHub> kdsHub,
|
||||
IHubContext<GuestOrderHub> guestHub,
|
||||
ISmsService sms,
|
||||
ILogger<OrderNotificationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kdsHub = kdsHub;
|
||||
_guestHub = guestHub;
|
||||
_sms = sms;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task NotifyGuestOrderPlacedAsync(Order order, LiveOrderDto live, CancellationToken ct = default)
|
||||
{
|
||||
if (order.Source != OrderSource.GuestQr)
|
||||
return;
|
||||
|
||||
var tableNumber = await ResolveTableNumberAsync(order, ct);
|
||||
var orderNumber = ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber > 0 ? order.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(order.Id));
|
||||
var notification = new CafeNotification
|
||||
{
|
||||
CafeId = order.CafeId,
|
||||
Type = "guest_order_new",
|
||||
Title = $"سفارش جدید میز {tableNumber ?? "—"}",
|
||||
Body = $"شماره {orderNumber} · {order.Total:N0} ت",
|
||||
ReferenceId = order.Id,
|
||||
TableNumber = tableNumber
|
||||
};
|
||||
_db.CafeNotifications.Add(notification);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var dto = MapNotification(notification);
|
||||
await _kdsHub.Clients.Group(KdsHub.GroupName(order.CafeId))
|
||||
.SendAsync("NotificationReceived", dto, ct);
|
||||
|
||||
await PushGuestTrackAsync(order, ct);
|
||||
}
|
||||
|
||||
public async Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default)
|
||||
{
|
||||
await PushGuestTrackAsync(order, ct);
|
||||
|
||||
if (order.Source != OrderSource.GuestQr)
|
||||
return;
|
||||
|
||||
if (order.Status == OrderStatus.Ready)
|
||||
{
|
||||
var tableNumber = await ResolveTableNumberAsync(order, ct);
|
||||
var orderNumber = ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber > 0 ? order.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(order.Id));
|
||||
var readyNotification = new CafeNotification
|
||||
{
|
||||
CafeId = order.CafeId,
|
||||
Type = "guest_order_ready",
|
||||
Title = $"سفارش آماده — میز {tableNumber ?? "—"}",
|
||||
Body = $"شماره {orderNumber}",
|
||||
ReferenceId = order.Id,
|
||||
TableNumber = tableNumber
|
||||
};
|
||||
_db.CafeNotifications.Add(readyNotification);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _kdsHub.Clients.Group(KdsHub.GroupName(order.CafeId))
|
||||
.SendAsync("NotificationReceived", MapNotification(readyNotification), ct);
|
||||
|
||||
await TrySmsGuestReadyAsync(order, tableNumber, orderNumber, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushGuestTrackAsync(Order order, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(order.GuestTrackingToken))
|
||||
return;
|
||||
|
||||
var full = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Table)
|
||||
.FirstOrDefaultAsync(o => o.Id == order.Id, cancellationToken);
|
||||
if (full is null) return;
|
||||
|
||||
var track = OrderTrackingHelper.BuildTrackDto(full);
|
||||
await _guestHub.Clients.Group(GuestOrderHub.OrderGroup(order.Id))
|
||||
.SendAsync("OrderTrackUpdated", track, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TrySmsGuestReadyAsync(
|
||||
Order order,
|
||||
string? tableNumber,
|
||||
string orderNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var phone = order.GuestPhone?.Trim();
|
||||
if (string.IsNullOrEmpty(phone))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var msg = $"میزی: سفارش {orderNumber} (میز {tableNumber ?? "—"}) آماده است.";
|
||||
await _sms.SendMessageAsync(phone, msg, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Guest ready SMS failed for order {OrderId}", order.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveTableNumberAsync(Order order, CancellationToken cancellationToken)
|
||||
{
|
||||
if (order.Table is not null)
|
||||
return order.Table.Number;
|
||||
if (string.IsNullOrEmpty(order.TableId))
|
||||
return null;
|
||||
return await _db.Tables
|
||||
.Where(t => t.Id == order.TableId)
|
||||
.Select(t => t.Number)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default)
|
||||
{
|
||||
var notification = new CafeNotification
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Type = "table_call_waiter",
|
||||
Title = $"درخواست گارسون — میز {tableNumber}",
|
||||
Body = "مشتری درخواست خدمت کرد",
|
||||
ReferenceId = tableId,
|
||||
TableNumber = tableNumber
|
||||
};
|
||||
_db.CafeNotifications.Add(notification);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _kdsHub.Clients.Group(KdsHub.GroupName(cafeId))
|
||||
.SendAsync("NotificationReceived", MapNotification(notification), ct);
|
||||
}
|
||||
|
||||
private static CafeNotificationDto MapNotification(CafeNotification n) =>
|
||||
new(n.Id, n.Type, n.Title, n.Body, n.ReferenceId, n.TableNumber, n.IsRead, n.CreatedAt);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.API.Services.Printing;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public static class OrderTrackingHelper
|
||||
{
|
||||
private static readonly (string Key, int Rank)[] Flow =
|
||||
[
|
||||
("submitted", 0),
|
||||
("seen", 1),
|
||||
("preparing", 2),
|
||||
("ready", 3),
|
||||
("done", 4)
|
||||
];
|
||||
|
||||
public static string NewTrackingToken() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public static OrderTrackDto BuildTrackDto(Order order)
|
||||
{
|
||||
var steps = BuildSteps(order.Status);
|
||||
return new OrderTrackDto(
|
||||
order.Id,
|
||||
ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber > 0 ? order.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(order.Id)),
|
||||
order.Status,
|
||||
StatusLabel(order.Status),
|
||||
order.Total,
|
||||
order.Table?.Number,
|
||||
order.CreatedAt,
|
||||
order.StatusUpdatedAt,
|
||||
order.GuestTrackingToken ?? string.Empty,
|
||||
steps,
|
||||
order.Items
|
||||
.Where(i => !i.IsVoided)
|
||||
.Select(i => new OrderItemDto(
|
||||
i.Id,
|
||||
i.MenuItemId,
|
||||
i.MenuItem?.Name ?? "",
|
||||
i.Quantity,
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public static IReadOnlyList<OrderTrackingStepDto> BuildSteps(OrderStatus status)
|
||||
{
|
||||
if (status == OrderStatus.Cancelled)
|
||||
{
|
||||
return
|
||||
[
|
||||
new OrderTrackingStepDto("submitted", "submitted", true, false),
|
||||
new OrderTrackingStepDto("cancelled", "cancelled", true, true)
|
||||
];
|
||||
}
|
||||
|
||||
var rank = StatusRank(status);
|
||||
return Flow.Select(step =>
|
||||
{
|
||||
var isComplete = rank > step.Rank || status == OrderStatus.Delivered;
|
||||
var isCurrent = rank == step.Rank && status is not OrderStatus.Delivered;
|
||||
return new OrderTrackingStepDto(step.Key, step.Key, isComplete, isCurrent);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static int StatusRank(OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Pending => 0,
|
||||
OrderStatus.Confirmed => 1,
|
||||
OrderStatus.Preparing => 2,
|
||||
OrderStatus.Ready => 3,
|
||||
OrderStatus.Delivered => 4,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
public static string StatusLabel(OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Pending => "pending",
|
||||
OrderStatus.Confirmed => "seen",
|
||||
OrderStatus.Preparing => "preparing",
|
||||
OrderStatus.Ready => "ready",
|
||||
OrderStatus.Delivered => "done",
|
||||
OrderStatus.Cancelled => "cancelled",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IPlanLimitChecker
|
||||
{
|
||||
Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAsync(
|
||||
HttpContext context,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PlanLimitChecker : IPlanLimitChecker
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IPlatformCatalogService _platformCatalog;
|
||||
|
||||
public PlanLimitChecker(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
IPlatformCatalogService platformCatalog)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_platformCatalog = platformCatalog;
|
||||
}
|
||||
|
||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAsync(
|
||||
HttpContext context,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenant.IsSystemAdmin || !tenant.IsAuthenticated || tenant.PlanTier is null || string.IsNullOrEmpty(tenant.CafeId))
|
||||
return (true, null, null);
|
||||
|
||||
var method = context.Request.Method;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
if (method != HttpMethods.Post)
|
||||
return (true, null, null);
|
||||
|
||||
var cafeId = tenant.CafeId;
|
||||
var tier = tenant.PlanTier.Value;
|
||||
|
||||
var ordersPath = $"/api/cafes/{cafeId}/orders";
|
||||
if (method == HttpMethods.Post &&
|
||||
path.StartsWith(ordersPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(ordersPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{ordersPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limits = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxOrders = limits.MaxOrdersPerDay;
|
||||
if (maxOrders == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var count = await _db.Orders
|
||||
.CountAsync(o => o.CafeId == cafeId && o.CreatedAt >= todayStart, cancellationToken);
|
||||
|
||||
if (count >= maxOrders)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Daily order limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var customersPath = $"/api/cafes/{cafeId}/customers";
|
||||
if (path.StartsWith(customersPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(customersPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{customersPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limitsCustomers = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxCustomers = limitsCustomers.MaxCustomers;
|
||||
if (maxCustomers == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var count = await _db.Customers
|
||||
.CountAsync(c => c.CafeId == cafeId, cancellationToken);
|
||||
|
||||
if (count >= maxCustomers)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Customer limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var branchesPath = $"/api/cafes/{cafeId}/branches";
|
||||
if (path.StartsWith(branchesPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(branchesPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{branchesPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limitsBranches = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxBranches = limitsBranches.MaxBranches;
|
||||
if (maxBranches == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var branchCount = await _db.Branches.CountAsync(b => b.CafeId == cafeId, cancellationToken);
|
||||
if (branchCount >= maxBranches)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxSms = limitsSms.MaxSmsPerMonth;
|
||||
if (maxSms == 0)
|
||||
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
|
||||
|
||||
if (maxSms == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var used = await redis.StringGetAsync(monthKey);
|
||||
var usedCount = used.HasValue ? (int)used : 0;
|
||||
|
||||
if (usedCount >= maxSms)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
return (true, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record PosDeviceResult(bool Success, bool Skipped, string? ErrorCode, string? Detail = null)
|
||||
{
|
||||
public static PosDeviceResult Ok() => new(true, false, null);
|
||||
public static PosDeviceResult SkippedNotConfigured() => new(true, true, null);
|
||||
public static PosDeviceResult Fail(string code, string? detail = null) => new(false, false, code, detail);
|
||||
}
|
||||
|
||||
public interface IPosDeviceService
|
||||
{
|
||||
Task<PosDeviceResult> SendPaymentRequestAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PosPaymentRequest request,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class PosDeviceService : IPosDeviceService
|
||||
{
|
||||
private const int DefaultPort = 8088;
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(90);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<PosDeviceService> _logger;
|
||||
|
||||
public PosDeviceService(
|
||||
AppDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<PosDeviceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PosDeviceResult> SendPaymentRequestAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PosPaymentRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request.Amount <= 0)
|
||||
return PosDeviceResult.Fail("INVALID_AMOUNT");
|
||||
|
||||
var branch = await _db.Branches
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
|
||||
if (branch is null)
|
||||
return PosDeviceResult.Fail("BRANCH_NOT_FOUND");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(branch.PosDeviceIp))
|
||||
return PosDeviceResult.SkippedNotConfigured();
|
||||
|
||||
var port = branch.PosDevicePort is > 0 and <= 65535
|
||||
? branch.PosDevicePort.Value
|
||||
: DefaultPort;
|
||||
|
||||
var order = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId && o.CafeId == cafeId, ct);
|
||||
|
||||
if (order is null)
|
||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
||||
orderId = request.OrderId,
|
||||
branchId,
|
||||
};
|
||||
|
||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(nameof(PosDeviceService));
|
||||
client.Timeout = RequestTimeout;
|
||||
|
||||
using var response = await client.PostAsJsonAsync(url, payload, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogWarning(
|
||||
"POS device returned {Status} for {Url}: {Body}",
|
||||
(int)response.StatusCode,
|
||||
url,
|
||||
body.Length > 200 ? body[..200] : body);
|
||||
return PosDeviceResult.Fail(
|
||||
"POS_DEVICE_REJECTED",
|
||||
$"HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
return PosDeviceResult.Ok();
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return PosDeviceResult.Fail("POS_DEVICE_TIMEOUT");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "POS device connection failed for {Url}", url);
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "POS device response invalid for {Url}", url);
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public class EscPosBuilder
|
||||
{
|
||||
private readonly List<byte> _buffer = [];
|
||||
|
||||
public EscPosBuilder Initialize()
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x40]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder SetEncoding()
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x74, 0x25]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder AlignCenter()
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x61, 0x01]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder AlignRight()
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x61, 0x02]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder AlignLeft()
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x61, 0x00]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder Bold(bool on)
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x45, on ? (byte)1 : (byte)0]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder DoubleHeight(bool on)
|
||||
{
|
||||
_buffer.AddRange([0x1B, 0x21, on ? (byte)0x10 : (byte)0x00]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder Text(string text)
|
||||
{
|
||||
_buffer.AddRange(Encoding.UTF8.GetBytes(text));
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder Line(string text = "")
|
||||
{
|
||||
return Text(text + "\n");
|
||||
}
|
||||
|
||||
public EscPosBuilder Separator(int width = 48, char ch = '-')
|
||||
{
|
||||
return Line(new string(ch, Math.Min(width, 64)));
|
||||
}
|
||||
|
||||
public EscPosBuilder TwoColumns(string left, string right, int totalWidth = 48)
|
||||
{
|
||||
var safeLeft = left.Length > totalWidth ? left[..totalWidth] : left;
|
||||
var safeRight = right.Length > totalWidth ? right[^totalWidth..] : right;
|
||||
var pad = Math.Max(1, totalWidth - safeLeft.Length - safeRight.Length);
|
||||
var line = safeLeft + new string(' ', pad) + safeRight;
|
||||
if (line.Length > totalWidth)
|
||||
line = line[..totalWidth];
|
||||
return Line(line);
|
||||
}
|
||||
|
||||
public EscPosBuilder Feed(int lines = 3)
|
||||
{
|
||||
for (var i = 0; i < lines; i++)
|
||||
_buffer.Add(0x0A);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EscPosBuilder Cut()
|
||||
{
|
||||
_buffer.AddRange([0x1D, 0x56, 0x42, 0x03]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public byte[] Build() => [.. _buffer];
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Net.Sockets;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public record PrintResult(bool Success, string? ErrorCode, string? ErrorDetail = null)
|
||||
{
|
||||
public static PrintResult Ok() => new(true, null);
|
||||
public static PrintResult Fail(string code, string? detail = null) => new(false, code, detail);
|
||||
}
|
||||
|
||||
public interface IPrinterService
|
||||
{
|
||||
Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default);
|
||||
Task<PrintResult> PrintKitchenTicketAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CancellationToken ct = default);
|
||||
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class NetworkPrinterService : IPrinterService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOrderService _orders;
|
||||
private readonly ReceiptBuilder _receiptBuilder;
|
||||
private readonly ILogger<NetworkPrinterService> _logger;
|
||||
|
||||
public NetworkPrinterService(
|
||||
AppDbContext db,
|
||||
IOrderService orders,
|
||||
ReceiptBuilder receiptBuilder,
|
||||
ILogger<NetworkPrinterService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
_receiptBuilder = receiptBuilder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default)
|
||||
{
|
||||
var ctx = await BuildContextAsync(cafeId, orderId, ct);
|
||||
if (ctx is null)
|
||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
|
||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||
return await SendToPrinterAsync(
|
||||
ctx.Value.branch.ReceiptPrinterIp!,
|
||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
||||
bytes,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<PrintResult> PrintKitchenTicketAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var ctx = await BuildContextAsync(cafeId, orderId, ct);
|
||||
if (ctx is null)
|
||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var order = ctx.Value.printCtx.Order;
|
||||
var activeItems = order.Items.Where(i => !i.IsVoided).ToList();
|
||||
if (activeItems.Count == 0)
|
||||
return PrintResult.Ok();
|
||||
|
||||
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
|
||||
var categoryStations = await (
|
||||
from m in _db.MenuItems.AsNoTracking()
|
||||
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
|
||||
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
|
||||
select new { m.Id, c.KitchenStationId }
|
||||
).ToListAsync(ct);
|
||||
|
||||
var stationIds = categoryStations
|
||||
.Select(x => x.KitchenStationId)
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var stations = stationIds.Count == 0
|
||||
? []
|
||||
: await _db.KitchenStations
|
||||
.AsNoTracking()
|
||||
.Where(s => stationIds.Contains(s.Id) && s.CafeId == cafeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var groups = activeItems
|
||||
.GroupBy(item =>
|
||||
{
|
||||
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
||||
return cat?.KitchenStationId;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
PrintResult? lastFail = null;
|
||||
var anyPrinted = false;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var station = string.IsNullOrEmpty(group.Key)
|
||||
? null
|
||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||
|
||||
string? ip;
|
||||
int port;
|
||||
string? stationLabel = null;
|
||||
|
||||
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
|
||||
{
|
||||
ip = station.PrinterIp;
|
||||
port = station.PrinterPort;
|
||||
stationLabel = station.Name;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||
{
|
||||
ip = ctx.Value.branch.KitchenPrinterIp;
|
||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastFail = PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
|
||||
continue;
|
||||
}
|
||||
|
||||
var itemsOnly = group.ToList();
|
||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||
ctx.Value.printCtx with { StationName = stationLabel },
|
||||
itemsOnly);
|
||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
||||
if (result.Success)
|
||||
anyPrinted = true;
|
||||
else
|
||||
lastFail = result;
|
||||
}
|
||||
|
||||
return anyPrinted ? PrintResult.Ok() : lastFail ?? PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
public async Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerIp))
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
|
||||
var bytes = _receiptBuilder.BuildTestPage();
|
||||
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
||||
}
|
||||
|
||||
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var order = await _orders.GetOrderAsync(cafeId, orderId, ct);
|
||||
if (order is null || string.IsNullOrEmpty(order.BranchId))
|
||||
return null;
|
||||
|
||||
var branch = await _db.Branches
|
||||
.AsNoTracking()
|
||||
.Include(b => b.Cafe)
|
||||
.FirstOrDefaultAsync(b => b.Id == order.BranchId && b.CafeId == cafeId, ct);
|
||||
|
||||
if (branch is null)
|
||||
return null;
|
||||
|
||||
var print = new ReceiptPrintContext(
|
||||
order,
|
||||
branch.Cafe.Name,
|
||||
branch.Name,
|
||||
branch.ReceiptHeader,
|
||||
branch.ReceiptFooter,
|
||||
branch.WifiPassword,
|
||||
branch.PaperWidthMm is 58 or 80 ? branch.PaperWidthMm : 80,
|
||||
branch.AutoCutEnabled);
|
||||
|
||||
return (branch, printCtx: print);
|
||||
}
|
||||
|
||||
private async Task<PrintResult> SendToPrinterAsync(
|
||||
string ip,
|
||||
int port,
|
||||
byte[] data,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
await client.ConnectAsync(ip, port, timeout.Token);
|
||||
await using var stream = client.GetStream();
|
||||
await stream.WriteAsync(data, timeout.Token);
|
||||
await stream.FlushAsync(timeout.Token);
|
||||
|
||||
_logger.LogInformation("Printed {Bytes} bytes to {Ip}:{Port}", data.Length, ip, port);
|
||||
return PrintResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Print failed to {Ip}:{Port}", ip, port);
|
||||
return PrintResult.Fail("PRINTER_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class PrinterBackgroundJobs
|
||||
{
|
||||
public static void QueueReceiptPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
|
||||
try
|
||||
{
|
||||
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
|
||||
var result = await printer.PrintReceiptAsync(cafeId, orderId, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
logger.LogWarning("Auto-print receipt failed for {OrderId}: {Code}", orderId, result.ErrorCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Auto-print receipt failed for order {OrderId}", orderId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void QueueKitchenPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
|
||||
try
|
||||
{
|
||||
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
|
||||
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Kitchen print failed for order {OrderId}", orderId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public class ReceiptBuilder
|
||||
{
|
||||
public byte[] BuildReceipt(ReceiptPrintContext ctx)
|
||||
{
|
||||
var order = ctx.Order;
|
||||
var width = ReceiptPrintFormatting.LineWidth(ctx.PaperWidthMm);
|
||||
var b = new EscPosBuilder();
|
||||
|
||||
b.Initialize().SetEncoding();
|
||||
|
||||
b.AlignCenter()
|
||||
.Bold(true)
|
||||
.DoubleHeight(true)
|
||||
.Line(ctx.CafeName)
|
||||
.DoubleHeight(false)
|
||||
.Bold(false)
|
||||
.Line(ctx.BranchName);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ReceiptHeader))
|
||||
b.Line(ctx.ReceiptHeader.Trim());
|
||||
|
||||
var orderNo = ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber);
|
||||
b.AlignRight()
|
||||
.Line($"شماره سفارش: {orderNo}")
|
||||
.Line($"تاریخ: {ReceiptPrintFormatting.ToShamsi(order.CreatedAt)}")
|
||||
.Line($"میز: {order.TableNumber ?? "—"}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(order.GuestName))
|
||||
b.Line($"مهمان: {order.GuestName}");
|
||||
else if (!string.IsNullOrWhiteSpace(order.CustomerName))
|
||||
b.Line($"مشتری: {order.CustomerName}");
|
||||
|
||||
b.Separator(width).AlignRight();
|
||||
|
||||
foreach (var item in order.Items.Where(i => !i.IsVoided))
|
||||
{
|
||||
var itemTotal = ReceiptPrintFormatting.FormatCurrency(item.UnitPrice * item.Quantity);
|
||||
var itemLine = $"{item.MenuItemName} × {item.Quantity}";
|
||||
b.TwoColumns(itemLine, itemTotal, width);
|
||||
}
|
||||
|
||||
b.Separator(width);
|
||||
|
||||
if (order.DiscountAmount > 0)
|
||||
b.TwoColumns("تخفیف", ReceiptPrintFormatting.FormatCurrency(order.DiscountAmount), width);
|
||||
|
||||
if (order.TaxTotal > 0)
|
||||
b.TwoColumns("مالیات", ReceiptPrintFormatting.FormatCurrency(order.TaxTotal), width);
|
||||
|
||||
b.Bold(true)
|
||||
.TwoColumns("مجموع کل", ReceiptPrintFormatting.FormatCurrency(order.Total), width)
|
||||
.Bold(false);
|
||||
|
||||
foreach (var payment in order.Payments)
|
||||
{
|
||||
var label = ReceiptPrintFormatting.PaymentMethodLabel(payment.Method);
|
||||
b.TwoColumns(label, ReceiptPrintFormatting.FormatCurrency(payment.Amount), width);
|
||||
}
|
||||
|
||||
b.Separator(width).AlignCenter();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.WifiPassword))
|
||||
b.Line($"WiFi: {ctx.WifiPassword.Trim()}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ReceiptFooter))
|
||||
b.Line(ctx.ReceiptFooter.Trim());
|
||||
|
||||
b.Line("ممنون از انتخاب شما");
|
||||
|
||||
b.Feed(3);
|
||||
if (ctx.AutoCutEnabled)
|
||||
b.Cut();
|
||||
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
public byte[] BuildKitchenTicket(ReceiptPrintContext ctx, IReadOnlyList<OrderItemDto>? itemsOnly = null)
|
||||
{
|
||||
var order = ctx.Order;
|
||||
var items = itemsOnly ?? order.Items.Where(i => !i.IsVoided).ToList();
|
||||
var width = ReceiptPrintFormatting.LineWidth(ctx.PaperWidthMm);
|
||||
var b = new EscPosBuilder();
|
||||
|
||||
b.Initialize()
|
||||
.SetEncoding()
|
||||
.AlignCenter()
|
||||
.Bold(true)
|
||||
.DoubleHeight(true)
|
||||
.Line(string.IsNullOrWhiteSpace(ctx.StationName) ? "آشپزخانه" : ctx.StationName!)
|
||||
.DoubleHeight(false)
|
||||
.Bold(false)
|
||||
.AlignRight()
|
||||
.Line($"میز: {order.TableNumber ?? "—"} | #{ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber)}")
|
||||
.Line($"{DateTime.Now:HH:mm}")
|
||||
.Separator(width);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
b.Bold(true)
|
||||
.Line($"× {item.Quantity} {item.MenuItemName}")
|
||||
.Bold(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||
b.Line($" ← {item.Notes}");
|
||||
}
|
||||
|
||||
b.Feed(4);
|
||||
if (ctx.AutoCutEnabled)
|
||||
b.Cut();
|
||||
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
public byte[] BuildTestPage()
|
||||
{
|
||||
var b = new EscPosBuilder();
|
||||
b.Initialize()
|
||||
.SetEncoding()
|
||||
.AlignCenter()
|
||||
.Bold(true)
|
||||
.Line("Meezi Test Print ✓")
|
||||
.Bold(false)
|
||||
.Line(ReceiptPrintFormatting.ToShamsi(DateTime.Now))
|
||||
.Feed(3)
|
||||
.Cut();
|
||||
return b.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public record ReceiptPrintContext(
|
||||
OrderDto Order,
|
||||
string CafeName,
|
||||
string BranchName,
|
||||
string? ReceiptHeader,
|
||||
string? ReceiptFooter,
|
||||
string? WifiPassword,
|
||||
int PaperWidthMm,
|
||||
bool AutoCutEnabled,
|
||||
string? StationName = null);
|
||||
|
||||
public static class ReceiptPrintFormatting
|
||||
{
|
||||
public static int LineWidth(int paperWidthMm) => paperWidthMm == 58 ? 32 : 48;
|
||||
|
||||
public static string FormatCurrency(decimal amount) =>
|
||||
$"{amount:N0} ت";
|
||||
|
||||
public static string ToShamsi(DateTime dt)
|
||||
{
|
||||
var pc = new System.Globalization.PersianCalendar();
|
||||
return $"{pc.GetYear(dt)}/{pc.GetMonth(dt):D2}/{pc.GetDayOfMonth(dt):D2} {dt:HH:mm}";
|
||||
}
|
||||
|
||||
public static string OrderNumberLabel(int displayNumber) =>
|
||||
displayNumber > 0 ? displayNumber.ToString() : "0";
|
||||
|
||||
public static string OrderNumberLabel(string orderId) =>
|
||||
OrderNumberLabel(StableDisplayNumberFromId(orderId));
|
||||
|
||||
public static int StableDisplayNumberFromId(string orderId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(orderId)) return 0;
|
||||
var hex = orderId.Replace("-", "", StringComparison.Ordinal);
|
||||
if (hex.Length == 32
|
||||
&& ulong.TryParse(hex.AsSpan(0, 16), System.Globalization.NumberStyles.HexNumber, null, out var hi)
|
||||
&& ulong.TryParse(hex.AsSpan(16, 16), System.Globalization.NumberStyles.HexNumber, null, out var lo))
|
||||
{
|
||||
return (int)((hi ^ lo) % 9_999_999) + 1;
|
||||
}
|
||||
|
||||
var digits = new string(orderId.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length > 0 && int.TryParse(digits.Length > 9 ? digits[^9..] : digits, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return (int)((uint)Math.Abs(StringComparer.Ordinal.GetHashCode(hex)) % 999_999) + 1;
|
||||
}
|
||||
|
||||
public static string PaymentMethodLabel(PaymentMethod method) => method switch
|
||||
{
|
||||
PaymentMethod.Cash => "نقد",
|
||||
PaymentMethod.Card => "کارت",
|
||||
PaymentMethod.Credit => "اعتبار",
|
||||
_ => method.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.API.Security;
|
||||
using Meezi.Infrastructure.Discover;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IPublicService
|
||||
{
|
||||
Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<CafePublicDto?> GetCafeAsync(string slug, CancellationToken cancellationToken = default);
|
||||
Task<PublicMenuDto?> GetMenuAsync(string slug, CancellationToken cancellationToken = default);
|
||||
Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||||
string slug,
|
||||
GuestCreateOrderRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<OrderTrackDto?> TrackOrderAsync(
|
||||
string orderId,
|
||||
string trackingToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(ReservationDto? Data, string? ErrorCode, string? Message)> CreateReservationAsync(
|
||||
string slug,
|
||||
CreateReservationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<PublicMenuDto?> GetBranchMenuAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PlaceGuestOrderRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PublicService : IPublicService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOrderService _orders;
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IKdsNotifier _kdsNotifier;
|
||||
private readonly IBranchMenuService _branchMenu;
|
||||
private readonly IBranchIdentityService _identity;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public PublicService(
|
||||
AppDbContext db,
|
||||
IOrderService orders,
|
||||
IReviewService reviews,
|
||||
IKdsNotifier kdsNotifier,
|
||||
IBranchMenuService branchMenu,
|
||||
IBranchIdentityService identity,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
_reviews = reviews;
|
||||
_kdsNotifier = kdsNotifier;
|
||||
_branchMenu = branchMenu;
|
||||
_identity = identity;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_reviews.DiscoverAsync(filters, cancellationToken);
|
||||
|
||||
public async Task<CafePublicDto?> GetCafeAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var (avg, count) = await _reviews.GetRatingSummaryAsync(cafe.Id, cancellationToken);
|
||||
|
||||
var profile = CafeDiscoverProfileSerializer.Deserialize(cafe.DiscoverProfileJson);
|
||||
var badges = DiscoverBadgeMapping.ToDtos(cafe)
|
||||
.Select(b => new CafeBadgePublicDto(b.Key, b.Label, b.Icon))
|
||||
.ToList();
|
||||
|
||||
var gallery = DeserializeStringList(cafe.GalleryJson);
|
||||
var hours = DeserializeHours(cafe.WorkingHoursJson);
|
||||
|
||||
return new CafePublicDto(
|
||||
cafe.Id,
|
||||
cafe.Name,
|
||||
cafe.NameAr,
|
||||
cafe.NameEn,
|
||||
cafe.Slug,
|
||||
cafe.City,
|
||||
cafe.Address,
|
||||
cafe.Phone,
|
||||
cafe.LogoUrl,
|
||||
cafe.CoverImageUrl,
|
||||
cafe.Description,
|
||||
cafe.IsVerified,
|
||||
avg,
|
||||
count,
|
||||
CafeDiscoverProfileMapping.ToDto(profile),
|
||||
badges,
|
||||
gallery,
|
||||
hours?.IsOpenNow() ?? false,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours));
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> DeserializeStringList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json, _jsonOpts) ?? []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static WorkingHoursSchedule? DeserializeHours(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<WorkingHoursSchedule>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static WorkingHoursPublicDto? ToHoursDto(WorkingHoursSchedule? h)
|
||||
{
|
||||
if (h is null) return null;
|
||||
DaySchedulePublicDto? Map(DaySchedule? d) =>
|
||||
d is null ? null : new DaySchedulePublicDto(d.IsOpen, d.Open, d.Close);
|
||||
return new WorkingHoursPublicDto(Map(h.Sat), Map(h.Sun), Map(h.Mon), Map(h.Tue), Map(h.Wed), Map(h.Thu), Map(h.Fri));
|
||||
}
|
||||
|
||||
public async Task<PublicMenuDto?> GetMenuAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var categories = await _db.MenuCategories
|
||||
.Where(c => c.CafeId == cafe.Id && c.IsActive)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = await _db.MenuItems
|
||||
.Include(i => i.Category)
|
||||
.Where(i => i.CafeId == cafe.Id && i.IsAvailable)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var grouped = categories
|
||||
.Select(cat => new PublicMenuCategoryDto(
|
||||
cat.Id,
|
||||
cat.Name,
|
||||
cat.NameAr,
|
||||
cat.NameEn,
|
||||
cat.Icon,
|
||||
cat.IconPresetId,
|
||||
cat.IconStyle,
|
||||
cat.ImageUrl,
|
||||
items
|
||||
.Where(i => i.CategoryId == cat.Id)
|
||||
.Select(i => new PublicMenuItemDto(
|
||||
i.Id,
|
||||
i.CategoryId,
|
||||
i.Name,
|
||||
i.NameAr,
|
||||
i.NameEn,
|
||||
i.Description,
|
||||
i.Price,
|
||||
i.DiscountPercent > 0 ? i.DiscountPercent : cat.DiscountPercent,
|
||||
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||||
i.VideoUrl,
|
||||
i.Model3dUrl,
|
||||
i.IsAvailable))
|
||||
.ToList()))
|
||||
.Where(c => c.Items.Count > 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
}
|
||||
|
||||
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||||
string slug,
|
||||
GuestCreateOrderRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||||
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: true, cancellationToken);
|
||||
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
if (maxOrders != int.MaxValue)
|
||||
{
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var count = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafe.Id && o.CreatedAt >= todayStart,
|
||||
cancellationToken);
|
||||
if (count >= maxOrders)
|
||||
return (null, "PLAN_LIMIT_REACHED", "This cafe has reached its daily order limit.");
|
||||
}
|
||||
|
||||
string? couponId = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.CouponCode))
|
||||
{
|
||||
var coupon = await _db.Coupons.FirstOrDefaultAsync(
|
||||
c => c.CafeId == cafe.Id && c.Code == request.CouponCode && c.IsActive,
|
||||
cancellationToken);
|
||||
couponId = coupon?.Id;
|
||||
}
|
||||
|
||||
var order = await _orders.CreateGuestOrderAsync(
|
||||
cafe.Id,
|
||||
new CreateOrderRequest(
|
||||
request.OrderType,
|
||||
null,
|
||||
request.TableId,
|
||||
null,
|
||||
request.GuestName,
|
||||
request.GuestPhone,
|
||||
null,
|
||||
couponId,
|
||||
request.Items),
|
||||
request.GuestPhone,
|
||||
request.GuestName,
|
||||
cancellationToken);
|
||||
|
||||
if (order is null)
|
||||
return (null, "VALIDATION_ERROR", "Could not place order. Check menu items and table.");
|
||||
|
||||
return (new GuestOrderPlacedDto(order.Id, order.Status, order.Total, order.TableNumber), null, null);
|
||||
}
|
||||
|
||||
public async Task<OrderTrackDto?> TrackOrderAsync(
|
||||
string orderId,
|
||||
string trackingToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Table)
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.Id == orderId && o.GuestTrackingToken == trackingToken,
|
||||
cancellationToken);
|
||||
|
||||
if (order is null) return null;
|
||||
|
||||
return OrderTrackingHelper.BuildTrackDto(order);
|
||||
}
|
||||
|
||||
public async Task<(ReservationDto? Data, string? ErrorCode, string? Message)> CreateReservationAsync(
|
||||
string slug,
|
||||
CreateReservationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken);
|
||||
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: false, cancellationToken);
|
||||
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||||
|
||||
var entity = new TableReservation
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
TableId = request.TableId,
|
||||
GuestName = request.GuestName,
|
||||
GuestPhone = request.GuestPhone,
|
||||
Date = request.Date,
|
||||
Time = request.Time,
|
||||
PartySize = request.PartySize,
|
||||
Notes = request.Notes,
|
||||
Status = ReservationStatus.Pending
|
||||
};
|
||||
|
||||
_db.TableReservations.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.TableId))
|
||||
await _kdsNotifier.NotifyTableStatusChangedAsync(cafe.Id, cancellationToken);
|
||||
|
||||
var loaded = await _db.TableReservations
|
||||
.Include(r => r.Table)
|
||||
.FirstAsync(r => r.Id == entity.Id, cancellationToken);
|
||||
|
||||
return (ToReservationDto(loaded), null, null);
|
||||
}
|
||||
|
||||
public async Task<PublicMenuDto?> GetBranchMenuAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var branchMenu = await _branchMenu.GetBranchMenuAsync(
|
||||
cafeId,
|
||||
branchId,
|
||||
includeUnavailable: false,
|
||||
cancellationToken);
|
||||
if (branchMenu is null) return null;
|
||||
|
||||
var categories = await _db.MenuCategories
|
||||
.Where(c => c.CafeId == cafeId && c.IsActive)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var categoryById = categories.ToDictionary(c => c.Id);
|
||||
var grouped = branchMenu
|
||||
.Where(i => i.IsAvailable)
|
||||
.GroupBy(i => i.CategoryId)
|
||||
.Select(g =>
|
||||
{
|
||||
categoryById.TryGetValue(g.Key, out var cat);
|
||||
return new PublicMenuCategoryDto(
|
||||
g.Key,
|
||||
cat?.Name ?? "",
|
||||
cat?.NameAr,
|
||||
cat?.NameEn,
|
||||
cat?.Icon,
|
||||
cat?.IconPresetId,
|
||||
cat?.IconStyle,
|
||||
cat?.ImageUrl,
|
||||
g.Select(i => new PublicMenuItemDto(
|
||||
i.Id,
|
||||
i.CategoryId,
|
||||
i.Name,
|
||||
i.NameAr,
|
||||
i.NameEn,
|
||||
i.Description,
|
||||
i.EffectivePrice,
|
||||
i.DiscountPercent,
|
||||
MenuItemImageDefaults.IsUsableImageUrl(i.ImageUrl)
|
||||
? i.ImageUrl!
|
||||
: MenuItemImageDefaults.ResolveImageUrl(i.Id, i.CategoryId, null),
|
||||
i.VideoUrl,
|
||||
i.Model3dUrl,
|
||||
true)).ToList());
|
||||
})
|
||||
.Where(c => c.Items.Count > 0)
|
||||
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
}
|
||||
|
||||
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
PlaceGuestOrderRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var traffic = await GuardPublicWriteAsync(cafe, request.CaptchaToken, guestOrder: true, cancellationToken);
|
||||
if (!traffic.Ok) return (null, traffic.ErrorCode, traffic.Message);
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
if (maxOrders != int.MaxValue)
|
||||
{
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var count = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafe.Id && o.CreatedAt >= todayStart,
|
||||
cancellationToken);
|
||||
if (count >= maxOrders)
|
||||
return (null, "PLAN_LIMIT_REACHED", "This cafe has reached its daily order limit.");
|
||||
}
|
||||
|
||||
return await _orders.PlaceBranchGuestOrderAsync(cafeId, branchId, request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<(bool Ok, string? ErrorCode, string? Message)> GuardPublicWriteAsync(
|
||||
Cafe cafe,
|
||||
string? captchaToken,
|
||||
bool guestOrder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var availability = PublicCafeGuard.EnsureAcceptingPublicTraffic(cafe);
|
||||
if (!availability.Ok)
|
||||
return (false, availability.ErrorCode, availability.Message);
|
||||
|
||||
var ctx = _http.HttpContext;
|
||||
if (ctx is null)
|
||||
return (true, null, null);
|
||||
|
||||
var ip = ClientIpResolver.GetClientIp(ctx);
|
||||
var writeCheck = await _abuse.CheckPublicWriteByIpAsync(ip, cancellationToken);
|
||||
if (!writeCheck.Allowed)
|
||||
return (false, writeCheck.ErrorCode, writeCheck.Message);
|
||||
|
||||
if (guestOrder)
|
||||
{
|
||||
var orderCheck = await _abuse.CheckGuestOrderAsync(cafe.Id, ip, cancellationToken);
|
||||
if (!orderCheck.Allowed)
|
||||
return (false, orderCheck.ErrorCode, orderCheck.Message);
|
||||
}
|
||||
|
||||
var captcha = await _abuse.VerifyCaptchaAsync(captchaToken, cancellationToken);
|
||||
if (!captcha.Ok)
|
||||
return (false, captcha.ErrorCode, captcha.Message);
|
||||
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
private static ReservationDto ToReservationDto(TableReservation r) =>
|
||||
ReservationService.Map(r);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using Meezi.API.Models.Queue;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IQueueService
|
||||
{
|
||||
Task<QueueBoardDto> GetTodayBoardAsync(string cafeId, string? branchId, CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? ErrorCode, string? Message)> IssuePublicAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? Error)> IssueNextAsync(
|
||||
string cafeId,
|
||||
string? userId,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? Error)> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
QueueTicketStatus status,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class QueueService : IQueueService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
private readonly ISmsService _sms;
|
||||
|
||||
public QueueService(AppDbContext db, IPlatformCatalogService catalog, ISmsService sms)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_sms = sms;
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? ErrorCode, string? Message)> IssuePublicAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "queue", ct))
|
||||
return (null, "PLAN_LIMIT_REACHED", "Queue is not available on your plan.");
|
||||
|
||||
var (ticket, error) = await IssueNextAsync(cafeId, null, request, ct);
|
||||
if (error is not null)
|
||||
return (null, error, error switch
|
||||
{
|
||||
"BRANCH_NOT_FOUND" => "Branch not found.",
|
||||
"ORDER_NOT_FOUND" => "Order not found.",
|
||||
_ => "Could not issue ticket."
|
||||
});
|
||||
|
||||
return (ticket, null, null);
|
||||
}
|
||||
|
||||
public async Task<QueueBoardDto> GetTodayBoardAsync(
|
||||
string cafeId,
|
||||
string? branchId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tickets = await FilterToday(_db.QueueTickets, cafeId, branchId, today)
|
||||
.OrderBy(q => q.Number)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dtos = tickets.Select(Map).ToList();
|
||||
var waiting = tickets.Where(t => t.Status == QueueTicketStatus.Waiting).ToList();
|
||||
var nowServing = tickets
|
||||
.Where(t => t.Status == QueueTicketStatus.Called)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.Select(t => (int?)t.Number)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (nowServing is null && waiting.Count > 0)
|
||||
nowServing = waiting[0].Number;
|
||||
|
||||
var lastIssued = tickets.Count > 0 ? tickets.Max(t => t.Number) : 0;
|
||||
|
||||
return new QueueBoardDto(
|
||||
today,
|
||||
nowServing,
|
||||
lastIssued,
|
||||
waiting.Count,
|
||||
dtos);
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? Error)> IssueNextAsync(
|
||||
string cafeId,
|
||||
string? userId,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.BranchId))
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == request.BranchId && b.CafeId == cafeId,
|
||||
ct);
|
||||
if (!branchOk) return (null, "BRANCH_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.OrderId))
|
||||
{
|
||||
var orderOk = await _db.Orders.AnyAsync(
|
||||
o => o.Id == request.OrderId && o.CafeId == cafeId,
|
||||
ct);
|
||||
if (!orderOk) return (null, "ORDER_NOT_FOUND");
|
||||
}
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var branchId = string.IsNullOrEmpty(request.BranchId) ? null : request.BranchId;
|
||||
|
||||
var maxNumber = await FilterToday(_db.QueueTickets, cafeId, branchId, today)
|
||||
.MaxAsync(q => (int?)q.Number, ct) ?? 0;
|
||||
|
||||
var entity = new QueueTicket
|
||||
{
|
||||
Id = $"qt_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
ServiceDate = today,
|
||||
Number = maxNumber + 1,
|
||||
CustomerLabel = string.IsNullOrWhiteSpace(request.CustomerLabel)
|
||||
? null
|
||||
: request.CustomerLabel.Trim(),
|
||||
IssuedByUserId = userId,
|
||||
OrderId = request.OrderId,
|
||||
Status = QueueTicketStatus.Waiting,
|
||||
IssuedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.QueueTickets.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return (Map(entity), null);
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? Error)> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
QueueTicketStatus status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var ticket = await _db.QueueTickets.FirstOrDefaultAsync(
|
||||
q => q.Id == ticketId && q.CafeId == cafeId,
|
||||
ct);
|
||||
if (ticket is null) return (null, "NOT_FOUND");
|
||||
if (ticket.ServiceDate != IranCalendar.TodayInIran)
|
||||
return (null, "TICKET_EXPIRED");
|
||||
|
||||
ticket.Status = status;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (status == QueueTicketStatus.Called && !string.IsNullOrWhiteSpace(ticket.CustomerLabel))
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cafe is not null)
|
||||
{
|
||||
var phone = ExtractPhone(ticket.CustomerLabel);
|
||||
if (phone is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sms.SendMessageAsync(
|
||||
phone,
|
||||
$"{cafe.Name}: نوبت شما فرا رسید — شماره {ticket.Number}",
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* SMS optional */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (Map(ticket), null);
|
||||
}
|
||||
|
||||
private static string? ExtractPhone(string label)
|
||||
{
|
||||
var digits = new string(label.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length >= 10 && digits.StartsWith("09", StringComparison.Ordinal))
|
||||
return digits.Length > 11 ? digits[^11..] : digits;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IQueryable<QueueTicket> FilterToday(
|
||||
IQueryable<QueueTicket> source,
|
||||
string cafeId,
|
||||
string? branchId,
|
||||
DateOnly today)
|
||||
{
|
||||
var query = source.Where(q => q.CafeId == cafeId && q.ServiceDate == today);
|
||||
return string.IsNullOrEmpty(branchId)
|
||||
? query
|
||||
: query.Where(q => q.BranchId == branchId);
|
||||
}
|
||||
|
||||
private static QueueTicketDto Map(QueueTicket q) =>
|
||||
new(
|
||||
q.Id,
|
||||
q.BranchId,
|
||||
q.ServiceDate,
|
||||
q.Number,
|
||||
q.CustomerLabel,
|
||||
q.OrderId,
|
||||
q.Status,
|
||||
q.IssuedAt);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record RefreshTokenPayload(
|
||||
string UserId,
|
||||
string CafeId,
|
||||
string Role,
|
||||
string PlanTier,
|
||||
string Language,
|
||||
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant);
|
||||
|
||||
public interface IRefreshTokenStore
|
||||
{
|
||||
Task StoreAsync(string refreshToken, RefreshTokenPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
Task<RefreshTokenPayload?> GetAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class RedisRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public RedisRefreshTokenStore(IConnectionMultiplexer redis)
|
||||
{
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
private static string Key(string token) => $"refresh:{token}";
|
||||
|
||||
public async Task StoreAsync(string refreshToken, RefreshTokenPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
await db.StringSetAsync(Key(refreshToken), json, ttl);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenPayload?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var value = await db.StringGetAsync(Key(refreshToken));
|
||||
if (value.IsNullOrEmpty)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<RefreshTokenPayload>(value.ToString());
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.KeyDeleteAsync(Key(refreshToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public static class ReportPlanGate
|
||||
{
|
||||
public static bool IsDateInRange(PlanTier tier, DateOnly date, DateOnly todayIran)
|
||||
{
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return date <= todayIran;
|
||||
|
||||
var earliest = todayIran.AddDays(-(maxDays - 1));
|
||||
return date >= earliest && date <= todayIran;
|
||||
}
|
||||
|
||||
public static (DateOnly From, DateOnly To)? ClampRange(
|
||||
PlanTier tier,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
DateOnly todayIran)
|
||||
{
|
||||
if (from > to) return null;
|
||||
if (!IsDateInRange(tier, to, todayIran) || !IsDateInRange(tier, from, todayIran))
|
||||
return null;
|
||||
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return (from, to);
|
||||
|
||||
var earliest = todayIran.AddDays(-(maxDays - 1));
|
||||
var clampedFrom = from < earliest ? earliest : from;
|
||||
var clampedTo = to > todayIran ? todayIran : to;
|
||||
if (clampedFrom > clampedTo) return null;
|
||||
return (clampedFrom, clampedTo);
|
||||
}
|
||||
|
||||
public static string LimitMessage(PlanTier tier)
|
||||
{
|
||||
var days = PlanLimits.MaxReportHistoryDays(tier);
|
||||
return tier switch
|
||||
{
|
||||
PlanTier.Free =>
|
||||
"Daily reports on the Free plan are limited to today and the previous 7 days. Upgrade to Pro for 90 days of history.",
|
||||
PlanTier.Pro =>
|
||||
"Daily reports on the Pro plan are limited to the last 90 days. Upgrade to Business for unlimited history.",
|
||||
_ => "Report date is outside your plan range."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Globalization;
|
||||
using Meezi.API.Models.Reports;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OfficeOpenXml;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IReportService
|
||||
{
|
||||
Task<DailyReportDto> GetDailyReportAsync(string cafeId, string dateJalali, CancellationToken cancellationToken = default);
|
||||
Task<MonthlyReportDto> GetMonthlyReportAsync(string cafeId, string monthJalali, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TrendDayDto>> GetTrendAsync(string cafeId, int days, CancellationToken cancellationToken = default);
|
||||
Task<byte[]> ExportExcelAsync(string cafeId, string monthJalali, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ReportService : IReportService
|
||||
{
|
||||
private static readonly OrderStatus[] RevenueStatuses =
|
||||
[
|
||||
OrderStatus.Confirmed,
|
||||
OrderStatus.Preparing,
|
||||
OrderStatus.Ready,
|
||||
OrderStatus.Delivered
|
||||
];
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public ReportService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<DailyReportDto> GetDailyReportAsync(
|
||||
string cafeId,
|
||||
string dateJalali,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!JalaliCalendarHelper.TryParseJalaliDate(dateJalali, out var y, out var m, out var d))
|
||||
{
|
||||
var today = JalaliCalendarHelper.TodayJalali();
|
||||
y = today.Year;
|
||||
m = today.Month;
|
||||
d = today.Day;
|
||||
dateJalali = JalaliCalendarHelper.FormatJalaliDate(y, m, d);
|
||||
}
|
||||
|
||||
var (utcStart, utcEnd) = JalaliCalendarHelper.GetUtcRangeForJalaliDay(y, m, d);
|
||||
var orders = await LoadRevenueOrdersAsync(cafeId, utcStart, utcEnd, cancellationToken);
|
||||
|
||||
var newCustomers = await _db.Customers
|
||||
.Where(c => c.CafeId == cafeId && c.CreatedAt >= utcStart && c.CreatedAt < utcEnd)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var returningCustomers = await CountReturningCustomersAsync(cafeId, utcStart, utcEnd, cancellationToken);
|
||||
var topItems = await GetTopItemsAsync(cafeId, utcStart, utcEnd, 5, cancellationToken);
|
||||
|
||||
return new DailyReportDto(
|
||||
dateJalali,
|
||||
orders.Count,
|
||||
newCustomers,
|
||||
returningCustomers,
|
||||
orders.Sum(o => o.Total),
|
||||
orders.Sum(o => o.TaxTotal),
|
||||
orders.Sum(o => o.DiscountAmount),
|
||||
topItems);
|
||||
}
|
||||
|
||||
public async Task<MonthlyReportDto> GetMonthlyReportAsync(
|
||||
string cafeId,
|
||||
string monthJalali,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!JalaliCalendarHelper.TryParseJalaliMonth(monthJalali, out var y, out var m))
|
||||
{
|
||||
var today = JalaliCalendarHelper.TodayJalali();
|
||||
y = today.Year;
|
||||
m = today.Month;
|
||||
monthJalali = JalaliCalendarHelper.FormatJalaliMonth(y, m);
|
||||
}
|
||||
|
||||
var (utcStart, utcEnd) = JalaliCalendarHelper.GetUtcRangeForJalaliMonth(y, m);
|
||||
var daysInMonth = new PersianCalendar().GetDaysInMonth(y, m);
|
||||
var salaryCosts = await GetSalaryCostsForGregorianMonthAsync(cafeId, utcStart, cancellationToken);
|
||||
var dailyCost = daysInMonth > 0 ? salaryCosts / daysInMonth : 0m;
|
||||
|
||||
var breakdown = new List<DailyBreakdownDto>();
|
||||
decimal totalRevenue = 0;
|
||||
|
||||
for (var day = 1; day <= daysInMonth; day++)
|
||||
{
|
||||
var (dayStart, dayEnd) = JalaliCalendarHelper.GetUtcRangeForJalaliDay(y, m, day);
|
||||
var dayOrders = await LoadRevenueOrdersAsync(cafeId, dayStart, dayEnd, cancellationToken);
|
||||
var revenue = dayOrders.Sum(o => o.Total);
|
||||
totalRevenue += revenue;
|
||||
breakdown.Add(new DailyBreakdownDto(
|
||||
JalaliCalendarHelper.FormatJalaliDate(y, m, day),
|
||||
revenue,
|
||||
dailyCost));
|
||||
}
|
||||
|
||||
var totalCosts = salaryCosts;
|
||||
return new MonthlyReportDto(
|
||||
monthJalali,
|
||||
breakdown,
|
||||
totalRevenue,
|
||||
totalCosts,
|
||||
salaryCosts,
|
||||
0m,
|
||||
totalRevenue - totalCosts);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TrendDayDto>> GetTrendAsync(
|
||||
string cafeId,
|
||||
int days,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
days = Math.Clamp(days, 1, 31);
|
||||
var (y, m, d) = JalaliCalendarHelper.TodayJalali();
|
||||
var persian = new PersianCalendar();
|
||||
var cursor = persian.ToDateTime(y, m, d, 0, 0, 0, 0);
|
||||
var monthYear = $"{cursor:yyyy-MM}";
|
||||
var salaryCosts = await _db.EmployeeSalaries
|
||||
.Include(s => s.Employee)
|
||||
.Where(s => s.Employee.CafeId == cafeId && s.MonthYear == monthYear)
|
||||
.SumAsync(s => s.NetSalary, cancellationToken);
|
||||
var daysInMonth = persian.GetDaysInMonth(y, m);
|
||||
var dailyCost = daysInMonth > 0 ? salaryCosts / daysInMonth : 0m;
|
||||
|
||||
var result = new List<TrendDayDto>();
|
||||
for (var i = days - 1; i >= 0; i--)
|
||||
{
|
||||
var localDay = cursor.AddDays(-i);
|
||||
var jy = persian.GetYear(localDay);
|
||||
var jm = persian.GetMonth(localDay);
|
||||
var jd = persian.GetDayOfMonth(localDay);
|
||||
var (utcStart, utcEnd) = JalaliCalendarHelper.GetUtcRangeForJalaliDay(jy, jm, jd);
|
||||
var orders = await LoadRevenueOrdersAsync(cafeId, utcStart, utcEnd, cancellationToken);
|
||||
result.Add(new TrendDayDto(
|
||||
JalaliCalendarHelper.FormatJalaliDate(jy, jm, jd),
|
||||
orders.Sum(o => o.Total),
|
||||
dailyCost));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<byte[]> ExportExcelAsync(
|
||||
string cafeId,
|
||||
string monthJalali,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!JalaliCalendarHelper.TryParseJalaliMonth(monthJalali, out var y, out var m))
|
||||
throw new ArgumentException("Invalid Jalali month.", nameof(monthJalali));
|
||||
|
||||
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
||||
var (utcStart, utcEnd) = JalaliCalendarHelper.GetUtcRangeForJalaliMonth(y, m);
|
||||
|
||||
var orders = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Customer)
|
||||
.Where(o => o.CafeId == cafeId && RevenueStatuses.Contains(o.Status))
|
||||
.Where(o => o.CreatedAt >= utcStart && o.CreatedAt < utcEnd)
|
||||
.OrderBy(o => o.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var customers = await _db.Customers
|
||||
.Where(c => c.CafeId == cafeId && c.CreatedAt >= utcStart && c.CreatedAt < utcEnd)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var salaries = await _db.EmployeeSalaries
|
||||
.Include(s => s.Employee)
|
||||
.Where(s => s.Employee.CafeId == cafeId && s.MonthYear == $"{utcStart:yyyy-MM}")
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
using var package = new ExcelPackage();
|
||||
|
||||
var sales = package.Workbook.Worksheets.Add("Sales");
|
||||
sales.Cells[1, 1].Value = "OrderId";
|
||||
sales.Cells[1, 2].Value = "Date";
|
||||
sales.Cells[1, 3].Value = "Total";
|
||||
sales.Cells[1, 4].Value = "Tax";
|
||||
sales.Cells[1, 5].Value = "Discount";
|
||||
var row = 2;
|
||||
foreach (var order in orders)
|
||||
{
|
||||
var jalali = JalaliCalendarHelper.ToJalali(order.CreatedAt);
|
||||
sales.Cells[row, 1].Value = order.Id;
|
||||
sales.Cells[row, 2].Value = JalaliCalendarHelper.FormatJalaliDate(jalali.Year, jalali.Month, jalali.Day);
|
||||
sales.Cells[row, 3].Value = order.Total;
|
||||
sales.Cells[row, 4].Value = order.TaxTotal;
|
||||
sales.Cells[row, 5].Value = order.DiscountAmount;
|
||||
row++;
|
||||
}
|
||||
|
||||
var items = package.Workbook.Worksheets.Add("Items");
|
||||
items.Cells[1, 1].Value = "Item";
|
||||
items.Cells[1, 2].Value = "Quantity";
|
||||
items.Cells[1, 3].Value = "Revenue";
|
||||
row = 2;
|
||||
foreach (var line in orders.SelectMany(o => o.Items))
|
||||
{
|
||||
items.Cells[row, 1].Value = line.MenuItem.Name;
|
||||
items.Cells[row, 2].Value = line.Quantity;
|
||||
items.Cells[row, 3].Value = line.UnitPrice * line.Quantity;
|
||||
row++;
|
||||
}
|
||||
|
||||
var customersSheet = package.Workbook.Worksheets.Add("Customers");
|
||||
customersSheet.Cells[1, 1].Value = "Name";
|
||||
customersSheet.Cells[1, 2].Value = "Phone";
|
||||
customersSheet.Cells[1, 3].Value = "Created";
|
||||
row = 2;
|
||||
foreach (var customer in customers)
|
||||
{
|
||||
var jalali = JalaliCalendarHelper.ToJalali(customer.CreatedAt);
|
||||
customersSheet.Cells[row, 1].Value = customer.Name;
|
||||
customersSheet.Cells[row, 2].Value = customer.Phone;
|
||||
customersSheet.Cells[row, 3].Value = JalaliCalendarHelper.FormatJalaliDate(jalali.Year, jalali.Month, jalali.Day);
|
||||
row++;
|
||||
}
|
||||
|
||||
var employeesSheet = package.Workbook.Worksheets.Add("Employees");
|
||||
employeesSheet.Cells[1, 1].Value = "Employee";
|
||||
employeesSheet.Cells[1, 2].Value = "Month";
|
||||
employeesSheet.Cells[1, 3].Value = "NetSalary";
|
||||
employeesSheet.Cells[1, 4].Value = "Paid";
|
||||
row = 2;
|
||||
foreach (var salary in salaries)
|
||||
{
|
||||
employeesSheet.Cells[row, 1].Value = salary.Employee.Name;
|
||||
employeesSheet.Cells[row, 2].Value = salary.MonthYear;
|
||||
employeesSheet.Cells[row, 3].Value = salary.NetSalary;
|
||||
employeesSheet.Cells[row, 4].Value = salary.IsPaid ? "Yes" : "No";
|
||||
row++;
|
||||
}
|
||||
|
||||
return await package.GetAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<Core.Entities.Order>> LoadRevenueOrdersAsync(
|
||||
string cafeId,
|
||||
DateTime utcStart,
|
||||
DateTime utcEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId && RevenueStatuses.Contains(o.Status))
|
||||
.Where(o => o.CreatedAt >= utcStart && o.CreatedAt < utcEnd)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<int> CountReturningCustomersAsync(
|
||||
string cafeId,
|
||||
DateTime utcStart,
|
||||
DateTime utcEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customerIds = await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId && o.CustomerId != null)
|
||||
.Where(o => RevenueStatuses.Contains(o.Status))
|
||||
.Where(o => o.CreatedAt >= utcStart && o.CreatedAt < utcEnd)
|
||||
.Select(o => o.CustomerId!)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (customerIds.Count == 0)
|
||||
return 0;
|
||||
|
||||
return await _db.Customers
|
||||
.Where(c => c.CafeId == cafeId && customerIds.Contains(c.Id) && c.CreatedAt < utcStart)
|
||||
.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TopItemDto>> GetTopItemsAsync(
|
||||
string cafeId,
|
||||
DateTime utcStart,
|
||||
DateTime utcEnd,
|
||||
int take,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lines = await _db.OrderItems
|
||||
.Include(i => i.MenuItem)
|
||||
.Include(i => i.Order)
|
||||
.Where(i => i.Order.CafeId == cafeId && RevenueStatuses.Contains(i.Order.Status))
|
||||
.Where(i => i.Order.CreatedAt >= utcStart && i.Order.CreatedAt < utcEnd)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return lines
|
||||
.GroupBy(i => i.MenuItemId)
|
||||
.Select(g => new TopItemDto(
|
||||
g.Key,
|
||||
g.First().MenuItem.Name,
|
||||
g.Sum(x => x.Quantity),
|
||||
g.Sum(x => x.UnitPrice * x.Quantity)))
|
||||
.OrderByDescending(x => x.Revenue)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<decimal> GetSalaryCostsForGregorianMonthAsync(
|
||||
string cafeId,
|
||||
DateTime utcStart,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var monthYear = utcStart.ToString("yyyy-MM");
|
||||
return await _db.EmployeeSalaries
|
||||
.Include(s => s.Employee)
|
||||
.Where(s => s.Employee.CafeId == cafeId && s.MonthYear == monthYear)
|
||||
.SumAsync(s => s.NetSalary, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IReservationService
|
||||
{
|
||||
Task<IReadOnlyList<ReservationDto>> GetReservationsAsync(
|
||||
string cafeId,
|
||||
DateOnly? date,
|
||||
ReservationStatus? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReservationDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateReservationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReservationDto?> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
ReservationStatus status,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ReservationService : IReservationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kdsNotifier;
|
||||
|
||||
public ReservationService(AppDbContext db, IKdsNotifier kdsNotifier)
|
||||
{
|
||||
_db = db;
|
||||
_kdsNotifier = kdsNotifier;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReservationDto>> GetReservationsAsync(
|
||||
string cafeId,
|
||||
DateOnly? date,
|
||||
ReservationStatus? status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.TableReservations
|
||||
.Include(r => r.Table)
|
||||
.Where(r => r.CafeId == cafeId);
|
||||
if (date.HasValue)
|
||||
query = query.Where(r => r.Date == date.Value);
|
||||
if (status.HasValue)
|
||||
query = query.Where(r => r.Status == status.Value);
|
||||
|
||||
var list = await query
|
||||
.OrderBy(r => r.Date)
|
||||
.ThenBy(r => r.Time)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return list.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<ReservationDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateReservationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.TableId))
|
||||
{
|
||||
var tableOk = await _db.Tables.AnyAsync(
|
||||
t => t.Id == request.TableId && t.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (!tableOk) return null;
|
||||
}
|
||||
|
||||
var entity = new TableReservation
|
||||
{
|
||||
CafeId = cafeId,
|
||||
TableId = request.TableId,
|
||||
GuestName = request.GuestName.Trim(),
|
||||
GuestPhone = request.GuestPhone.Trim(),
|
||||
Date = request.Date,
|
||||
Time = request.Time,
|
||||
PartySize = request.PartySize,
|
||||
Notes = request.Notes,
|
||||
Status = ReservationStatus.Confirmed
|
||||
};
|
||||
|
||||
_db.TableReservations.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.TableId))
|
||||
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
|
||||
|
||||
var loaded = await _db.TableReservations
|
||||
.Include(r => r.Table)
|
||||
.FirstAsync(r => r.Id == entity.Id, cancellationToken);
|
||||
|
||||
return Map(loaded);
|
||||
}
|
||||
|
||||
public async Task<ReservationDto?> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
ReservationStatus status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.TableReservations
|
||||
.Include(r => r.Table)
|
||||
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.Status = status;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.TableId))
|
||||
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
internal static ReservationDto Map(TableReservation r) => new(
|
||||
r.Id,
|
||||
r.CafeId,
|
||||
r.TableId,
|
||||
r.Table?.Number,
|
||||
r.GuestName,
|
||||
r.GuestPhone,
|
||||
r.Date,
|
||||
r.Time,
|
||||
r.PartySize,
|
||||
r.Status,
|
||||
r.Notes);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Security;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Discover;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IReviewService
|
||||
{
|
||||
Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<CafeReviewDto>> GetReviewsAsync(
|
||||
string cafeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
bool publicOnly = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync(
|
||||
string cafeId,
|
||||
CreateCafeReviewRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<CafeReviewDto?> ReplyReviewAsync(string cafeId, string reviewId, string reply, CancellationToken cancellationToken = default);
|
||||
Task<CafeReviewDto?> SetHiddenAsync(string cafeId, string reviewId, bool isHidden, CancellationToken cancellationToken = default);
|
||||
Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync(
|
||||
string cafeId,
|
||||
CreateCafeReviewRequest request,
|
||||
IReadOnlyList<IFormFile> photos,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ReviewService : IReviewService
|
||||
{
|
||||
private const int MaxReviewPhotos = 3;
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly IMediaStorageService _media;
|
||||
|
||||
public ReviewService(
|
||||
AppDbContext db,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http,
|
||||
IMediaStorageService media)
|
||||
{
|
||||
_db = db;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
_media = media;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.City))
|
||||
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.Q))
|
||||
{
|
||||
var q = filters.Q.Trim();
|
||||
var qNorm = PersianSearchNormalizer.Normalize(q);
|
||||
var pattern = $"%{q}%";
|
||||
var patternNorm = qNorm.Length > 0 && !string.Equals(qNorm, q, StringComparison.Ordinal)
|
||||
? $"%{qNorm}%"
|
||||
: null;
|
||||
|
||||
query = query.Where(c =>
|
||||
EF.Functions.ILike(c.Name, pattern)
|
||||
|| EF.Functions.ILike(c.Slug, pattern)
|
||||
|| (c.Description != null && EF.Functions.ILike(c.Description, pattern))
|
||||
|| (c.Address != null && EF.Functions.ILike(c.Address, pattern))
|
||||
|| (c.City != null && EF.Functions.ILike(c.City, pattern))
|
||||
|| (c.NameAr != null && EF.Functions.ILike(c.NameAr, pattern))
|
||||
|| (patternNorm != null && (
|
||||
EF.Functions.ILike(c.Name, patternNorm)
|
||||
|| (c.Description != null && EF.Functions.ILike(c.Description, patternNorm))
|
||||
|| (c.Address != null && EF.Functions.ILike(c.Address, patternNorm))))
|
||||
|| _db.MenuItems.Any(m =>
|
||||
m.CafeId == c.Id
|
||||
&& m.DeletedAt == null
|
||||
&& (EF.Functions.ILike(m.Name, pattern)
|
||||
|| (patternNorm != null && EF.Functions.ILike(m.Name, patternNorm)))));
|
||||
}
|
||||
|
||||
var cafes = await query.ToListAsync(cancellationToken);
|
||||
var cafeIds = cafes.Select(c => c.Id).ToList();
|
||||
|
||||
var ratings = await _db.CafeReviews
|
||||
.Where(r => cafeIds.Contains(r.CafeId) && !r.IsHidden)
|
||||
.GroupBy(r => r.CafeId)
|
||||
.Select(g => new { CafeId = g.Key, Avg = g.Average(x => x.Rating), Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var ratingMap = ratings.ToDictionary(x => x.CafeId, x => (x.Avg, x.Count));
|
||||
|
||||
// Determine whether this is a free-text NLP search or a pure chip-filter search
|
||||
bool hasTextQuery = !string.IsNullOrWhiteSpace(filters.Q);
|
||||
|
||||
var result = cafes
|
||||
.Select(c =>
|
||||
{
|
||||
ratingMap.TryGetValue(c.Id, out var r);
|
||||
var count = r.Count;
|
||||
var avg = count > 0 ? r.Avg : 0.0;
|
||||
var profile = CafeDiscoverProfileSerializer.Deserialize(c.DiscoverProfileJson);
|
||||
var hours = DeserializeHours(c.WorkingHoursJson);
|
||||
var gallery = DeserializeGallery(c.GalleryJson);
|
||||
var badges = MapBadges(c);
|
||||
|
||||
// openNow filter — skip cafes that are provably closed
|
||||
if (filters.OpenNow && hours is not null && !hours.IsOpenNow())
|
||||
return default;
|
||||
|
||||
double score;
|
||||
if (hasTextQuery)
|
||||
{
|
||||
// Soft scoring: partial matches surface instead of being hidden
|
||||
score = DiscoverProfileMatcher.Score(profile, filters);
|
||||
if (filters.RequireProfile
|
||||
&& !DiscoverProfileMatcher.HasMeaningfulProfile(profile)
|
||||
&& score < DiscoverProfileMatcher.MinScoreThreshold)
|
||||
return default;
|
||||
if (score < DiscoverProfileMatcher.MinScoreThreshold)
|
||||
return default;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hard AND match for chip-only searches (backward compatible)
|
||||
if (!DiscoverProfileMatcher.Matches(profile, filters))
|
||||
return default;
|
||||
score = DiscoverProfileMatcher.Score(profile, filters);
|
||||
}
|
||||
|
||||
bool isOpenNow = hours?.IsOpenNow() ?? false;
|
||||
|
||||
var dto = new CafeDiscoverDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Slug,
|
||||
c.City,
|
||||
c.Address,
|
||||
c.LogoUrl,
|
||||
c.CoverImageUrl,
|
||||
c.IsVerified,
|
||||
Math.Round(avg, 1),
|
||||
count,
|
||||
CafeDiscoverProfileMapping.ToDto(profile),
|
||||
badges,
|
||||
gallery,
|
||||
isOpenNow,
|
||||
c.InstagramHandle,
|
||||
c.WebsiteUrl,
|
||||
score);
|
||||
return (dto, score, (object?)dto);
|
||||
})
|
||||
.Where(x => x.Item3 is not null)
|
||||
.Select(x => x.dto)
|
||||
.ToList();
|
||||
|
||||
if (filters.MinRating.HasValue)
|
||||
result = result.Where(c => c.AverageRating >= filters.MinRating.Value).ToList();
|
||||
|
||||
result = (filters.Sort?.ToLowerInvariant()) switch
|
||||
{
|
||||
"rating" => result.OrderByDescending(c => c.AverageRating).ThenByDescending(c => c.ReviewCount).ToList(),
|
||||
"reviews" => result.OrderByDescending(c => c.ReviewCount).ToList(),
|
||||
"score" => result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList(),
|
||||
_ => hasTextQuery
|
||||
? result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList()
|
||||
: result.OrderBy(c => c.Name).ToList()
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CafeReviewDto>> GetReviewsAsync(
|
||||
string cafeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
bool publicOnly = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 50);
|
||||
|
||||
var reviews = await _db.CafeReviews
|
||||
.Include(r => r.Photos)
|
||||
.Where(r => r.CafeId == cafeId && (!publicOnly || !r.IsHidden))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return reviews.Select(r => ToDto(r, publicView: true)).ToList();
|
||||
}
|
||||
|
||||
public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync(
|
||||
string cafeId,
|
||||
CreateCafeReviewRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId && c.IsVerified, cancellationToken);
|
||||
if (cafe is null) return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var ctx = _http.HttpContext;
|
||||
if (ctx is not null)
|
||||
{
|
||||
var availability = PublicCafeGuard.EnsureAcceptingPublicTraffic(cafe);
|
||||
if (!availability.Ok) return (null, availability.ErrorCode, availability.Message);
|
||||
|
||||
var ip = ClientIpResolver.GetClientIp(ctx);
|
||||
var writeCheck = await _abuse.CheckPublicWriteByIpAsync(ip, cancellationToken);
|
||||
if (!writeCheck.Allowed) return (null, writeCheck.ErrorCode, writeCheck.Message);
|
||||
|
||||
var captcha = await _abuse.VerifyCaptchaAsync(request.CaptchaToken, cancellationToken);
|
||||
if (!captcha.Ok) return (null, captcha.ErrorCode, captcha.Message);
|
||||
}
|
||||
|
||||
var entity = new CafeReview
|
||||
{
|
||||
CafeId = cafeId,
|
||||
AuthorName = request.AuthorName.Trim(),
|
||||
AuthorPhone = request.AuthorPhone?.Trim(),
|
||||
Rating = request.Rating,
|
||||
Comment = request.Comment?.Trim()
|
||||
};
|
||||
|
||||
_db.CafeReviews.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (ToDto(entity, publicView: true), null, null);
|
||||
}
|
||||
|
||||
public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync(
|
||||
string cafeId,
|
||||
CreateCafeReviewRequest request,
|
||||
IReadOnlyList<IFormFile> photos,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var baseResult = await CreateReviewAsync(cafeId, request, cancellationToken);
|
||||
if (baseResult.Data is null)
|
||||
return baseResult;
|
||||
|
||||
var files = photos?.Where(f => f.Length > 0).Take(MaxReviewPhotos).ToList() ?? [];
|
||||
if (files.Count == 0)
|
||||
return baseResult;
|
||||
|
||||
var review = await _db.CafeReviews
|
||||
.Include(r => r.Photos)
|
||||
.FirstAsync(r => r.Id == baseResult.Data.Id, cancellationToken);
|
||||
|
||||
var sort = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
var url = await _media.SaveReviewPhotoAsync(cafeId, file, cancellationToken);
|
||||
if (url is null) continue;
|
||||
review.Photos.Add(new CafeReviewPhoto
|
||||
{
|
||||
ReviewId = review.Id,
|
||||
Url = url,
|
||||
SortOrder = sort++
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (ToDto(review, publicView: true), null, null);
|
||||
}
|
||||
|
||||
public async Task<CafeReviewDto?> ReplyReviewAsync(
|
||||
string cafeId,
|
||||
string reviewId,
|
||||
string reply,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.CafeReviews
|
||||
.FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.OwnerReply = reply.Trim();
|
||||
entity.OwnerRepliedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity, publicView: false);
|
||||
}
|
||||
|
||||
public async Task<CafeReviewDto?> SetHiddenAsync(
|
||||
string cafeId,
|
||||
string reviewId,
|
||||
bool isHidden,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.CafeReviews
|
||||
.Include(r => r.Photos)
|
||||
.FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.IsHidden = isHidden;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity, publicView: false);
|
||||
}
|
||||
|
||||
public async Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reviews = await _db.CafeReviews
|
||||
.Where(r => r.CafeId == cafeId && !r.IsHidden)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (reviews.Count == 0) return (0, 0);
|
||||
return (Math.Round(reviews.Average(r => r.Rating), 1), reviews.Count);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CafeBadgePublicDto> MapBadges(Cafe c) =>
|
||||
DiscoverBadgeMapping.ToDtos(c)
|
||||
.Select(b => new CafeBadgePublicDto(b.Key, b.Label, b.Icon))
|
||||
.ToList();
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private static WorkingHoursSchedule? DeserializeHours(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<WorkingHoursSchedule>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeGallery(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json, _jsonOpts) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static CafeReviewDto ToDto(CafeReview r, bool publicView) => new(
|
||||
r.Id,
|
||||
r.AuthorName,
|
||||
r.Rating,
|
||||
r.Comment,
|
||||
r.OwnerReply,
|
||||
r.CreatedAt,
|
||||
r.Photos.OrderBy(p => p.SortOrder).Select(p => p.Url).ToList(),
|
||||
publicView ? false : r.IsHidden);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using Meezi.API.Models.Shifts;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record ShiftServiceResult<T>(bool Success, T? Data, string? ErrorCode = null, string? Field = null);
|
||||
|
||||
public interface IShiftService
|
||||
{
|
||||
Task<ShiftServiceResult<ShiftDto>> OpenShiftAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
decimal openingCash,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ShiftServiceResult<ShiftDto>> CloseShiftAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
decimal closingCash,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ShiftDto?> GetCurrentShiftAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<CashTransactionDto>?> GetTransactionsAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ShiftServiceResult<CashTransactionDto>> RecordTransactionAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
CashTransactionType type,
|
||||
PaymentMethod method,
|
||||
decimal amount,
|
||||
string createdByUserId,
|
||||
string? referenceId = null,
|
||||
string? note = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ShiftServiceResult<Shift>> RequireOpenShiftForBranchAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ShiftService : IShiftService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public ShiftService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<ShiftServiceResult<ShiftDto>> OpenShiftAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
decimal openingCash,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var branch = await _db.Branches.FirstOrDefaultAsync(
|
||||
b => b.Id == branchId && b.CafeId == cafeId && b.IsActive,
|
||||
cancellationToken);
|
||||
if (branch is null)
|
||||
return new ShiftServiceResult<ShiftDto>(false, null, "BRANCH_NOT_FOUND", "branchId");
|
||||
|
||||
var hasOpen = await _db.RegisterShifts.AnyAsync(
|
||||
s => s.BranchId == branchId && s.CafeId == cafeId && s.Status == ShiftStatus.Open,
|
||||
cancellationToken);
|
||||
if (hasOpen)
|
||||
return new ShiftServiceResult<ShiftDto>(false, null, "SHIFT_ALREADY_OPEN", "branchId");
|
||||
|
||||
var employeeExists = await _db.Employees.AnyAsync(
|
||||
e => e.Id == userId && e.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (!employeeExists)
|
||||
return new ShiftServiceResult<ShiftDto>(false, null, "USER_NOT_FOUND", "userId");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var shift = new Shift
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
OpenedByUserId = userId,
|
||||
OpenedAt = now,
|
||||
OpeningCash = openingCash,
|
||||
ExpectedCash = openingCash,
|
||||
Status = ShiftStatus.Open,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.RegisterShifts.Add(shift);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ShiftServiceResult<ShiftDto>(true, ToDto(shift));
|
||||
}
|
||||
|
||||
public async Task<ShiftServiceResult<ShiftDto>> CloseShiftAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
decimal closingCash,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var shift = await _db.RegisterShifts
|
||||
.Include(s => s.Transactions)
|
||||
.FirstOrDefaultAsync(s => s.Id == shiftId && s.CafeId == cafeId, cancellationToken);
|
||||
|
||||
if (shift is null)
|
||||
return new ShiftServiceResult<ShiftDto>(false, null, "SHIFT_NOT_FOUND");
|
||||
|
||||
if (shift.Status != ShiftStatus.Open)
|
||||
return new ShiftServiceResult<ShiftDto>(false, null, "SHIFT_ALREADY_CLOSED");
|
||||
|
||||
shift.ExpectedCash = CalculateExpectedCash(shift.OpeningCash, shift.Transactions);
|
||||
shift.ClosingCash = closingCash;
|
||||
shift.Discrepancy = closingCash - shift.ExpectedCash;
|
||||
shift.ClosedByUserId = userId;
|
||||
shift.ClosedAt = DateTime.UtcNow;
|
||||
shift.Status = ShiftStatus.Closed;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ShiftServiceResult<ShiftDto>(true, ToDto(shift));
|
||||
}
|
||||
|
||||
public async Task<ShiftDto?> GetCurrentShiftAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var shift = await _db.RegisterShifts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
s => s.CafeId == cafeId && s.BranchId == branchId && s.Status == ShiftStatus.Open,
|
||||
cancellationToken);
|
||||
|
||||
return shift is null ? null : ToDto(shift);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CashTransactionDto>?> GetTransactionsAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var shiftExists = await _db.RegisterShifts.AnyAsync(
|
||||
s => s.Id == shiftId && s.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
if (!shiftExists) return null;
|
||||
|
||||
var rows = await _db.CashTransactions
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ShiftId == shiftId && t.CafeId == cafeId)
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows.Select(ToTransactionDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ShiftServiceResult<CashTransactionDto>> RecordTransactionAsync(
|
||||
string cafeId,
|
||||
string shiftId,
|
||||
CashTransactionType type,
|
||||
PaymentMethod method,
|
||||
decimal amount,
|
||||
string createdByUserId,
|
||||
string? referenceId = null,
|
||||
string? note = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (amount <= 0)
|
||||
return new ShiftServiceResult<CashTransactionDto>(false, null, "INVALID_AMOUNT", "amount");
|
||||
|
||||
var shift = await _db.RegisterShifts.FirstOrDefaultAsync(
|
||||
s => s.Id == shiftId && s.CafeId == cafeId,
|
||||
cancellationToken);
|
||||
|
||||
if (shift is null)
|
||||
return new ShiftServiceResult<CashTransactionDto>(false, null, "SHIFT_NOT_FOUND");
|
||||
|
||||
if (shift.Status != ShiftStatus.Open)
|
||||
return new ShiftServiceResult<CashTransactionDto>(false, null, "SHIFT_ALREADY_CLOSED");
|
||||
|
||||
var tx = new CashTransaction
|
||||
{
|
||||
CafeId = cafeId,
|
||||
BranchId = shift.BranchId,
|
||||
ShiftId = shiftId,
|
||||
Type = type,
|
||||
Method = method,
|
||||
Amount = amount,
|
||||
ReferenceId = referenceId,
|
||||
Note = note,
|
||||
CreatedByUserId = createdByUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.CashTransactions.Add(tx);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ShiftServiceResult<CashTransactionDto>(true, ToTransactionDto(tx));
|
||||
}
|
||||
|
||||
public async Task<ShiftServiceResult<Shift>> RequireOpenShiftForBranchAsync(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var shift = await _db.RegisterShifts.FirstOrDefaultAsync(
|
||||
s => s.CafeId == cafeId && s.BranchId == branchId && s.Status == ShiftStatus.Open,
|
||||
cancellationToken);
|
||||
|
||||
if (shift is null)
|
||||
return new ShiftServiceResult<Shift>(false, null, "NO_OPEN_SHIFT", "branchId");
|
||||
|
||||
return new ShiftServiceResult<Shift>(true, shift);
|
||||
}
|
||||
|
||||
internal static decimal CalculateExpectedCash(decimal openingCash, IEnumerable<CashTransaction> transactions)
|
||||
{
|
||||
var cashPayments = transactions
|
||||
.Where(t => t.Type == CashTransactionType.OrderPayment && t.Method == PaymentMethod.Cash)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
var withdrawals = transactions
|
||||
.Where(t => t.Type == CashTransactionType.Withdrawal)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
return openingCash + cashPayments - withdrawals;
|
||||
}
|
||||
|
||||
private static ShiftDto ToDto(Shift s) => new(
|
||||
s.Id,
|
||||
s.CafeId,
|
||||
s.BranchId,
|
||||
s.OpenedByUserId,
|
||||
s.ClosedByUserId,
|
||||
s.OpenedAt,
|
||||
s.ClosedAt,
|
||||
s.OpeningCash,
|
||||
s.ClosingCash,
|
||||
s.ExpectedCash,
|
||||
s.Discrepancy,
|
||||
s.Status);
|
||||
|
||||
private static CashTransactionDto ToTransactionDto(CashTransaction t) => new(
|
||||
t.Id,
|
||||
t.ShiftId,
|
||||
t.BranchId,
|
||||
t.Type,
|
||||
t.Method,
|
||||
t.Amount,
|
||||
t.ReferenceId,
|
||||
t.Note,
|
||||
t.CreatedByUserId,
|
||||
t.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ISmsMarketingService
|
||||
{
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SmsMarketingService : ISmsMarketingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly ILogger<SmsMarketingService> _logger;
|
||||
|
||||
public SmsMarketingService(
|
||||
AppDbContext db,
|
||||
ISmsService smsService,
|
||||
IConnectionMultiplexer redis,
|
||||
ILogger<SmsMarketingService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_smsService = smsService;
|
||||
_redis = redis;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SmsUsageDto> GetUsageAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
var limit = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
if (maxSms == 0)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan.");
|
||||
|
||||
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
|
||||
if (phones.Count == 0)
|
||||
return (false, null, "NOT_FOUND", "No recipients found.");
|
||||
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||
|
||||
var sent = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var phone in phones)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(phone, request.Message, cancellationToken);
|
||||
sent++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send SMS to recipient");
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sent > 0)
|
||||
await IncrementUsageAsync(cafeId, month, sent);
|
||||
|
||||
return (true, new SmsCampaignResult(sent, failed), null, null);
|
||||
}
|
||||
|
||||
private async Task<List<string>> ResolvePhonesAsync(
|
||||
string cafeId,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Phones is { Count: > 0 })
|
||||
{
|
||||
return request.Phones
|
||||
.Select(PhoneNormalizer.Normalize)
|
||||
.Where(PhoneNormalizer.IsValidIranMobile)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var query = _db.Customers.Where(c => c.CafeId == cafeId);
|
||||
if (request.TargetGroup.HasValue)
|
||||
query = query.Where(c => c.Group == request.TargetGroup.Value);
|
||||
|
||||
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<int> GetUsedCountAsync(string cafeId, string month)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var value = await redis.StringGetAsync(UsageKey(cafeId, month));
|
||||
return value.HasValue ? (int)value : 0;
|
||||
}
|
||||
|
||||
private async Task IncrementUsageAsync(string cafeId, string month, int count)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
var key = UsageKey(cafeId, month);
|
||||
await redis.StringIncrementAsync(key, count);
|
||||
await redis.KeyExpireAsync(key, TimeSpan.FromDays(40));
|
||||
}
|
||||
|
||||
private static string UsageKey(string cafeId, string month) => $"sms:usage:{cafeId}:{month}";
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.API.Models.Snappfood;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ISnappfoodWebhookService
|
||||
{
|
||||
bool VerifySignature(string rawBody, string? signatureHeader);
|
||||
Task<(bool Success, string? Error)> ProcessOrderAsync(SnappfoodWebhookOrder order, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SnappfoodWebhookService : ISnappfoodWebhookService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IKdsNotifier _kdsNotifier;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<SnappfoodWebhookService> _logger;
|
||||
|
||||
public SnappfoodWebhookService(
|
||||
AppDbContext db,
|
||||
IKdsNotifier kdsNotifier,
|
||||
IConfiguration configuration,
|
||||
ILogger<SnappfoodWebhookService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_kdsNotifier = kdsNotifier;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool VerifySignature(string rawBody, string? signatureHeader)
|
||||
{
|
||||
var secret = _configuration["Snappfood:WebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
return false;
|
||||
|
||||
var expected = ComputeHmac(rawBody, secret);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(signatureHeader.Trim()));
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> ProcessOrderAsync(
|
||||
SnappfoodWebhookOrder order,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.SnappfoodVendorId == order.VendorId, cancellationToken);
|
||||
|
||||
if (cafe is null)
|
||||
return (false, "Unknown vendor.");
|
||||
|
||||
var existing = await _db.Orders
|
||||
.AnyAsync(o => o.CafeId == cafe.Id && o.SnappfoodOrderId == order.OrderId, cancellationToken);
|
||||
if (existing)
|
||||
return (true, null);
|
||||
|
||||
var menuItems = await _db.MenuItems
|
||||
.Where(m => m.CafeId == cafe.Id && m.IsAvailable)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var orderItems = new List<OrderItem>();
|
||||
decimal subtotal = 0;
|
||||
|
||||
foreach (var item in order.Items)
|
||||
{
|
||||
var menuItem = menuItems.FirstOrDefault(m =>
|
||||
m.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||
(m.NameEn != null && m.NameEn.Equals(item.Name, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (menuItem is null)
|
||||
{
|
||||
_logger.LogWarning("Snappfood item {Name} not matched for cafe {CafeId}", item.Name, cafe.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lineTotal = item.UnitPrice * item.Quantity;
|
||||
subtotal += lineTotal;
|
||||
orderItems.Add(new OrderItem
|
||||
{
|
||||
MenuItemId = menuItem.Id,
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
Notes = "Snappfood"
|
||||
});
|
||||
}
|
||||
|
||||
if (orderItems.Count == 0)
|
||||
return (false, "No menu items matched.");
|
||||
|
||||
var taxRate = await _db.Taxes
|
||||
.Where(t => t.CafeId == cafe.Id && t.IsDefault)
|
||||
.Select(t => t.Rate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var taxTotal = Math.Round(subtotal * taxRate / 100m, 0);
|
||||
var total = order.Total > 0 ? order.Total : subtotal + taxTotal;
|
||||
|
||||
var displayNumber = await AllocateDisplayNumberAsync(cafe.Id, cancellationToken);
|
||||
var meeziOrder = new Order
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
OrderType = OrderType.Delivery,
|
||||
Status = OrderStatus.Confirmed,
|
||||
DisplayNumber = displayNumber,
|
||||
SnappfoodOrderId = order.OrderId,
|
||||
Subtotal = subtotal,
|
||||
TaxTotal = taxTotal,
|
||||
Total = total,
|
||||
Items = orderItems
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(order.CustomerPhone))
|
||||
{
|
||||
var customer = await _db.Customers
|
||||
.FirstOrDefaultAsync(c => c.CafeId == cafe.Id && c.Phone == order.CustomerPhone, cancellationToken);
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer
|
||||
{
|
||||
CafeId = cafe.Id,
|
||||
Name = order.CustomerName ?? "Snappfood",
|
||||
Phone = order.CustomerPhone,
|
||||
Group = CustomerGroup.New
|
||||
};
|
||||
_db.Customers.Add(customer);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
meeziOrder.CustomerId = customer.Id;
|
||||
}
|
||||
|
||||
_db.Orders.Add(meeziOrder);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loaded = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.Include(o => o.Table)
|
||||
.FirstAsync(o => o.Id == meeziOrder.Id, cancellationToken);
|
||||
|
||||
await _kdsNotifier.NotifyOrderCreatedAsync(cafe.Id, MapLive(loaded), cancellationToken);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static string ComputeHmac(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
|
||||
private async Task<int> AllocateDisplayNumberAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var max = await _db.Orders
|
||||
.Where(o => o.CafeId == cafeId)
|
||||
.MaxAsync(o => (int?)o.DisplayNumber, ct);
|
||||
return (max ?? 0) + 1;
|
||||
} private static LiveOrderDto MapLive(Order o) => new(
|
||||
o.Id,
|
||||
o.DisplayNumber > 0 ? o.DisplayNumber : ReceiptPrintFormatting.StableDisplayNumberFromId(o.Id),
|
||||
o.Status,
|
||||
o.Table?.Number,
|
||||
o.OrderType,
|
||||
o.Total,
|
||||
o.CreatedAt,
|
||||
o.Items.Select(i => new OrderItemDto(
|
||||
i.Id,
|
||||
i.MenuItemId,
|
||||
i.MenuItem?.Name ?? "",
|
||||
i.Quantity,
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
using Meezi.API.Models.Taxes;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ITaxService
|
||||
{
|
||||
Task<IReadOnlyList<TaxDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<TaxDto?> CreateAsync(string cafeId, CreateTaxRequest request, CancellationToken cancellationToken = default);
|
||||
Task<TaxDto?> UpdateAsync(string cafeId, string id, UpdateTaxRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class TaxService : ITaxService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public TaxService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<TaxDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _db.Taxes
|
||||
.Where(t => t.CafeId == cafeId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => ToDto(t))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TaxDto?> CreateAsync(string cafeId, CreateTaxRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request.IsDefault)
|
||||
{
|
||||
var existing = await _db.Taxes.Where(t => t.CafeId == cafeId && t.IsDefault).ToListAsync(cancellationToken);
|
||||
foreach (var t in existing) t.IsDefault = false;
|
||||
}
|
||||
|
||||
var entity = new Tax
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Name = request.Name,
|
||||
Rate = request.Rate,
|
||||
IsDefault = request.IsDefault,
|
||||
IsRequired = request.IsRequired,
|
||||
IsCompound = request.IsCompound
|
||||
};
|
||||
_db.Taxes.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<TaxDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string id,
|
||||
UpdateTaxRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Taxes.FirstOrDefaultAsync(t => t.Id == id && t.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Name is not null) entity.Name = request.Name;
|
||||
if (request.Rate.HasValue) entity.Rate = request.Rate.Value;
|
||||
if (request.IsDefault == true)
|
||||
{
|
||||
var others = await _db.Taxes.Where(t => t.CafeId == cafeId && t.Id != id).ToListAsync(cancellationToken);
|
||||
foreach (var t in others) t.IsDefault = false;
|
||||
entity.IsDefault = true;
|
||||
}
|
||||
else if (request.IsDefault.HasValue) entity.IsDefault = request.IsDefault.Value;
|
||||
|
||||
if (request.IsRequired.HasValue) entity.IsRequired = request.IsRequired.Value;
|
||||
if (request.IsCompound.HasValue) entity.IsCompound = request.IsCompound.Value;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Taxes.FirstOrDefaultAsync(t => t.Id == id && t.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
var wasDefault = entity.IsDefault;
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
entity.IsDefault = false;
|
||||
|
||||
var categories = await _db.MenuCategories
|
||||
.Where(c => c.CafeId == cafeId && c.TaxId == id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Tax? replacementDefault = null;
|
||||
if (wasDefault)
|
||||
{
|
||||
replacementDefault = await _db.Taxes
|
||||
.Where(t => t.CafeId == cafeId && t.Id != id)
|
||||
.OrderBy(t => t.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (replacementDefault is not null)
|
||||
replacementDefault.IsDefault = true;
|
||||
}
|
||||
|
||||
foreach (var category in categories)
|
||||
category.TaxId = replacementDefault?.Id;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TaxDto ToDto(Tax t) => new(t.Id, t.Name, t.Rate, t.IsDefault, t.IsRequired, t.IsCompound);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public record TerminalInfoDto(string TerminalId, DateTime? LastSeenUtc);
|
||||
|
||||
public interface ITerminalRegistryService
|
||||
{
|
||||
Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
string terminalId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<TerminalInfoDto>> ListAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeAsync(string cafeId, string terminalId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class TerminalRegistryService : ITerminalRegistryService
|
||||
{
|
||||
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
|
||||
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
|
||||
|
||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
string terminalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terminalId))
|
||||
return (false, "TERMINAL_ID_REQUIRED", "Terminal id is required.");
|
||||
|
||||
terminalId = terminalId.Trim();
|
||||
var db = _redis.GetDatabase();
|
||||
var setKey = $"terminals:{cafeId}";
|
||||
var max = PlanLimits.MaxTerminals(tier);
|
||||
|
||||
if (max == int.MaxValue)
|
||||
{
|
||||
await db.SetAddAsync(setKey, terminalId);
|
||||
await db.KeyExpireAsync(setKey, TerminalTtl);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
var members = await db.SetMembersAsync(setKey);
|
||||
var known = members.Select(m => m.ToString()).ToHashSet(StringComparer.Ordinal);
|
||||
if (known.Contains(terminalId))
|
||||
{
|
||||
await db.KeyExpireAsync(setKey, TerminalTtl);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
if (known.Count >= max)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Terminal limit reached for your plan. Please upgrade.");
|
||||
|
||||
await db.SetAddAsync(setKey, terminalId);
|
||||
await db.KeyExpireAsync(setKey, TerminalTtl);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TerminalInfoDto>> ListAsync(
|
||||
string cafeId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var setKey = $"terminals:{cafeId}";
|
||||
var members = await db.SetMembersAsync(setKey);
|
||||
return members
|
||||
.Select(m => m.ToString())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Select(id => new TerminalInfoDto(id!, null))
|
||||
.OrderBy(t => t.TerminalId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(
|
||||
string cafeId,
|
||||
string terminalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terminalId)) return;
|
||||
var db = _redis.GetDatabase();
|
||||
await db.SetRemoveAsync($"terminals:{cafeId}", terminalId.Trim());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user