feat(api): supply posting hook for cost & markup

При проведении приёмки (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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 21:02:00 +05:00
parent 6acf6b7c03
commit 38040b4ec7

View file

@ -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<ActionResult<SupplyDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
/// с IsDefault=true; если такого нет — первый IsRetail; иначе — первый
/// PriceType в списке. Currency берётся из приёмки (или из существующей записи).</summary>
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<IActionResult> Unpost(Guid id, CancellationToken ct)
{
@ -292,6 +379,8 @@ private async Task<string> 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<string> 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(