food-market/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs
nns a25b5b3277
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
feat(money): бэкенд модуля «Деньги» для простого режима (пп.0–6)
Сверка с МойСкладом (docs/money-moysklad-reference.md) + домен/инфра/API:

- Счета и кассы (MoneyAccount: Cash/Safe/Bank/Acquiring, начальный остаток,
  привязка к магазину, IsDefault/IsActive, инвариант CurrentBalance).
- Денежные документы (MoneyTransaction: PaymentIn/Out, CashIn/Out, переводы
  парой TransferOut↔TransferIn; статьи расходов ExpenseItem; проведение/отмена
  в Serializable-транзакции через MoneyService).
- Оплата поставщикам: Supply/SupplierReturn.PayedSum + MoneyTransactionLink,
  SuppliesController отдаёт PaymentStatus + фильтр paymentStatus.
- Взаиморасчёты: CounterpartyAdjustment + MoneyReportsController.SupplierDebts.
- Смены кассы: CashShift + CashDrawerOperation (open/close, expected↔actual,
  Z-отчёт), запрет розничной продажи без открытой смены в RetailSalesController.
- Отчёт «Деньги»: balances/movement с проверкой инварианта остатков.

Права: RolePermissions +MoneyView/MoneyEdit/CashShiftOperate; сидер раздаёт
кассиру CashShiftOperate+MoneyView, кладовщику — нет; 5 системных статей
расходов на новую орг. Миграция Phase33a (ручная, с [Migration]+[DbContext]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 21:52:04 +05:00

372 lines
18 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.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Money;
/// <summary>Платежи и ордера (единый reпозит на все виды <see cref="MoneyTransactionKind"/>
/// кроме «ящиковых» внутри смены — те через смену).
///
/// Создание — <c>POST /</c>, где <c>Kind</c> определяет тип документа.
/// Переводы между своими счетами делаются одним <c>POST /transfers</c>,
/// внутри атомарно создаётся пара TransferOut + TransferIn (с
/// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная,
/// см. <see cref="IMoneyService"/>.
///
/// Проведение / отмена — <c>POST /{id}/post</c>, <c>POST /{id}/unpost</c>.
/// Привязка к приёмке/возврату — через поле <c>Links</c>, разноска суммы
/// по правилу «Σ LinkedSum ≤ Amount» (валидация в <see cref="MoneyService"/>).</summary>
[ApiController]
[Authorize]
[Route("api/money/transactions")]
public class MoneyTransactionsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IMoneyService _money;
public MoneyTransactionsController(AppDbContext db, ITenantContext tenant, IMoneyService money)
{
_db = db;
_tenant = tenant;
_money = money;
}
public record MoneyTxLinkRow(Guid? SupplyId, string? SupplyNumber, Guid? SupplierReturnId, string? SupplierReturnNumber, decimal LinkedSum);
public record MoneyTxRow(
Guid Id, string Number, DateTime Date,
MoneyTransactionKind Kind, MoneyTransactionStatus Status,
Guid AccountId, string AccountName,
Guid? CounterpartyId, string? CounterpartyName,
Guid? ExpenseItemId, string? ExpenseItemName,
Guid CurrencyId, string CurrencyCode,
decimal Amount, string? Purpose, string? Notes,
Guid? LinkedTransactionId,
DateTime? PostedAt,
IReadOnlyList<MoneyTxLinkRow> Links,
uint Xmin);
public record MoneyTxLinkInput(Guid? SupplyId, Guid? SupplierReturnId, [Range(0, 1e12)] decimal LinkedSum);
public record MoneyTxInput(
DateTime Date,
MoneyTransactionKind Kind,
Guid AccountId,
Guid? CounterpartyId,
Guid? ExpenseItemId,
Guid CurrencyId,
[Range(0, 1e12)] decimal Amount,
[MaxLength(500)] string? Purpose,
[MaxLength(1000)] string? Notes,
IReadOnlyList<MoneyTxLinkInput>? Links = null,
uint? Xmin = null);
[HttpGet, RequiresPermission("MoneyView")]
public async Task<ActionResult<PagedResult<MoneyTxRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] MoneyTransactionKind? kind,
[FromQuery] MoneyTransactionStatus? status,
[FromQuery] Guid? accountId,
[FromQuery] Guid? counterpartyId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
var q = _db.MoneyTransactions.AsNoTracking().AsQueryable();
if (kind is not null) q = q.Where(x => x.Kind == kind);
if (status is not null) q = q.Where(x => x.Status == status);
if (accountId is not null) q = q.Where(x => x.AccountId == accountId);
if (counterpartyId is not null) q = q.Where(x => x.CounterpartyId == counterpartyId);
if (from is not null) q = q.Where(x => x.Date >= from);
if (to is not null) q = q.Where(x => x.Date < to);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.Number.ToLower().Contains(s)
|| (x.Purpose != null && x.Purpose.ToLower().Contains(s)));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.Date).ThenByDescending(x => x.Number)
.Skip(req.Skip).Take(req.Take)
.Select(x => new
{
x.Id, x.Number, x.Date, x.Kind, x.Status,
x.AccountId, AccountName = _db.MoneyAccounts.Where(a => a.Id == x.AccountId).Select(a => a.Name).FirstOrDefault() ?? "",
x.CounterpartyId, CounterpartyName = x.CounterpartyId == null ? null : _db.Counterparties.Where(c => c.Id == x.CounterpartyId).Select(c => c.Name).FirstOrDefault(),
x.ExpenseItemId, ExpenseItemName = x.ExpenseItemId == null ? null : _db.ExpenseItems.Where(e => e.Id == x.ExpenseItemId).Select(e => e.Name).FirstOrDefault(),
x.CurrencyId, CurrencyCode = _db.Currencies.Where(c => c.Id == x.CurrencyId).Select(c => c.Code).FirstOrDefault() ?? "",
x.Amount, x.Purpose, x.Notes,
x.LinkedTransactionId, x.PostedAt,
Xmin = Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin"),
})
.ToListAsync(ct);
var ids = rows.Select(r => r.Id).ToList();
var links = await _db.MoneyTransactionLinks.AsNoTracking()
.Where(l => ids.Contains(l.TransactionId))
.Select(l => new
{
l.TransactionId,
l.SupplyId, SupplyNumber = l.Supply != null ? l.Supply.Number : null,
l.SupplierReturnId, SRNumber = l.SupplierReturn != null ? l.SupplierReturn.Number : null,
l.LinkedSum,
})
.ToListAsync(ct);
var items = rows.Select(r => new MoneyTxRow(
r.Id, r.Number, r.Date, r.Kind, r.Status,
r.AccountId, r.AccountName, r.CounterpartyId, r.CounterpartyName,
r.ExpenseItemId, r.ExpenseItemName,
r.CurrencyId, r.CurrencyCode,
r.Amount, r.Purpose, r.Notes,
r.LinkedTransactionId, r.PostedAt,
links.Where(l => l.TransactionId == r.Id)
.Select(l => new MoneyTxLinkRow(l.SupplyId, l.SupplyNumber, l.SupplierReturnId, l.SRNumber, l.LinkedSum))
.ToList(),
r.Xmin)).ToList();
return new PagedResult<MoneyTxRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}"), RequiresPermission("MoneyView")]
public async Task<ActionResult<MoneyTxRow>> Get(Guid id, CancellationToken ct)
{
var row = await ReadOneAsync(id, ct);
return row is null ? NotFound() : row;
}
[HttpPost, RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyTxRow>> Create([FromBody] MoneyTxInput input, CancellationToken ct)
{
if (input.Kind == MoneyTransactionKind.TransferIn || input.Kind == MoneyTransactionKind.TransferOut)
return Problem("Переводы между счетами создавайте через /api/money/transfers.",
statusCode: 400, type: "https://food-market.kz/errors/use-transfers-endpoint");
if (input.Amount <= 0)
return Problem("Сумма должна быть больше нуля.", statusCode: 400,
type: "https://food-market.kz/errors/amount-must-be-positive");
if ((input.Kind == MoneyTransactionKind.PaymentOut || input.Kind == MoneyTransactionKind.CashOut)
&& input.ExpenseItemId is null && (input.Links is null || input.Links.Count == 0))
return Problem("Расходный документ без привязок к документам должен иметь статью расхода.",
statusCode: 400, type: "https://food-market.kz/errors/expense-item-required");
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var mt = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = input.Kind,
Status = MoneyTransactionStatus.Draft,
AccountId = input.AccountId,
CounterpartyId = input.CounterpartyId,
ExpenseItemId = input.ExpenseItemId,
CurrencyId = input.CurrencyId,
Amount = input.Amount,
Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim(),
Notes = input.Notes,
};
foreach (var l in input.Links ?? Array.Empty<MoneyTxLinkInput>())
{
// CHECK в БД поймает некорректное; продублируем валидацией для внятной ошибки.
if ((l.SupplyId is null) == (l.SupplierReturnId is null))
return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.",
statusCode: 400, type: "https://food-market.kz/errors/link-single-target");
mt.Links.Add(new MoneyTransactionLink
{
OrganizationId = orgId,
SupplyId = l.SupplyId,
SupplierReturnId = l.SupplierReturnId,
LinkedSum = l.LinkedSum,
});
}
_db.MoneyTransactions.Add(mt);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = mt.Id }, await ReadOneAsync(mt.Id, ct));
}
[HttpPut("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<MoneyTxRow>> Update(Guid id, [FromBody] MoneyTxInput input, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.Include(m => m.Links).FirstOrDefaultAsync(m => m.Id == id, ct);
if (mt is null) return NotFound();
if (mt.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя редактировать. Отмените проведение сначала." });
if (input.Xmin is not null && input.Xmin.Value != 0)
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
mt.Date = input.Date;
mt.AccountId = input.AccountId;
mt.CounterpartyId = input.CounterpartyId;
mt.ExpenseItemId = input.ExpenseItemId;
mt.CurrencyId = input.CurrencyId;
mt.Amount = input.Amount;
mt.Purpose = string.IsNullOrWhiteSpace(input.Purpose) ? null : input.Purpose.Trim();
mt.Notes = input.Notes;
mt.UpdatedAt = DateTime.UtcNow;
_db.MoneyTransactionLinks.RemoveRange(mt.Links);
mt.Links.Clear();
foreach (var l in input.Links ?? Array.Empty<MoneyTxLinkInput>())
{
if ((l.SupplyId is null) == (l.SupplierReturnId is null))
return Problem("В каждой связи должен быть ровно один из SupplyId / SupplierReturnId.",
statusCode: 400, type: "https://food-market.kz/errors/link-single-target");
mt.Links.Add(new MoneyTransactionLink
{
OrganizationId = mt.OrganizationId,
SupplyId = l.SupplyId,
SupplierReturnId = l.SupplierReturnId,
LinkedSum = l.LinkedSum,
});
}
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException)
{
return Conflict(new { code = "concurrency-conflict" });
}
return (await ReadOneAsync(id, ct))!;
}
[HttpDelete("{id:guid}"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.FirstOrDefaultAsync(m => m.Id == id, ct);
if (mt is null) return NotFound();
if (mt.Status == MoneyTransactionStatus.Posted)
return Conflict(new { code = "already-posted", message = "Проведённый документ нельзя удалить." });
_db.MoneyTransactions.Remove(mt);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.PostAsync(id, userId, ct);
return MapResult(r);
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("MoneyEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var userId = CurrentUserId();
var r = await _money.UnpostAsync(id, userId, ct);
return MapResult(r);
}
public record TransferInput(
DateTime Date,
Guid FromAccountId,
Guid ToAccountId,
[Range(0.0001, 1e12)] decimal Amount,
[MaxLength(500)] string? Purpose);
[HttpPost("/api/money/transfers"), RequiresPermission("MoneyEdit")]
public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input, CancellationToken ct)
{
if (input.FromAccountId == input.ToAccountId)
return Problem("Нельзя перевести на тот же счёт.", statusCode: 400,
type: "https://food-market.kz/errors/transfer-same-account");
var orgId = _tenant.OrganizationId ?? Guid.Empty;
var from = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.FromAccountId, ct);
var to = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.Id == input.ToAccountId, ct);
if (from is null || to is null) return NotFound();
if (from.CurrencyId != to.CurrencyId)
return Problem("Перевод между счетами разной валюты не поддерживается в простом режиме.",
statusCode: 400, type: "https://food-market.kz/errors/transfer-currency-mismatch");
var outTx = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = MoneyTransactionKind.TransferOut,
Status = MoneyTransactionStatus.Draft,
AccountId = from.Id,
CurrencyId = from.CurrencyId,
Amount = input.Amount,
Purpose = input.Purpose,
};
var inTx = new MoneyTransaction
{
OrganizationId = orgId,
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
Date = input.Date,
Kind = MoneyTransactionKind.TransferIn,
Status = MoneyTransactionStatus.Draft,
AccountId = to.Id,
CurrencyId = to.CurrencyId,
Amount = input.Amount,
Purpose = input.Purpose,
};
_db.MoneyTransactions.AddRange(outTx, inTx);
await _db.SaveChangesAsync(ct);
// Теперь свяжем парой — после первого save появились Id.
outTx.LinkedTransactionId = inTx.Id;
inTx.LinkedTransactionId = outTx.Id;
await _db.SaveChangesAsync(ct);
return Ok(new { outId = outTx.Id, inId = inTx.Id, outNumber = outTx.Number, inNumber = inTx.Number });
}
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;
}
private IActionResult MapResult(MoneyServiceResult r) => r.Status switch
{
MoneyServiceStatus.Ok => NoContent(),
MoneyServiceStatus.NotFound => NotFound(),
MoneyServiceStatus.AlreadyPosted => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AlreadyDraft => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.OverpaidDocument => Conflict(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.LinkedSumMismatch => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.WrongCurrency => BadRequest(new { code = r.ErrorCode, message = r.Message }),
MoneyServiceStatus.AccountInactive => Conflict(new { code = r.ErrorCode, message = r.Message }),
_ => BadRequest(new { code = r.ErrorCode, message = r.Message }),
};
private async Task<MoneyTxRow?> ReadOneAsync(Guid id, CancellationToken ct)
{
var mt = await _db.MoneyTransactions.AsNoTracking()
.Include(x => x.Links).ThenInclude(l => l.Supply)
.Include(x => x.Links).ThenInclude(l => l.SupplierReturn)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (mt is null) return null;
var account = await _db.MoneyAccounts.AsNoTracking().Where(a => a.Id == mt.AccountId).Select(a => a.Name).FirstAsync(ct);
var counterparty = mt.CounterpartyId is null ? null :
await _db.Counterparties.AsNoTracking().Where(c => c.Id == mt.CounterpartyId).Select(c => c.Name).FirstOrDefaultAsync(ct);
var exp = mt.ExpenseItemId is null ? null :
await _db.ExpenseItems.AsNoTracking().Where(e => e.Id == mt.ExpenseItemId).Select(e => e.Name).FirstOrDefaultAsync(ct);
var currency = await _db.Currencies.AsNoTracking().Where(c => c.Id == mt.CurrencyId).Select(c => c.Code).FirstAsync(ct);
var xmin = await _db.MoneyTransactions.AsNoTracking().Where(x => x.Id == id)
.Select(x => Microsoft.EntityFrameworkCore.EF.Property<uint>(x, "xmin")).FirstAsync(ct);
return new MoneyTxRow(
mt.Id, mt.Number, mt.Date, mt.Kind, mt.Status,
mt.AccountId, account, mt.CounterpartyId, counterparty,
mt.ExpenseItemId, exp,
mt.CurrencyId, currency,
mt.Amount, mt.Purpose, mt.Notes,
mt.LinkedTransactionId, mt.PostedAt,
mt.Links.Select(l => new MoneyTxLinkRow(l.SupplyId, l.Supply?.Number, l.SupplierReturnId, l.SupplierReturn?.Number, l.LinkedSum)).ToList(),
xmin);
}
}