feat(api): recalc-retail endpoint + 30-day reference price refresh job
Some checks are pending
Some checks are pending
POST /api/catalog/products/{id}/recalc-retail (Admin/Manager/Storekeeper):
- Если у Group товара задан MarkupPercent — записывает в дефолтный
розничный PriceType значение ceil(Cost * (1 + pct/100)). Округление
под AllowFractionalPrices: до сотых при включённом, до целого иначе.
- Возвращает 400 «У группы не задана наценка. …» если MarkupPercent null.
- Возвращает 400 если нет ни одного активного PriceType.
- Использует Organization.DefaultCurrencyId как fallback при создании
новой записи цены.
Background/ReferencePriceRefreshJob (IHostedService, PeriodicTimer 24ч):
- Раз в сутки находит товары с LastSupplyAt < now-30d и Cost > 0,
переписывает ReferencePrice = Cost, обновляет ReferencePriceUpdatedAt.
- IgnoreQueryFilters — работает над всеми organizations.
- Стартовая задержка 5 минут чтобы не пересечься с пендинг-миграцией.
- Зарегистрирован через AddHostedService в Program.cs.
- Hangfire не подключаем как полноценный server — IHostedService даёт
тот же эффект без отдельной schema/dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f88cd71ca
commit
b2f589655f
60
src/food-market.api/Background/ReferencePriceRefreshJob.cs
Normal file
60
src/food-market.api/Background/ReferencePriceRefreshJob.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Background;
|
||||
|
||||
/// <summary>Раз в сутки переписывает ReferencePrice = Cost для товаров,
|
||||
/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая
|
||||
/// «эталонная» цена не остаётся годами — её сравнивают с актуальной
|
||||
/// себестоимостью. Если пользователь редактировал ReferencePrice вручную
|
||||
/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит
|
||||
/// в now, и таймер начинает 30 дней заново.</summary>
|
||||
public class ReferencePriceRefreshJob : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<ReferencePriceRefreshJob> _log;
|
||||
// Запускаем каждые 24 часа. Промах между сутками не критичен — цена
|
||||
// не падает, а лишь догоняет текущую Cost.
|
||||
private static readonly TimeSpan Period = TimeSpan.FromHours(24);
|
||||
|
||||
public ReferencePriceRefreshJob(IServiceProvider services, ILogger<ReferencePriceRefreshJob> log)
|
||||
{
|
||||
_services = services;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
// Ждём 5 минут после старта чтобы не упасть на ещё не применённой миграции.
|
||||
try { await Task.Delay(TimeSpan.FromMinutes(5), ct); } catch (TaskCanceledException) { return; }
|
||||
|
||||
using var timer = new PeriodicTimer(Period);
|
||||
do
|
||||
{
|
||||
try { await RunOnceAsync(ct); }
|
||||
catch (Exception ex) { _log.LogError(ex, "ReferencePriceRefreshJob: iteration failed"); }
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(ct));
|
||||
}
|
||||
|
||||
public async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var threshold = DateTime.UtcNow.AddDays(-30);
|
||||
// IgnoreQueryFilters — фоновый job работает над всеми organizations.
|
||||
var stale = await db.Products
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.LastSupplyAt != null && p.LastSupplyAt < threshold && p.Cost > 0m)
|
||||
.ToListAsync(ct);
|
||||
if (stale.Count == 0) return;
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in stale)
|
||||
{
|
||||
p.ReferencePrice = p.Cost;
|
||||
p.ReferencePriceUpdatedAt = now;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
_log.LogInformation("ReferencePriceRefreshJob: refreshed {Count} stale products", stale.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -273,6 +273,59 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>«Привести розничную к себестоимости»: ставит дефолтную
|
||||
/// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
|
||||
/// группы товара не задан MarkupPercent — 400 с подсказкой.</summary>
|
||||
[HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||
public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
||||
{
|
||||
var p = await _db.Products
|
||||
.Include(x => x.ProductGroup)
|
||||
.Include(x => x.Prices)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (p is null) return NotFound();
|
||||
if (p.ProductGroup?.MarkupPercent is not decimal pct)
|
||||
return BadRequest(new { error = "У группы не задана наценка. Задайте её в настройках или введите цену вручную." });
|
||||
|
||||
var allowFractional = await AllowFractionalAsync(ct);
|
||||
var raw = p.Cost * (1m + pct / 100m);
|
||||
var newRetail = allowFractional
|
||||
? Math.Ceiling(raw * 100m) / 100m
|
||||
: Math.Ceiling(raw);
|
||||
|
||||
var defaultType = await _db.PriceTypes
|
||||
.Where(pt => pt.IsActive)
|
||||
.OrderByDescending(pt => pt.IsDefault)
|
||||
.ThenByDescending(pt => pt.IsRetail)
|
||||
.ThenBy(pt => pt.SortOrder)
|
||||
.ThenBy(pt => pt.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (defaultType is null)
|
||||
return BadRequest(new { error = "Нет ни одного активного типа цен. Создайте его в настройках." });
|
||||
|
||||
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
|
||||
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
|
||||
if (fallbackCurrency is null)
|
||||
return BadRequest(new { error = "Не задана валюта по умолчанию." });
|
||||
|
||||
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
p.Prices.Add(new ProductPrice
|
||||
{
|
||||
PriceTypeId = defaultType.Id,
|
||||
Amount = newRetail,
|
||||
CurrencyId = fallbackCurrency.Value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Amount = newRetail;
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { retail = newRetail });
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@
|
|||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||
builder.Services.AddHostedService<DevDataSeeder>();
|
||||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
||||
// Keep the file as reference for anyone starting without MoySklad access —
|
||||
// just re-register here to turn demo data back on.
|
||||
|
|
|
|||
Loading…
Reference in a new issue