feat(api): supply posting hook for cost & markup
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 43s
CI / Web (React + Vite) (push) Successful in 33s
Docker API / Build + push API (push) Has been cancelled

При проведении приёмки (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 23d6f2bd5a
commit 6f88cd71ca

View file

@ -35,7 +35,9 @@ public record SupplyListRow(
public record SupplyLineDto( public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol, 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( public record SupplyDto(
Guid Id, string Number, DateTime Date, SupplyStatus Status, Guid Id, string Number, DateTime Date, SupplyStatus Status,
@ -50,7 +52,9 @@ public record SupplyDto(
public record SupplyLineInput( public record SupplyLineInput(
Guid ProductId, Guid ProductId,
[Range(0, 1e10)] decimal Quantity, [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( public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
@ -147,6 +151,10 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
UnitPrice = unitPrice, UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice, LineTotal = l.Quantity * unitPrice,
SortOrder = order++, 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); 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, UnitPrice = unitPrice,
LineTotal = l.Quantity * unitPrice, LineTotal = l.Quantity * unitPrice,
SortOrder = order++, 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); 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.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (supply.Lines.Count == 0) return BadRequest(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) 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( await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId, ProductId: line.ProductId,
StoreId: supply.StoreId, StoreId: supply.StoreId,
@ -232,11 +288,42 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
} }
supply.Status = SupplyStatus.Posted; supply.Status = SupplyStatus.Posted;
supply.PostedAt = DateTime.UtcNow; supply.PostedAt = now;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return NoContent(); 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")] [HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) 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); select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null; if (row is null) return null;
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
// отображается в строке приёмки как «Розничная (из карточки)».
var lines = await (from l in _db.SupplyLines.AsNoTracking() var lines = await (from l in _db.SupplyLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.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 orderby l.SortOrder
select new SupplyLineDto( select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name, 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); .ToListAsync(ct);
return new SupplyDto( return new SupplyDto(