feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
Reference in New Issue
Block a user