diff --git a/src/food-market.api/Background/ReferencePriceRefreshJob.cs b/src/food-market.api/Background/ReferencePriceRefreshJob.cs
new file mode 100644
index 0000000..505d523
--- /dev/null
+++ b/src/food-market.api/Background/ReferencePriceRefreshJob.cs
@@ -0,0 +1,60 @@
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Api.Background;
+
+/// Раз в сутки переписывает ReferencePrice = Cost для товаров,
+/// у которых LastSupplyAt старше 30 дней и Cost > 0. Цель: устаревшая
+/// «эталонная» цена не остаётся годами — её сравнивают с актуальной
+/// себестоимостью. Если пользователь редактировал ReferencePrice вручную
+/// (через PUT /api/catalog/products/...), ReferencePriceUpdatedAt уходит
+/// в now, и таймер начинает 30 дней заново.
+public class ReferencePriceRefreshJob : BackgroundService
+{
+ private readonly IServiceProvider _services;
+ private readonly ILogger _log;
+ // Запускаем каждые 24 часа. Промах между сутками не критичен — цена
+ // не падает, а лишь догоняет текущую Cost.
+ private static readonly TimeSpan Period = TimeSpan.FromHours(24);
+
+ public ReferencePriceRefreshJob(IServiceProvider services, ILogger 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();
+ 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);
+ }
+}
diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs
index 83a45b2..1bd9b05 100644
--- a/src/food-market.api/Controllers/Catalog/ProductsController.cs
+++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs
@@ -273,6 +273,59 @@ public async Task Update(Guid id, [FromBody] ProductInput input,
return NoContent();
}
+ /// «Привести розничную к себестоимости»: ставит дефолтную
+ /// розничную цену = ceil(Cost * (1 + Group.MarkupPercent/100)). Если у
+ /// группы товара не задан MarkupPercent — 400 с подсказкой.
+ [HttpPost("{id:guid}/recalc-retail"), Authorize(Roles = "Admin,Manager,Storekeeper")]
+ public async Task 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 Delete(Guid id, CancellationToken ct)
{
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 5538013..e1431f4 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -138,6 +138,7 @@
builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
// 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.