food-market/src/food-market.api/Controllers/Money/CashShiftsController.cs
nns 1e6d7fb2ab fix(money): движения смены попадают в остатки счетов + запрет отрицательного остатка
Раньше продажи/возвраты/внесения/выплаты/расхождение смены не влияли на остаток
кассы (касса уходила в −, эквайринг оставался 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>
2026-10-08 03:11:09 +05:00

213 lines
9.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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