From 38040b4ec7d0f993525214b9440eeff6b4b62c10 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:02:00 +0500 Subject: [PATCH] feat(api): supply posting hook for cost & markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При проведении приёмки (POST /api/purchases/supplies/{id}/post): - Себестоимость товара пересчитывается по скользящему среднему по ВСЕМ складам организации: newCost = (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in). При qty_old = 0 или cost_old = 0 → newCost = price_in. Хранится с 4 знаками (Math.Round AwayFromZero). - ReferencePrice автозаполняется UnitPrice'ом первой Posted приёмки. - LastSupplyAt = UtcNow. - Розничная (дефолтный PriceType, IsDefault → IsRetail → SortOrder/Name): • если у строки RetailPriceManuallyOverridden=true и есть RetailPriceOverride — пишем его как розничную (override per-line), • иначе если у Group задан MarkupPercent — пишем Math.Ceiling(cost * (1 + pct/100)) с округлением: при AllowFractionalPrices=true — до сотых, иначе до целого, • иначе — розничная не трогается. Если в Product.Prices ещё нет записи под дефолтный PriceType — создаётся (currency = supply.CurrencyId). - Всё в одной транзакции, ApplyMovementAsync вызывается ПОСЛЕ расчёта Cost (currentQty снимается до приёмки). SupplyLineInput/SupplyLineDto расширены полями RetailPriceManuallyOverridden, RetailPriceOverride; в DTO дополнительно CurrentRetailPrice — текущая дефолтная розничная цена товара (для отображения в UI приёмки). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Purchases/SuppliesController.cs | 105 +++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) 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(