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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+198
View File
@@ -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);
}
+307
View File
@@ -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);
}
}
+185
View File
@@ -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();
}
}
+188
View File
@@ -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);
}
+136
View File
@@ -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();
}
}
+228
View File
@@ -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;
}
}
+185
View File
@@ -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);
}
+336
View File
@@ -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);
}
+18
View File
@@ -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);
+10
View File
@@ -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);
}
+550
View File
@@ -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}";
}
+26
View File
@@ -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);
}
}
+92
View File
@@ -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);
}
}
+25
View File
@@ -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);
}
}
+88
View File
@@ -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=");
}
+229
View File
@@ -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);
}
}
+117
View File
@@ -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"
};
}
+125
View File
@@ -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);
}
}
+120
View File
@@ -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()
};
}
+424
View File
@@ -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);
}
+220
View File
@@ -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));
}
}
+51
View File
@@ -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."
};
}
}
+312
View File
@@ -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);
}
+358
View File
@@ -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);
}
+263
View File
@@ -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
+111
View File
@@ -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());
}
}