ef15fd6247
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>
264 lines
8.6 KiB
C#
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);
|
|
}
|