using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Authorization;
using foodmarket.Application.Common;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Money;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт
/// получаем через GET /{id}/report, который возвращает всё в
/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница).
///
/// Если текущий пользователь — не Admin, он видит только свои смены;
/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е
/// (нужна отдельная логика «моя смена» для кассира).
[ApiController]
[Authorize]
[Route("api/money/shifts")]
public class CashShiftsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IMoneyService _money;
public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyService money)
{
_db = db;
_tenant = tenant;
_money = money;
}
public record CashShiftRow(
Guid Id, string Number, CashShiftStatus Status,
Guid RetailPointId, string RetailPointName,
Guid CashAccountId, string CashAccountName,
Guid? AcquiringAccountId, string? AcquiringAccountName,
Guid CashierUserId, string? CashierName,
DateTime OpenedAt, DateTime? ClosedAt,
decimal OpeningFloat,
decimal SalesCash, decimal SalesCard,
decimal ReturnsCash, decimal ReturnsCard,
decimal DrawerIn, decimal DrawerOut,
decimal ExpectedCash, decimal? ActualCash, decimal CashDifference,
string? Notes, uint Xmin);
public record CashDrawerOpRow(Guid Id, CashDrawerDirection Direction, decimal Amount, string Reason, DateTime At, Guid ByUserId);
public record OpenShiftInput(
Guid RetailPointId, Guid CashAccountId,
Guid? AcquiringAccountId,
[Range(0, 1e12)] decimal OpeningFloat);
public record CloseShiftInput(
[Range(0, 1e12)] decimal ActualCash,
[MaxLength(1000)] string? Notes);
public record DrawerOpInput(
CashDrawerDirection Direction,
[Range(0.0001, 1e12)] decimal Amount,
[Required, MaxLength(500)] string Reason);
[HttpGet, RequiresPermission("MoneyView,CashShiftOperate")]
public async Task>> List(
[FromQuery] PagedRequest req,
[FromQuery] CashShiftStatus? status,
[FromQuery] Guid? retailPointId,
[FromQuery] bool? mine,
CancellationToken ct)
{
var q = _db.CashShifts.AsNoTracking().AsQueryable();
if (status is not null) q = q.Where(x => x.Status == status);
if (retailPointId is not null) q = q.Where(x => x.RetailPointId == retailPointId);
if (mine == true)
{
var userId = CurrentUserId();
q = q.Where(x => x.CashierUserId == userId);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.OpenedAt)
.Skip(req.Skip).Take(req.Take)
.Select(x => new
{
x.Id, x.Number, x.Status, x.RetailPointId,
RetailPointName = _db.RetailPoints.Where(r => r.Id == x.RetailPointId).Select(r => r.Name).FirstOrDefault() ?? "",
x.CashAccountId,
CashAccountName = _db.MoneyAccounts.Where(a => a.Id == x.CashAccountId).Select(a => a.Name).FirstOrDefault() ?? "",
x.AcquiringAccountId,
AcquiringAccountName = x.AcquiringAccountId == null ? null : _db.MoneyAccounts.Where(a => a.Id == x.AcquiringAccountId).Select(a => a.Name).FirstOrDefault(),
x.CashierUserId,
x.OpenedAt, x.ClosedAt,
x.OpeningFloat,
x.SalesCash, x.SalesCard, x.ReturnsCash, x.ReturnsCard,
x.DrawerIn, x.DrawerOut, x.ExpectedCash, x.ActualCash, x.CashDifference,
x.Notes,
Xmin = Microsoft.EntityFrameworkCore.EF.Property(x, "xmin"),
})
.ToListAsync(ct);
var items = rows.Select(r => new CashShiftRow(
r.Id, r.Number, r.Status, r.RetailPointId, r.RetailPointName,
r.CashAccountId, r.CashAccountName,
r.AcquiringAccountId, r.AcquiringAccountName,
r.CashierUserId, null,
r.OpenedAt, r.ClosedAt, r.OpeningFloat,
r.SalesCash, r.SalesCard, r.ReturnsCash, r.ReturnsCard,
r.DrawerIn, r.DrawerOut, r.ExpectedCash, r.ActualCash, r.CashDifference,
r.Notes, r.Xmin)).ToList();
return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView,CashShiftOperate")]
public async Task> Get(Guid id, CancellationToken ct)
{
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (s is null) return NotFound();
var ops = await _db.CashDrawerOperations.AsNoTracking()
.Where(o => o.CashShiftId == id)
.OrderBy(o => o.At)
.Select(o => new CashDrawerOpRow(o.Id, o.Direction, o.Amount, o.Reason, o.At, o.ByUserId))
.ToListAsync(ct);
return Ok(new { shift = s, drawerOperations = ops });
}
[HttpGet("current"), RequiresPermission("CashShiftOperate")]
public async Task> Current([FromQuery] Guid retailPointId, CancellationToken ct)
{
var userId = CurrentUserId();
var s = await _db.CashShifts.AsNoTracking()
.FirstOrDefaultAsync(x => x.RetailPointId == retailPointId && x.Status == CashShiftStatus.Open, ct);
return s is null ? Ok(new { open = false }) : Ok(new { open = true, shift = s, isMine = s.CashierUserId == userId });
}
[HttpPost("open"), RequiresPermission("CashShiftOperate")]
public async Task> Open([FromBody] OpenShiftInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.OpenShiftAsync(input.RetailPointId, input.CashAccountId,
input.AcquiringAccountId, input.OpeningFloat, userId, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: r.Status == MoneyServiceStatus.ShiftAlreadyOpen ? 409 : 400,
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.ShiftId, number = r.Number });
}
[HttpPost("{id:guid}/close"), RequiresPermission("CashShiftOperate")]
public async Task> Close(Guid id, [FromBody] CloseShiftInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.CloseShiftAsync(id, input.ActualCash, userId, input.Notes, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message, statusCode: 400,
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { expectedCash = r.ExpectedCash, difference = r.CashDifference });
}
[HttpPost("{id:guid}/drawer"), RequiresPermission("CashShiftOperate")]
public async Task> Drawer(Guid id, [FromBody] DrawerOpInput input, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.AddDrawerOperationAsync(id, input.Direction, input.Amount, input.Reason, userId, ct);
if (r.Status != MoneyServiceStatus.Ok)
return Problem(r.Message,
statusCode: r.Status switch
{
MoneyServiceStatus.NotFound => 404,
MoneyServiceStatus.InsufficientFunds => 409,
_ => 400,
},
type: $"https://food-market.kz/errors/{r.ErrorCode}");
return Ok(new { id = r.OperationId });
}
/// Z-отчёт: все цифры смены + внесения/выплаты для печати.
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView,CashShiftOperate")]
public async Task> Report(Guid id, CancellationToken ct)
{
var s = await _db.CashShifts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (s is null) return NotFound();
var ops = await _db.CashDrawerOperations.AsNoTracking()
.Where(o => o.CashShiftId == id)
.OrderBy(o => o.At)
.ToListAsync(ct);
return Ok(new
{
number = s.Number,
openedAt = s.OpenedAt,
closedAt = s.ClosedAt,
openingFloat = s.OpeningFloat,
sales = new { cash = s.SalesCash, card = s.SalesCard },
returns = new { cash = s.ReturnsCash, card = s.ReturnsCard },
drawer = new { @in = s.DrawerIn, @out = s.DrawerOut, items = ops },
expectedCash = s.ExpectedCash,
actualCash = s.ActualCash,
cashDifference = s.CashDifference,
notes = s.Notes,
});
}
private Guid CurrentUserId()
{
var sub = User.FindFirst("sub")?.Value
?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(sub, out var g) ? g : Guid.Empty;
}
}