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; [ApiController] [Authorize] [Route("api/purchases/supplies")] public class SuppliesController : ControllerBase { private readonly AppDbContext _db; private readonly IStockService _stock; public SuppliesController(AppDbContext db, IStockService stock) { _db = db; _stock = stock; } public record SupplyListRow( Guid Id, string Number, DateTime Date, SupplyStatus Status, Guid SupplierId, string SupplierName, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, decimal Total, int LineCount, DateTime? PostedAt); public record SupplyLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? ProductBarcode, string? UnitSymbol, decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder, bool RetailPriceManuallyOverridden, decimal? RetailPriceOverride, decimal? CurrentRetailPrice); public record SupplyDto( Guid Id, string Number, DateTime Date, SupplyStatus Status, Guid SupplierId, string SupplierName, Guid StoreId, string StoreName, Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, uint Xmin, IReadOnlyList Lines); public record SupplyLineInput( Guid ProductId, [Range(0, 1e10)] decimal Quantity, [Range(0, 1e10)] decimal UnitPrice, bool RetailPriceManuallyOverridden = false, [Range(0, 1e10)] decimal? RetailPriceOverride = null); public record SupplyInput( DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, string? Notes, IReadOnlyList Lines, // Optimistic concurrency token. null/0 для нового черновика (POST), // обязателен для PUT — иначе считаем что клиент не передал version // и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409. uint? Xmin = null); [HttpGet] public async Task>> List( [FromQuery] PagedRequest req, [FromQuery] SupplyStatus? status, [FromQuery] Guid? storeId, [FromQuery] Guid? supplierId, CancellationToken ct) { var q = from s in _db.Supplies.AsNoTracking() join cp in _db.Counterparties on s.SupplierId equals cp.Id join st in _db.Stores on s.StoreId equals st.Id join cu in _db.Currencies on s.CurrencyId equals cu.Id select new { s, cp, st, cu }; if (status is not null) q = q.Where(x => x.s.Status == status); if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId); if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId); if (!string.IsNullOrWhiteSpace(req.Search)) { var s = req.Search.Trim().ToLower(); q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s)); } var total = await q.CountAsync(ct); q = (req.Sort, req.Desc) switch { ("number", false) => q.OrderBy(x => x.s.Number), ("number", true) => q.OrderByDescending(x => x.s.Number), ("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date), ("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date), ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date), ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date), ("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date), ("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date), ("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date), ("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date), ("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number), _ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number), }; var items = await q .Skip(req.Skip).Take(req.Take) .Select(x => new SupplyListRow( x.s.Id, x.s.Number, x.s.Date, x.s.Status, x.cp.Id, x.cp.Name, x.st.Id, x.st.Name, x.cu.Id, x.cu.Code, x.s.Total, x.s.Lines.Count, x.s.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("SuppliesEdit")] public async Task> Create([FromBody] SupplyInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( (nameof(input.SupplierId), input.SupplierId), (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 allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var supply = new Supply { Number = number, Date = input.Date, Status = SupplyStatus.Draft, SupplierId = input.SupplierId, StoreId = input.StoreId, CurrencyId = input.CurrencyId, Notes = input.Notes, }; var order = 0; foreach (var l in input.Lines) { var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero); supply.Lines.Add(new SupplyLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = unitPrice, LineTotal = l.Quantity * unitPrice, SortOrder = order++, RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden, RetailPriceOverride = l.RetailPriceOverride.HasValue ? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero)) : null, }); } supply.Total = supply.Lines.Sum(x => x.LineTotal); _db.Supplies.Add(supply); if (await SaveOrFkErrorAsync(ct) is { } err) return err; var dto = await GetInternal(supply.Id, ct); return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto); } /// SaveChanges + перехват PostgresException 23503 (FK violation). /// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId /// указывает на несуществующего контрагента) — это лучше чем 500. private async Task SaveOrFkErrorAsync(CancellationToken ct) { try { await _db.SaveChangesAsync(ct); return null; } catch (DbUpdateConcurrencyException) { // Optimistic concurrency: кто-то другой обновил документ между // SELECT и UPDATE. Клиент должен перезагрузить и попробовать снова. return Conflict(new { error = "Документ изменён другим пользователем. Обновите страницу и повторите.", code = "concurrency_conflict", }); } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") { // pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId // — вытащим из него имя FK-поля для UI. var name = pg.ConstraintName ?? ""; string field = name.Contains("Supplier") ? "supplierId" : 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("SuppliesEdit")] public async Task Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing( (nameof(input.SupplierId), input.SupplierId), (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 supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); if (supply is null) return NotFound(); if (supply.Status != SupplyStatus.Draft) return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); // Optimistic concurrency: если клиент прислал Xmin, сверяем с тем, // что EF только что прочитал через Include. Несовпадение → 409 // (кто-то изменил документ между нашими GET и PUT). Прозрачнее, чем // полагаться на EF DbUpdateConcurrencyException — там сообщение // зависит от других UPDATE'ов в одной SaveChanges. null/0 от клиента // → пропускаем проверку (старые клиенты). // Optimistic concurrency: если клиент прислал Xmin, сверяем со shadow // property "xmin", которую EF только что загрузила. Несовпадение → 409. if (input.Xmin is { } cliXmin && cliXmin != 0) { var serverXmin = (uint)_db.Entry(supply).Property("xmin").CurrentValue!; if (serverXmin != cliXmin) { return Conflict(new { error = "Документ изменён другим пользователем. Обновите страницу и повторите.", code = "concurrency_conflict", }); } } supply.Date = input.Date; supply.SupplierId = input.SupplierId; supply.StoreId = input.StoreId; supply.CurrencyId = input.CurrencyId; supply.Notes = input.Notes; // Удаляем старые строки через ExecuteDelete (минует трекер), новые // добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id // путается и UPDATE supplies с concurrency-token-WHERE падает 0 affected. // Тот же паттерн что в RetailSale/Demand.Update. await _db.SupplyLines.Where(l => l.SupplyId == supply.Id).ExecuteDeleteAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var order = 0; foreach (var l in input.Lines) { var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero); _db.SupplyLines.Add(new SupplyLine { SupplyId = supply.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = unitPrice, LineTotal = l.Quantity * unitPrice, SortOrder = order++, RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden, RetailPriceOverride = l.RetailPriceOverride.HasValue ? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero)) : null, }); } // Total считаем из input напрямую — supply.Lines navigation в этом // подходе пустая (мы добавляли через DbSet). supply.Total = input.Lines.Sum(l => l.Quantity * (allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero))); if (await SaveOrFkErrorAsync(ct) is { } err) return err; return NoContent(); } [HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")] public async Task Delete(Guid id, CancellationToken ct) { var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct); if (supply is null) return NotFound(); if (supply.Status != SupplyStatus.Draft) return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); _db.Supplies.Remove(supply); await _db.SaveChangesAsync(ct); return NoContent(); } [HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")] public async Task Post(Guid id, CancellationToken ct) { var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); if (supply is null) return NotFound(); if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); // Serializable: ApplyMovementAsync делает read-modify-write по // Stock.Quantity без RowVersion. На дефолтной изоляции два // одновременных проведения (в т.ч. двойное проведение ОДНОГО // документа, проскочившее проверку статуса выше до коммита соседа) // дают lost update: остаток отстаёт от Σ StockMovement, приёмка // применяется дважды, скользящее среднее Cost считается от // устаревшего currentQty. Serializable заставляет конкурирующую // транзакцию откатиться (40001) — ловим ниже и отдаём 409. await using var tx = await _db.Database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, ct); var now = DateTime.UtcNow; foreach (var line in supply.Lines) { var product = await _db.Products .Include(p => p.ProductGroup) .Include(p => p.Prices) .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; // 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест). product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute( currentQty, product.Cost, line.Quantity, line.UnitPrice); // 2. ReferencePrice — автозаполнение при первой приёмке. if (product.ReferencePrice is null) { product.ReferencePrice = line.UnitPrice; product.ReferencePriceUpdatedAt = now; } product.LastSupplyAt = now; // 3. Розничная: либо явный override строки, либо автонаценка по группе. if (line.RetailPriceManuallyOverridden && line.RetailPriceOverride.HasValue) { SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId); } else if (product.ProductGroup?.MarkupPercent is decimal pct) { var raw = product.Cost * (1m + pct / 100m); var newRetail = allowFractional ? Math.Ceiling(raw * 100m) / 100m : Math.Ceiling(raw); SetDefaultRetail(product, newRetail, supply.CurrencyId); } await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: supply.StoreId, Quantity: line.Quantity, Type: MovementType.Supply, DocumentType: "supply", DocumentId: supply.Id, DocumentNumber: supply.Number, UnitCost: line.UnitPrice, OccurredAt: supply.Date), ct); } supply.Status = SupplyStatus.Posted; supply.PostedAt = now; try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } catch (Exception ex) when (IsSerializationConflict(ex)) { foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("supply", "serialization"); return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." }); } foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("supply"); return NoContent(); } /// True, если исключение (или любое вложенное) — конфликт /// сериализации/дедлок Postgres (SQLSTATE 40001 / 40P01). Возникает, когда /// Serializable-транзакция откатывается из-за конкурирующего проведения. /// Используем System.Data.Common.DbException.SqlState (.NET 8), чтобы не /// тянуть прямую зависимость на Npgsql в API-слой. 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; } /// Записывает значение в дефолтный розничный PriceType. Если в списке /// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType /// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый /// PriceType в списке. Currency берётся из приёмки (или из существующей записи). private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId) { var defaultType = _db.PriceTypes .OrderByDescending(pt => pt.IsSystem) .ThenByDescending(pt => pt.IsRetail) .ThenBy(pt => pt.SortOrder) .ThenBy(pt => pt.Name) .FirstOrDefault(); if (defaultType is null) return; var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id); if (existing is null) { p.Prices.Add(new foodmarket.Domain.Catalog.ProductPrice { PriceTypeId = defaultType.Id, Amount = value, CurrencyId = fallbackCurrencyId, }); } else { existing.Amount = value; } } [HttpPost("{id:guid}/unpost"), RequiresPermission("SuppliesPost")] public async Task Unpost(Guid id, CancellationToken ct) { var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); if (supply is null) return NotFound(); if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." }); // Защита от ухода stock в минус: суммируем то, что вернём (по строкам // с тем же ProductId — могут быть дубли), сравниваем с текущим Stock. // Если приёмку «расковать», stock уменьшится на line.Quantity. Если // покупатели уже что-то купили из этой партии, остаток станет // отрицательным — это нарушение инварианта учёта. var reverseByProduct = supply.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 == supply.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, }); } // Reverse: negative movements with same document reference foreach (var line in supply.Lines) { await _stock.ApplyMovementAsync(new StockMovementDraft( ProductId: line.ProductId, StoreId: supply.StoreId, Quantity: -line.Quantity, Type: MovementType.Supply, DocumentType: "supply-reversal", DocumentId: supply.Id, DocumentNumber: supply.Number, UnitCost: line.UnitPrice, OccurredAt: DateTime.UtcNow, Notes: $"Отмена проведения документа {supply.Number}"), ct); } supply.Status = SupplyStatus.Draft; supply.PostedAt = null; await _db.SaveChangesAsync(ct); return NoContent(); } private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) { var year = date.Year; var prefix = $"П-{year}-"; var lastNumber = await _db.Supplies .Where(s => s.Number.StartsWith(prefix)) .OrderByDescending(s => s.Number) .Select(s => s.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) { // Shadow-property xmin читаем через EF.Property — она не на entity, // а в model metadata (UseXminAsConcurrencyToken). var row = await (from s in _db.Supplies.AsNoTracking() join cp in _db.Counterparties on s.SupplierId equals cp.Id join st in _db.Stores on s.StoreId equals st.Id join cu in _db.Currencies on s.CurrencyId equals cu.Id where s.Id == id select new { s, cp, st, cu, Xmin = EF.Property(s, "xmin") }).FirstOrDefaultAsync(ct); if (row is null) return null; // CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType), // отображается в строке приёмки как «Розничная (из карточки)». var lines = await (from l in _db.SupplyLines.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.SupplyId == id orderby l.SortOrder select new SupplyLineDto( l.Id, l.ProductId, p.Name, p.Article, // Основной штрихкод (IsPrimary=true), иначе первый по порядку. p.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(), u.Name, l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder, l.RetailPriceManuallyOverridden, l.RetailPriceOverride, p.Prices .OrderByDescending(pr => pr.PriceType!.IsSystem) .ThenByDescending(pr => pr.PriceType!.IsRetail) .ThenBy(pr => pr.PriceType!.SortOrder) .ThenBy(pr => pr.PriceType!.Name) .Select(pr => (decimal?)pr.Amount) .FirstOrDefault())) .ToListAsync(ct); return new SupplyDto( row.s.Id, row.s.Number, row.s.Date, row.s.Status, row.cp.Id, row.cp.Name, row.st.Id, row.st.Name, row.cu.Id, row.cu.Code, row.s.Notes, row.s.Total, row.s.PostedAt, row.Xmin, lines); } }