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.