using System.ComponentModel.DataAnnotations; using foodmarket.Application.Common; using foodmarket.Application.Inventory; using foodmarket.Domain.Inventory; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; using foodmarket.Api.Infrastructure.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Purchases; /// Оприходование (Enter) — постановка товара на склад без поставщика. /// Используется при запуске учёта (начальные остатки) и для излишков /// инвентаризации. Зеркалит , но проще: /// нет SupplierId, нет пересчёта Product.Cost (UnitCost — балансовая /// цена для отчёта). [ApiController] [Authorize] [Route("api/inventory/enters")] public class EntersController : ControllerBase { private readonly AppDbContext _db; private readonly IStockService _stock; public EntersController(AppDbContext db, IStockService stock) { _db = db; _stock = stock; } public record EnterListRow( Guid Id, string Number, DateTime Date, EnterStatus Status, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, decimal Total, int LineCount, DateTime? PostedAt); public record EnterLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder); public record EnterDto( Guid Id, string Number, DateTime Date, EnterStatus Status, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, IReadOnlyList Lines); public record EnterLineInput( Guid ProductId, [Range(0, 1e10)] decimal Quantity, [Range(0, 1e10)] decimal UnitCost); public record EnterInput( DateTime Date, Guid StoreId, Guid CurrencyId, string? Notes, IReadOnlyList Lines); [HttpGet] public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] EnterStatus? status, [FromQuery] Guid? storeId, CancellationToken ct) { var q = from e in _db.Enters.AsNoTracking() join st in _db.Stores on e.StoreId equals st.Id join cu in _db.Currencies on e.CurrencyId equals cu.Id select new { e, st, cu }; if (status is not null) q = q.Where(x => x.e.Status == status); if (storeId is not null) q = q.Where(x => x.e.StoreId == storeId); if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); q = q.Where(x => x.e.Number.ToLower().Contains(s)); } var total = await q.CountAsync(ct); q = (req.Sort, req.Desc) switch { ("number", false) => q.OrderBy(x => x.e.Number), ("number", true) => q.OrderByDescending(x => x.e.Number), ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.e.Date), ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.e.Date), ("status", false) => q.OrderBy(x => x.e.Status).ThenByDescending(x => x.e.Date), ("status", true) => q.OrderByDescending(x => x.e.Status).ThenByDescending(x => x.e.Date), ("total", false) => q.OrderBy(x => x.e.Total).ThenByDescending(x => x.e.Date), ("total", true) => q.OrderByDescending(x => x.e.Total).ThenByDescending(x => x.e.Date), ("date", false) => q.OrderBy(x => x.e.Date).ThenBy(x => x.e.Number), _ => q.OrderByDescending(x => x.e.Date).ThenByDescending(x => x.e.Number), }; var items = await q .Skip(req.Skip).Take(req.Take) .Select(x => new EnterListRow( x.e.Id, x.e.Number, x.e.Date, x.e.Status, x.st.Id, x.st.Name, x.cu.Id, x.cu.Code, x.e.Total, x.e.Lines.Count, x.e.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("EnterEdit")] public async Task> Create([FromBody] EnterInput 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 enter = new Enter { Number = number, Date = input.Date, Status = EnterStatus.Draft, StoreId = input.StoreId, CurrencyId = input.CurrencyId, Notes = input.Notes, }; var order = 0; foreach (var l in input.Lines) { enter.Lines.Add(new EnterLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.Quantity * l.UnitCost, SortOrder = order++, }); } enter.Total = enter.Lines.Sum(x => x.LineTotal); _db.Enters.Add(enter); if (await SaveOrFkErrorAsync(ct) is { } err) return err; var dto = await GetInternal(enter.Id, ct); return CreatedAtAction(nameof(Get), new { id = enter.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("EnterEdit")] public async Task Update(Guid id, [FromBody] EnterInput 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 enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct); if (enter is null) return NotFound(); if (enter.Status != EnterStatus.Draft) return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); enter.Date = input.Date; enter.StoreId = input.StoreId; enter.CurrencyId = input.CurrencyId; enter.Notes = input.Notes; // ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается // и UPDATE enters падает с DbUpdateConcurrencyException «0 rows affected» // даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update. await _db.EnterLines.Where(l => l.EnterId == enter.Id).ExecuteDeleteAsync(ct); var order = 0; decimal total = 0; foreach (var l in input.Lines) { var lineTotal = l.Quantity * l.UnitCost; _db.EnterLines.Add(new EnterLine { EnterId = enter.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = lineTotal, SortOrder = order++, }); total += lineTotal; } enter.Total = total; if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); } [HttpDelete("{id:guid}"), RequiresPermission("EnterEdit")] public async Task Delete(Guid id, CancellationToken ct) { var enter = await _db.Enters.FirstOrDefaultAsync(e => e.Id == id, ct); if (enter is null) return NotFound(); if (enter.Status != EnterStatus.Draft) return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); _db.Enters.Remove(enter); await _db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("{id:guid}/post"), RequiresPermission("EnterEdit")] public async Task Post(Guid id, CancellationToken ct) { var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct); if (enter is null) return NotFound(); if (enter.Status == EnterStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (enter.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var now = DateTime.UtcNow; foreach (var line in enter.Lines) { // Cost — скользящее среднее: Enter полноправно «вносит» товар на // склад с указанной себестоимостью. Без этого Profit/COGS-отчёты // показывают cost=0 для товаров, попавших в систему через // Оприходование (а не через Supply). var product = await _db.Products.FirstAsync(p => p.Id == line.ProductId, ct); var currentQty = await _db.Stocks .Where(s => s.ProductId == line.ProductId) .SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m; product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute( currentQty, product.Cost, line.Quantity, line.UnitCost); await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: enter.StoreId, Quantity: line.Quantity, Type: MovementType.Enter, DocumentType: "enter", DocumentId: enter.Id, DocumentNumber: enter.Number, UnitCost: line.UnitCost, OccurredAt: enter.Date), ct); } enter.Status = EnterStatus.Posted; enter.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("EnterEdit")] public async Task Unpost(Guid id, CancellationToken ct) { var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct); if (enter is null) return NotFound(); if (enter.Status != EnterStatus.Posted) return Conflict(new { error = "Документ не проведён." }); // Reverse: проверяем что текущий остаток позволяет «снять» оприходование // без ухода в минус (часть товара может быть уже продана). var reverseByProduct = enter.Lines .GroupBy(l => l.ProductId) .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }) .ToList(); var productIds = reverseByProduct.Select(r => r.ProductId).ToList(); var stocks = await _db.Stocks .Where(s => s.StoreId == enter.StoreId && productIds.Contains(s.ProductId)) .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); var conflicts = new List(); foreach (var r in reverseByProduct) { 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, reverseQty = r.Quantity, available, }); } } if (conflicts.Count > 0) { return Conflict(new { error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).", lines = conflicts, }); } foreach (var line in enter.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: enter.StoreId, Quantity: -line.Quantity, Type: MovementType.Enter, DocumentType: "enter-reversal", DocumentId: enter.Id, DocumentNumber: enter.Number, UnitCost: line.UnitCost, OccurredAt: DateTime.UtcNow, Notes: $"Отмена проведения документа {enter.Number}"), ct); } enter.Status = EnterStatus.Draft; enter.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.Enters .Where(e => e.Number.StartsWith(prefix)) .OrderByDescending(e => e.Number) .Select(e => e.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 e in _db.Enters.AsNoTracking() join st in _db.Stores on e.StoreId equals st.Id join cu in _db.Currencies on e.CurrencyId equals cu.Id where e.Id == id select new { e, st, cu }).FirstOrDefaultAsync(ct); if (row is null) return null; var lines = await (from l in _db.EnterLines.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.EnterId == id orderby l.SortOrder select new EnterLineDto( l.Id, l.ProductId, p.Name, p.Article, u.Name, l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder)) .ToListAsync(ct); return new EnterDto( row.e.Id, row.e.Number, row.e.Date, row.e.Status, row.st.Id, row.st.Name, row.cu.Id, row.cu.Code, row.e.Notes, row.e.Total, row.e.PostedAt, lines); } }