Files
meezi/src/Meezi.API/Services/ShiftService.cs
T
soroush.asadi c47922414a
CI/CD / CI · API (dotnet build + test) (push) Successful in 51s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Failing after 1m12s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Has been skipped
feat: اصلاح سند payment corrections + audit-log & daily P&L views
Backend:
- POST /orders/{id}/payments/corrections (Manager/Owner): void wrong
  payments (marked Refunded, never deleted) and/or record replacements
  atomically; mandatory reason; requires an open register shift; full
  before/after written to the immutable audit trail.
- GET /orders/closed?date= — closed orders of one Iran-calendar day,
  paged, the browsing surface for corrections.
- CalculateExpectedCash now subtracts cash refunds so corrections keep
  the drawer expectation honest.

Dashboard (reports screen now has three tabs):
- عملکرد و سود: existing KPIs/charts + new day-by-day breakdown table
  (orders, revenue, expenses, net profit per Jalali day).
- اصلاح سند: closed-orders browser with payment chips + correction
  dialog (void checkboxes, replacement rows, live balance, reason).
- گزارش عملیات: filterable audit-log viewer (category, Jalali range,
  branch) with expandable structured details.

fa/en/ar translations included. 86 backend tests pass; dashboard tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:24:19 +03:30

269 lines
8.8 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);
// Payment corrections (اصلاح سند) refund cash back out of the drawer.
var cashRefunds = transactions
.Where(t => t.Type == CashTransactionType.Refund && t.Method == PaymentMethod.Cash)
.Sum(t => t.Amount);
var withdrawals = transactions
.Where(t => t.Type == CashTransactionType.Withdrawal)
.Sum(t => t.Amount);
return openingCash + cashPayments - cashRefunds - 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);
}