feat: auto-create inventory records for new products + migration worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m52s

- CreateNewProductsCommandHandler: inject IInventoryService, auto-init
  inventory record with qty=0 when a new regular product is created
  (DiscountProduct handler already had this)
- InventoryInitializerService: one-time BackgroundService that runs on
  startup, finds existing products without InventoryItem records, and
  creates them (migration for legacy products)
- Register InventoryInitializerService in DI
This commit is contained in:
masoodafar-web
2026-02-18 00:42:41 +03:30
parent fd24dcebcd
commit bc710fdb7b
3 changed files with 174 additions and 0 deletions
@@ -0,0 +1,153 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.BackgroundServices;
/// <summary>
/// سرویس یکباره (Migration) برای مقداردهی اولیه انبارداری از محصولات قدیمی.
/// محصولاتی که قبل از فعال‌سازی سیستم انبارداری ایجاد شده‌اند رکورد InventoryItem ندارند.
/// این Worker در استارتاپ اجرا شده، برای آنها رکورد می‌سازد و سپس متوقف می‌شود.
/// توجه: محصولات جدید از طریق CreateProductCommandHandler خودکار رکورد انبار دریافت می‌کنند.
/// این سرویس را می‌توانید بعد از اجرای اولیه از DI حذف کنید.
/// </summary>
public class InventoryInitializerService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<InventoryInitializerService> _logger;
public InventoryInitializerService(
IServiceScopeFactory scopeFactory,
ILogger<InventoryInitializerService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// صبر کوتاه برای اطمینان از آماده شدن دیتابیس
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
_logger.LogInformation("InventoryInitializerService started — scanning products for missing inventory records...");
try
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IApplicationDbContext>();
var inventoryService = scope.ServiceProvider.GetRequiredService<IInventoryService>();
var (regularCount, discountCount) = await InitializeAllProducts(context, inventoryService, stoppingToken);
_logger.LogInformation(
"InventoryInitializerService completed — initialized {RegularCount} regular products and {DiscountCount} discount products",
regularCount, discountCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "InventoryInitializerService encountered an error");
}
// این Worker فقط یکبار اجرا می‌شود
_logger.LogInformation("InventoryInitializerService finished — shutting down (one-time execution)");
}
private async Task<(int regularCount, int discountCount)> InitializeAllProducts(
IApplicationDbContext context,
IInventoryService inventoryService,
CancellationToken ct)
{
var regularCount = 0;
var discountCount = 0;
// ── محصولات عادی ──
// محصولاتی که هنوز InventoryItem برایشان ایجاد نشده
var existingRegularProductIds = await context.InventoryItems
.Where(i => !i.IsDeleted && i.ProductId != null)
.Select(i => i.ProductId!.Value)
.ToListAsync(ct);
var regularProducts = await context.Products
.Where(p => !p.IsDeleted && !existingRegularProductIds.Contains(p.Id))
.Select(p => new { p.Id, p.Title, p.RemainingCount })
.ToListAsync(ct);
_logger.LogInformation("Found {Count} regular products without inventory records", regularProducts.Count);
foreach (var product in regularProducts)
{
if (ct.IsCancellationRequested) break;
try
{
var initialQty = Math.Max(0, product.RemainingCount);
await inventoryService.InitializeInventoryAsync(
product.Id,
ProductType.RegularProduct,
initialQty,
warehouseId: null, // انبار پیش‌فرض (ID=1)
lowStockThreshold: 10,
ct);
regularCount++;
_logger.LogDebug(
"Initialized inventory for Regular Product Id={ProductId} ({Title}), Qty={Qty}",
product.Id, product.Title, initialQty);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to initialize inventory for Regular Product Id={ProductId} ({Title})",
product.Id, product.Title);
}
}
// ── محصولات تخفیفی ──
var existingDiscountProductIds = await context.InventoryItems
.Where(i => !i.IsDeleted && i.DiscountProductId != null)
.Select(i => i.DiscountProductId!.Value)
.ToListAsync(ct);
var discountProducts = await context.DiscountProducts
.Where(p => !p.IsDeleted && !existingDiscountProductIds.Contains(p.Id))
.Select(p => new { p.Id, p.Title, p.RemainingCount })
.ToListAsync(ct);
_logger.LogInformation("Found {Count} discount products without inventory records", discountProducts.Count);
foreach (var product in discountProducts)
{
if (ct.IsCancellationRequested) break;
try
{
var initialQty = Math.Max(0, product.RemainingCount);
await inventoryService.InitializeInventoryAsync(
product.Id,
ProductType.DiscountProduct,
initialQty,
warehouseId: null,
lowStockThreshold: 10,
ct);
discountCount++;
_logger.LogDebug(
"Initialized inventory for Discount Product Id={ProductId} ({Title}), Qty={Qty}",
product.Id, product.Title, initialQty);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to initialize inventory for Discount Product Id={ProductId} ({Title})",
product.Id, product.Title);
}
}
return (regularCount, discountCount);
}
}
@@ -123,6 +123,9 @@ public static class ConfigureServices
// Expire pending discount orders after 30 minutes
services.AddHostedService<ExpirePendingOrdersService>();
// One-time: Initialize inventory records for existing products
services.AddHostedService<InventoryInitializerService>();
if (configuration.GetValue<bool>("UseInMemoryDatabase"))
{
services.AddDbContext<ApplicationDbContext>(options =>