food-market/src/food-market.api/Background/ReferencePriceRefreshJob.cs
nns de23f5fc7a feat(api): recalc-retail endpoint + 30-day reference price refresh job
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>
2026-04-25 21:03:44 +05:00

61 lines
2.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}