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; /// Платежи и ордера (единый reпозит на все виды /// кроме «ящиковых» внутри смены — те через смену). /// /// Создание — POST /, где Kind определяет тип документа. /// Переводы между своими счетами делаются одним POST /transfers, /// внутри атомарно создаётся пара TransferOut + TransferIn (с /// LinkedTransactionId в обе стороны); проведение/отмена — тоже парная, /// см. . /// /// Проведение / отмена — POST /{id}/post, POST /{id}/unpost. /// Привязка к приёмке/возврату — через поле Links, разноска суммы /// по правилу «Σ LinkedSum ≤ Amount» (валидация в ). [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 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? Links = null, uint? Xmin = null); [HttpGet, RequiresPermission("MoneyView")] public async Task>> 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(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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } [HttpGet("{id:guid}"), RequiresPermission("MoneyView")] public async Task> Get(Guid id, CancellationToken ct) { var row = await ReadOneAsync(id, ct); return row is null ? NotFound() : row; } [HttpPost, RequiresPermission("MoneyEdit")] public async Task> 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()) { // 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> 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()) { 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 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 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 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> 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 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(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); } }