using System.ComponentModel.DataAnnotations; using foodmarket.Application.Common; using foodmarket.Application.Inventory; using foodmarket.Domain.Inventory; using foodmarket.Infrastructure.Persistence; using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Inventory; /// Списание (Loss): уменьшение склада с указанием причины /// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт /// тип с /// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы /// добавляем обратно — остаток всегда увеличится). [ApiController] [Authorize] [Route("api/inventory/losses")] public class LossesController : ControllerBase { private readonly AppDbContext _db; private readonly IStockService _stock; public LossesController(AppDbContext db, IStockService stock) { _db = db; _stock = stock; } public record LossListRow( Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, decimal Total, int LineCount, DateTime? PostedAt); public record LossLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder, decimal? StockAtStore); public record LossDto( Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, IReadOnlyList Lines); public record LossLineInput( Guid ProductId, [Range(0, 1e10)] decimal Quantity, [Range(0, 1e10)] decimal UnitCost); public record LossInput( DateTime Date, Guid StoreId, Guid CurrencyId, LossReason Reason, string? Notes, IReadOnlyList Lines); [HttpGet] public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] LossStatus? status, [FromQuery] LossReason? reason, [FromQuery] Guid? storeId, CancellationToken ct) { var q = from l in _db.Losses.AsNoTracking() join st in _db.Stores on l.StoreId equals st.Id join cu in _db.Currencies on l.CurrencyId equals cu.Id select new { l, st, cu }; if (status is not null) q = q.Where(x => x.l.Status == status); if (reason is not null) q = q.Where(x => x.l.Reason == reason); if (storeId is not null) q = q.Where(x => x.l.StoreId == storeId); if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); q = q.Where(x => x.l.Number.ToLower().Contains(s)); } var total = await q.CountAsync(ct); q = (req.Sort, req.Desc) switch { ("number", false) => q.OrderBy(x => x.l.Number), ("number", true) => q.OrderByDescending(x => x.l.Number), ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.l.Date), ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.l.Date), ("status", false) => q.OrderBy(x => x.l.Status).ThenByDescending(x => x.l.Date), ("status", true) => q.OrderByDescending(x => x.l.Status).ThenByDescending(x => x.l.Date), ("reason", false) => q.OrderBy(x => x.l.Reason).ThenByDescending(x => x.l.Date), ("reason", true) => q.OrderByDescending(x => x.l.Reason).ThenByDescending(x => x.l.Date), ("total", false) => q.OrderBy(x => x.l.Total).ThenByDescending(x => x.l.Date), ("total", true) => q.OrderByDescending(x => x.l.Total).ThenByDescending(x => x.l.Date), ("date", false) => q.OrderBy(x => x.l.Date).ThenBy(x => x.l.Number), _ => q.OrderByDescending(x => x.l.Date).ThenByDescending(x => x.l.Number), }; var items = await q .Skip(req.Skip).Take(req.Take) .Select(x => new LossListRow( x.l.Id, x.l.Number, x.l.Date, x.l.Status, x.l.Reason, x.st.Id, x.st.Name, x.cu.Id, x.cu.Code, x.l.Total, x.l.Lines.Count, x.l.PostedAt)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { var dto = await GetInternal(id, ct); return dto is null ? NotFound() : Ok(dto); } [HttpPost, RequiresPermission("LossEdit")] public async Task> Create([FromBody] LossInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( (nameof(input.StoreId), input.StoreId), (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); if (input.Lines is null || input.Lines.Count == 0) return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." }); var number = await GenerateNumberAsync(input.Date, ct); var loss = new Loss { Number = number, Date = input.Date, Status = LossStatus.Draft, StoreId = input.StoreId, CurrencyId = input.CurrencyId, Reason = input.Reason, Notes = input.Notes, }; var order = 0; foreach (var l in input.Lines) { loss.Lines.Add(new LossLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.Quantity * l.UnitCost, SortOrder = order++, }); } loss.Total = loss.Lines.Sum(x => x.LineTotal); _db.Losses.Add(loss); if (await SaveOrFkErrorAsync(ct) is { } err) return err; var dto = await GetInternal(loss.Id, ct); return CreatedAtAction(nameof(Get), new { id = loss.Id }, dto); } private async Task SaveOrFkErrorAsync(CancellationToken ct) { try { await _db.SaveChangesAsync(ct); return null; } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") { var name = pg.ConstraintName ?? ""; string field = name.Contains("Store") ? "storeId" : name.Contains("Currency") ? "currencyId" : name.Contains("Product") ? "productId" : "(unknown)"; return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); } } [HttpPut("{id:guid}"), RequiresPermission("LossEdit")] public async Task Update(Guid id, [FromBody] LossInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( (nameof(input.StoreId), input.StoreId), (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); if (input.Lines is null || input.Lines.Count == 0) return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." }); var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct); if (loss is null) return NotFound(); if (loss.Status != LossStatus.Draft) return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); loss.Date = input.Date; loss.StoreId = input.StoreId; loss.CurrencyId = input.CurrencyId; loss.Reason = input.Reason; loss.Notes = input.Notes; // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и // UPDATE losses падает с DbUpdateConcurrencyException «0 rows affected» // даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update. await _db.LossLines.Where(l => l.LossId == loss.Id).ExecuteDeleteAsync(ct); var order = 0; decimal total = 0; foreach (var l in input.Lines) { var lineTotal = l.Quantity * l.UnitCost; _db.LossLines.Add(new LossLine { LossId = loss.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = lineTotal, SortOrder = order++, }); total += lineTotal; } loss.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); } [HttpDelete("{id:guid}"), RequiresPermission("LossEdit")] public async Task Delete(Guid id, CancellationToken ct) { var loss = await _db.Losses.FirstOrDefaultAsync(l => l.Id == id, ct); if (loss is null) return NotFound(); if (loss.Status != LossStatus.Draft) return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); _db.Losses.Remove(loss); await _db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("{id:guid}/post"), RequiresPermission("LossEdit")] public async Task Post(Guid id, CancellationToken ct) { var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct); if (loss is null) return NotFound(); if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); // Защита от ухода stock в минус. var byProduct = loss.Lines.GroupBy(l => l.ProductId) .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList(); var productIds = byProduct.Select(x => x.ProductId).ToList(); var stocks = await _db.Stocks .Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId)) .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); var conflicts = new List(); foreach (var r in byProduct) { stocks.TryGetValue(r.ProductId, out var available); if (available < r.Quantity) { var name = await _db.Products.Where(p => p.Id == r.ProductId) .Select(p => p.Name).FirstOrDefaultAsync(ct); conflicts.Add(new { productId = r.ProductId, productName = name, writeOffQty = r.Quantity, available, }); } } if (conflicts.Count > 0) { return Conflict(new { error = "Нельзя списать больше, чем есть в наличии.", lines = conflicts, }); } await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var now = DateTime.UtcNow; foreach (var line in loss.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: loss.StoreId, Quantity: -line.Quantity, Type: MovementType.WriteOff, DocumentType: "loss", DocumentId: loss.Id, DocumentNumber: loss.Number, UnitCost: line.UnitCost, OccurredAt: loss.Date, Notes: $"reason={loss.Reason}"), ct); } loss.Status = LossStatus.Posted; loss.PostedAt = now; try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." }); } return NoContent(); } [HttpPost("{id:guid}/unpost"), RequiresPermission("LossEdit")] public async Task Unpost(Guid id, CancellationToken ct) { var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct); if (loss is null) return NotFound(); if (loss.Status != LossStatus.Posted) return Conflict(new { error = "Документ не проведён." }); // Reverse: возвращаем товар обратно — остаток только увеличится, проверки на минус не нужно. foreach (var line in loss.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: loss.StoreId, Quantity: line.Quantity, Type: MovementType.WriteOff, DocumentType: "loss-reversal", DocumentId: loss.Id, DocumentNumber: loss.Number, UnitCost: line.UnitCost, OccurredAt: DateTime.UtcNow, Notes: $"Отмена проведения документа {loss.Number}"), ct); } loss.Status = LossStatus.Draft; loss.PostedAt = null; await _db.SaveChangesAsync(ct); return NoContent(); } private static bool IsSerializationConflict(Exception ex) { for (Exception? e = ex; e is not null; e = e.InnerException) { if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" }) return true; } return false; } private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) { var year = date.Year; var prefix = $"С-{year}-"; var lastNumber = await _db.Losses .Where(l => l.Number.StartsWith(prefix)) .OrderByDescending(l => l.Number) .Select(l => l.Number) .FirstOrDefaultAsync(ct); var seq = 1; if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last)) seq = last + 1; return $"{prefix}{seq:D6}"; } private async Task GetInternal(Guid id, CancellationToken ct) { var row = await (from l in _db.Losses.AsNoTracking() join st in _db.Stores on l.StoreId equals st.Id join cu in _db.Currencies on l.CurrencyId equals cu.Id where l.Id == id select new { l, st, cu }).FirstOrDefaultAsync(ct); if (row is null) return null; var lines = await (from l in _db.LossLines.AsNoTracking() join p in _db.Products on l.ProductId equals p.Id join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id where l.LossId == id orderby l.SortOrder select new LossLineDto( l.Id, l.ProductId, p.Name, p.Article, u.Name, l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder, _db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.l.StoreId) .Select(s => (decimal?)s.Quantity).FirstOrDefault())) .ToListAsync(ct); return new LossDto( row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason, row.st.Id, row.st.Name, row.cu.Id, row.cu.Code, row.l.Notes, row.l.Total, row.l.PostedAt, lines); } }