feat(api): recalc-retail endpoint + 30-day reference price refresh job
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 38s
CI / Web (React + Vite) (push) Successful in 33s
Docker API / Build + push API (push) Successful in 40s
Docker API / Deploy API on stage (push) Successful in 17s

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:
nns 2026-04-25 21:03:44 +05:00
parent 6f88cd71ca
commit b2f589655f
3 changed files with 114 additions and 0 deletions

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

View file

@ -273,6 +273,59 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
return NoContent(); 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")] [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {

View file

@ -138,6 +138,7 @@
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access — // Keep the file as reference for anyone starting without MoySklad access —
// just re-register here to turn demo data back on. // just re-register here to turn demo data back on.