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 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? 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? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, string? Notes, decimal Total, DateTime? PostedAt, 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? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, string? Notes, IReadOnlyList Lines); [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, Authorize(Roles = "Admin,Manager,Storekeeper")] public async Task> Create([FromBody] SupplyInput input, CancellationToken ct) { 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, SupplierInvoiceNumber = input.SupplierInvoiceNumber, SupplierInvoiceDate = input.SupplierInvoiceDate, 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); await _db.SaveChangesAsync(ct); var dto = await GetInternal(supply.Id, ct); return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto); } [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] public async Task Update(Guid id, [FromBody] SupplyInput input, 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.Draft) return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); supply.Date = input.Date; supply.SupplierId = input.SupplierId; supply.StoreId = input.StoreId; supply.CurrencyId = input.CurrencyId; supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber; supply.SupplierInvoiceDate = input.SupplierInvoiceDate; supply.Notes = input.Notes; // Replace lines wholesale (simple, idempotent). _db.SupplyLines.RemoveRange(supply.Lines); supply.Lines.Clear(); 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); supply.Lines.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, }); } supply.Total = supply.Lines.Sum(x => x.LineTotal); await _db.SaveChangesAsync(ct); return NoContent(); } [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] 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"), Authorize(Roles = "Admin,Manager,Storekeeper")] 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); await using var tx = await _db.Database.BeginTransactionAsync(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 — скользящее среднее. var totalQty = currentQty + line.Quantity; var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m ? line.UnitPrice : (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty; product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero); // 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; await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); return NoContent(); } /// Записывает значение в дефолтный розничный 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"), Authorize(Roles = "Admin,Manager,Storekeeper")] 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 = "Документ не проведён." }); // 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) { 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 }).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, 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.SupplierInvoiceNumber, row.s.SupplierInvoiceDate, row.s.Notes, row.s.Total, row.s.PostedAt, lines); } }