Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток кассы (касса уходила в −, эквайринг оставался 0). Теперь при закрытии смены MoneyService.MaterializeShiftMovementsAsync создаёт Posted-движения money_transactions по типам (Category): продажи нал/карта, возвраты нал/карта, внесение, выплата, излишек/недостача — привязанные к смене, идемпотентно. Остаток кассы/эквайринга и отчёт «Деньги» теперь верны автоматически (всё читает money_transactions). Отрицательный остаток: расход (CashOut/PaymentOut/TransferOut) больше остатка счёта и выплата из смены больше наличных смены → 409 insufficient-funds; настройка Organization.AllowNegativeBalance (по умолч. выкл., меняет только Admin). Миграция Phase34 (idempotent): money_transactions.Category/.CashShiftId, organizations.AllowNegativeBalance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
9.7 KiB
C#
213 lines
9.7 KiB
C#
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;
|
||
|
||
/// <summary>Смены кассы — открыть/закрыть + внесения/выплаты. Z-отчёт
|
||
/// получаем через <c>GET /{id}/report</c>, который возвращает всё в
|
||
/// печатаемом виде (суммы продаж/возвратов, нал/карта, разница).
|
||
///
|
||
/// Если текущий пользователь — не Admin, он видит только свои смены;
|
||
/// если Admin — все. Это закреплено в контроллере, а не в query-filter'е
|
||
/// (нужна отдельная логика «моя смена» для кассира).</summary>
|
||
[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<ActionResult<PagedResult<CashShiftRow>>> 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<uint>(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<CashShiftRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||
}
|
||
|
||
[HttpGet("{id:guid}"), RequiresPermission("MoneyView,CashShiftOperate")]
|
||
public async Task<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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 });
|
||
}
|
||
|
||
/// <summary>Z-отчёт: все цифры смены + внесения/выплаты для печати.</summary>
|
||
[HttpGet("{id:guid}/report"), RequiresPermission("MoneyView,CashShiftOperate")]
|
||
public async Task<ActionResult<object>> 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;
|
||
}
|
||
}
|