diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 2b13393..bdad53a 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -35,7 +35,9 @@ public record SupplyListRow( public record SupplyLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, - decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder); + 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, @@ -50,7 +52,9 @@ public record SupplyDto( public record SupplyLineInput( Guid ProductId, [Range(0, 1e10)] decimal Quantity, - [Range(0, 1e10)] decimal UnitPrice); + [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, @@ -147,6 +151,10 @@ public async Task> Create([FromBody] SupplyInput input, 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); @@ -189,6 +197,10 @@ public async Task Update(Guid id, [FromBody] SupplyInput input, C 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); @@ -217,8 +229,52 @@ public async Task Post(Guid id, CancellationToken ct) 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, @@ -232,11 +288,42 @@ public async Task Post(Guid id, CancellationToken ct) } supply.Status = SupplyStatus.Posted; - supply.PostedAt = DateTime.UtcNow; + supply.PostedAt = now; await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); return NoContent(); } + /// Записывает значение в дефолтный розничный PriceType. Если в списке + /// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType + /// с IsDefault=true; если такого нет — первый IsRetail; иначе — первый + /// PriceType в списке. Currency берётся из приёмки (или из существующей записи). + private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId) + { + var defaultType = _db.PriceTypes + .Where(pt => pt.IsActive) + .OrderByDescending(pt => pt.IsDefault) + .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) { @@ -292,6 +379,8 @@ private async Task GenerateNumberAsync(DateTime date, CancellationToken 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 @@ -299,7 +388,15 @@ private async Task GenerateNumberAsync(DateTime date, CancellationToken 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.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder, + l.RetailPriceManuallyOverridden, l.RetailPriceOverride, + p.Prices + .OrderByDescending(pr => pr.PriceType!.IsDefault) + .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(