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>
61 lines
2.8 KiB
C#
61 lines
2.8 KiB
C#
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);
|
||
}
|
||
}
|