Files
meezi/src/Meezi.API/Services/ShiftService.cs
T
soroush.asadi ef15fd6247 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>
2026-05-27 21:33:48 +03:30

264 lines
8.6 KiB
C#

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);
}