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
Сверка с МойСкладом (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>
372 lines
18 KiB
C#
372 lines
18 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.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);
|
||
}
|
||
}
|