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
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:
parent
23d6f2bd5a
commit
6f88cd71ca
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue