Implement Inventory Management Service with CRUD operations for warehouses and inventory items, stock operations, and bulk processing capabilities.
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m48s

This commit is contained in:
masoodafar-web
2026-01-02 00:45:37 +03:30
parent f0117eb1d5
commit 1cf501711f
50 changed files with 10699 additions and 101 deletions
@@ -14,9 +14,20 @@ namespace CMSMicroservice.Infrastructure.Persistence;
public class ApplicationDbContext : DbContext, IApplicationDbContext
{
private readonly IMediator _mediator;
private readonly AuditableEntitySaveChangesInterceptor _auditableEntitySaveChangesInterceptor;
private readonly IMediator? _mediator;
private readonly AuditableEntitySaveChangesInterceptor? _auditableEntitySaveChangesInterceptor;
/// <summary>
/// Constructor برای design-time (migrations)
/// </summary>
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
/// <summary>
/// Constructor اصلی برای runtime
/// </summary>
public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
IMediator mediator,
@@ -39,7 +50,10 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(_auditableEntitySaveChangesInterceptor);
if (_auditableEntitySaveChangesInterceptor != null)
{
optionsBuilder.AddInterceptors(_auditableEntitySaveChangesInterceptor);
}
// Suppress PendingModelChangesWarning in EF Core 9
optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
@@ -119,4 +133,9 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<Country> Countries => Set<Country>();
public DbSet<State> States => Set<State>();
public DbSet<City> Cities => Set<City>();
// ============= Inventory Management DbSets =============
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
}
@@ -0,0 +1,46 @@
using System.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
namespace CMSMicroservice.Infrastructure.Persistence;
/// <summary>
/// Factory برای ایجاد DbContext در زمان طراحی (migrations, scaffolding)
/// </summary>
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext(string[] args)
{
// سعی در خواندن connection string از appsettings.json
var basePath = Directory.GetCurrentDirectory();
var webApiPath = Path.Combine(basePath, "../CMSMicroservice.WebApi");
if (Directory.Exists(webApiPath))
{
basePath = webApiPath;
}
var configuration = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString("DefaultConnection");
// اگر connection string پیدا نشد، از یک مقدار پیش‌فرض استفاده کن
if (string.IsNullOrEmpty(connectionString))
{
connectionString = "Server=localhost;Database=CMS;Trusted_Connection=True;TrustServerCertificate=True;";
}
var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
optionsBuilder.UseSqlServer(connectionString,
b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName));
return new ApplicationDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,122 @@
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.DiscountShop;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
/// <summary>
/// تنظیمات Entity Framework برای موجودی کالا
/// </summary>
public class InventoryItemConfiguration : IEntityTypeConfiguration<InventoryItem>
{
public void Configure(EntityTypeBuilder<InventoryItem> builder)
{
// ========== تنظیمات پایه ==========
builder.HasQueryFilter(i => !i.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
// ========== فیلدهای اصلی ==========
builder.Property(e => e.ProductType)
.IsRequired()
.HasConversion<int>();
builder.Property(e => e.Quantity)
.IsRequired()
.HasDefaultValue(0);
builder.Property(e => e.ReservedQuantity)
.IsRequired()
.HasDefaultValue(0);
// ========== تنظیمات انبار ==========
builder.Property(e => e.LowStockThreshold)
.IsRequired()
.HasDefaultValue(10);
builder.Property(e => e.ReorderPoint)
.IsRequired()
.HasDefaultValue(5);
builder.Property(e => e.MaxStockLevel)
.IsRequired()
.HasDefaultValue(1000);
// ========== تاریخ‌ها ==========
builder.Property(e => e.LastRestockedAt)
.IsRequired(false);
builder.Property(e => e.LastSoldAt)
.IsRequired(false);
// ========== انبار ==========
builder.Property(e => e.WarehouseId)
.IsRequired()
.HasDefaultValue(1);
// ========== روابط ==========
// رابطه با Product (اختیاری)
builder.HasOne(e => e.Product)
.WithMany()
.HasForeignKey(e => e.ProductId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
// رابطه با DiscountProduct (اختیاری)
builder.HasOne(e => e.DiscountProduct)
.WithMany()
.HasForeignKey(e => e.DiscountProductId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
// رابطه با Warehouse
builder.HasOne(e => e.Warehouse)
.WithMany(w => w.InventoryItems)
.HasForeignKey(e => e.WarehouseId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
// ========== Index ها ==========
// Index روی ProductId برای جستجوی سریع محصولات معمولی
builder.HasIndex(e => e.ProductId)
.HasDatabaseName("IX_InventoryItems_ProductId");
// Index روی DiscountProductId برای جستجوی سریع محصولات تخفیفی
builder.HasIndex(e => e.DiscountProductId)
.HasDatabaseName("IX_InventoryItems_DiscountProductId");
// Index ترکیبی روی ProductType و Quantity برای گزارش‌گیری
builder.HasIndex(e => new { e.ProductType, e.Quantity })
.HasDatabaseName("IX_InventoryItems_ProductType_Quantity");
// Index ساده روی WarehouseId برای انبار
builder.HasIndex(e => e.WarehouseId)
.HasDatabaseName("IX_InventoryItems_WarehouseId");
// ========== محدودیت‌ها ==========
// محدودیت: یا ProductId یا DiscountProductId باید پر باشد (نه هر دو، نه هیچ‌کدام)
builder.HasCheckConstraint("CK_InventoryItem_ProductReference",
"(ProductId IS NOT NULL AND DiscountProductId IS NULL) OR (ProductId IS NULL AND DiscountProductId IS NOT NULL)");
// محدودیت: ProductType باید با نوع محصول مطابقت داشته باشد
builder.HasCheckConstraint("CK_InventoryItem_ProductType_Match",
"(ProductType = 1 AND ProductId IS NOT NULL) OR (ProductType = 2 AND DiscountProductId IS NOT NULL)");
// محدودیت: موجودی نمی‌تواند منفی باشد
builder.HasCheckConstraint("CK_InventoryItem_Quantity_NonNegative",
"Quantity >= 0");
// محدودیت: موجودی رزرو شده نمی‌تواند منفی باشد
builder.HasCheckConstraint("CK_InventoryItem_ReservedQuantity_NonNegative",
"ReservedQuantity >= 0");
// محدودیت: موجودی رزرو شده نمی‌تواند بیشتر از موجودی کل باشد
builder.HasCheckConstraint("CK_InventoryItem_ReservedQuantity_LessOrEqualQuantity",
"ReservedQuantity <= Quantity");
}
}
@@ -0,0 +1,117 @@
using CMSMicroservice.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
/// <summary>
/// تنظیمات Entity Framework برای حرکات انبار
/// </summary>
public class StockMovementConfiguration : IEntityTypeConfiguration<StockMovement>
{
public void Configure(EntityTypeBuilder<StockMovement> builder)
{
// ========== تنظیمات پایه ==========
builder.HasQueryFilter(s => !s.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
// ========== فیلدهای اصلی ==========
// نوع حرکت (enum به int)
builder.Property(e => e.MovementType)
.IsRequired()
.HasConversion<int>();
// مقدار تغییر (می‌تواند منفی باشد)
builder.Property(e => e.Quantity)
.IsRequired();
// موجودی قبل و بعد
builder.Property(e => e.QuantityBefore)
.IsRequired();
builder.Property(e => e.QuantityAfter)
.IsRequired();
// ========== فیلدهای اختیاری ==========
// شناسه سفارش معمولی
builder.Property(e => e.OrderId)
.IsRequired(false);
// شناسه سفارش تخفیفی
builder.Property(e => e.DiscountOrderId)
.IsRequired(false);
// شماره مرجع
builder.Property(e => e.ReferenceNumber)
.IsRequired(false)
.HasMaxLength(100);
// یادداشت
builder.Property(e => e.Note)
.IsRequired(false)
.HasMaxLength(500);
// کاربر انجام‌دهنده
builder.Property(e => e.PerformedByUserId)
.IsRequired(false);
// ========== روابط ==========
// رابطه با InventoryItem (اجباری)
builder.HasOne(e => e.InventoryItem)
.WithMany(i => i.StockMovements)
.HasForeignKey(e => e.InventoryItemId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
// ========== Index ها ==========
// Index روی InventoryItemId برای جستجوی حرکات یک آیتم
builder.HasIndex(e => e.InventoryItemId)
.HasDatabaseName("IX_StockMovements_InventoryItemId");
// Index روی MovementType برای فیلتر بر اساس نوع حرکت
builder.HasIndex(e => e.MovementType)
.HasDatabaseName("IX_StockMovements_MovementType");
// Index روی Created برای مرتب‌سازی زمانی
builder.HasIndex(e => e.Created)
.HasDatabaseName("IX_StockMovements_Created");
// Index ترکیبی برای گزارش‌گیری
builder.HasIndex(e => new { e.InventoryItemId, e.MovementType, e.Created })
.HasDatabaseName("IX_StockMovements_Item_Type_Date");
// Index روی OrderId برای ردیابی حرکات مرتبط با سفارش
builder.HasIndex(e => e.OrderId)
.HasDatabaseName("IX_StockMovements_OrderId")
.HasFilter("[OrderId] IS NOT NULL");
// Index روی DiscountOrderId برای ردیابی حرکات مرتبط با سفارش تخفیفی
builder.HasIndex(e => e.DiscountOrderId)
.HasDatabaseName("IX_StockMovements_DiscountOrderId")
.HasFilter("[DiscountOrderId] IS NOT NULL");
// Index روی ReferenceNumber برای جستجوی سریع با شماره مرجع
builder.HasIndex(e => e.ReferenceNumber)
.HasDatabaseName("IX_StockMovements_ReferenceNumber")
.HasFilter("[ReferenceNumber] IS NOT NULL");
// ========== محدودیت‌ها ==========
// محدودیت: QuantityAfter باید برابر QuantityBefore + Quantity باشد
builder.HasCheckConstraint("CK_StockMovement_QuantityAfter_Calculation",
"QuantityAfter = QuantityBefore + Quantity");
// محدودیت: QuantityBefore و QuantityAfter نمی‌توانند منفی باشند
builder.HasCheckConstraint("CK_StockMovement_QuantityBefore_NonNegative",
"QuantityBefore >= 0");
builder.HasCheckConstraint("CK_StockMovement_QuantityAfter_NonNegative",
"QuantityAfter >= 0");
}
}
@@ -0,0 +1,89 @@
using CMSMicroservice.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
/// <summary>
/// تنظیمات Entity Framework برای انبار
/// </summary>
public class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
{
public void Configure(EntityTypeBuilder<Warehouse> builder)
{
// ========== تنظیمات پایه ==========
builder.HasQueryFilter(w => !w.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
// ========== فیلدهای اصلی ==========
// نام انبار (اجباری)
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
// کد انبار (اجباری و یکتا)
builder.Property(e => e.Code)
.IsRequired()
.HasMaxLength(50);
// آدرس انبار (اختیاری)
builder.Property(e => e.Address)
.IsRequired(false)
.HasMaxLength(1000);
// ========== تنظیمات ==========
// انبار پیش‌فرض
builder.Property(e => e.IsDefault)
.IsRequired()
.HasDefaultValue(false);
// وضعیت فعال/غیرفعال
builder.Property(e => e.IsActive)
.IsRequired()
.HasDefaultValue(true);
// ========== Index ها ==========
// Index یکتا روی کد انبار
builder.HasIndex(e => e.Code)
.IsUnique()
.HasDatabaseName("IX_Warehouses_Code_Unique");
// Index روی IsDefault برای پیدا کردن سریع انبار پیش‌فرض
builder.HasIndex(e => e.IsDefault)
.HasDatabaseName("IX_Warehouses_IsDefault")
.HasFilter("[IsDefault] = 1");
// Index روی IsActive برای فیلتر انبارهای فعال
builder.HasIndex(e => e.IsActive)
.HasDatabaseName("IX_Warehouses_IsActive");
// ========== روابط ==========
// رابطه یک-به-چند با InventoryItems
builder.HasMany(w => w.InventoryItems)
.WithOne(i => i.Warehouse)
.HasForeignKey(i => i.WarehouseId)
.OnDelete(DeleteBehavior.Restrict); // جلوگیری از حذف انبار در صورت وجود موجودی
// ========== داده‌های اولیه ==========
// انبار پیش‌فرض
builder.HasData(new Warehouse
{
Id = 1,
Name = "انبار اصلی",
Code = "WH-001",
Address = "تهران - انبار مرکزی فروشگاه",
IsDefault = true,
IsActive = true,
Created = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CreatedBy = "System",
IsDeleted = false
});
}
}
@@ -0,0 +1,242 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddInventorySystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Warehouses",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Address = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsDefault = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Warehouses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "InventoryItems",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ProductId = table.Column<long>(type: "bigint", nullable: true),
DiscountProductId = table.Column<long>(type: "bigint", nullable: true),
ProductType = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
ReservedQuantity = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
LowStockThreshold = table.Column<int>(type: "int", nullable: false, defaultValue: 10),
ReorderPoint = table.Column<int>(type: "int", nullable: false, defaultValue: 5),
MaxStockLevel = table.Column<int>(type: "int", nullable: false, defaultValue: 1000),
LastRestockedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
LastSoldAt = table.Column<DateTime>(type: "datetime2", nullable: true),
WarehouseId = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InventoryItems", x => x.Id);
table.CheckConstraint("CK_InventoryItem_ProductReference", "(ProductId IS NOT NULL AND DiscountProductId IS NULL) OR (ProductId IS NULL AND DiscountProductId IS NOT NULL)");
table.CheckConstraint("CK_InventoryItem_ProductType_Match", "(ProductType = 1 AND ProductId IS NOT NULL) OR (ProductType = 2 AND DiscountProductId IS NOT NULL)");
table.CheckConstraint("CK_InventoryItem_Quantity_NonNegative", "Quantity >= 0");
table.CheckConstraint("CK_InventoryItem_ReservedQuantity_LessOrEqualQuantity", "ReservedQuantity <= Quantity");
table.CheckConstraint("CK_InventoryItem_ReservedQuantity_NonNegative", "ReservedQuantity >= 0");
table.ForeignKey(
name: "FK_InventoryItems_DiscountProducts_DiscountProductId",
column: x => x.DiscountProductId,
principalSchema: "CMS",
principalTable: "DiscountProducts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_InventoryItems_Products_ProductId",
column: x => x.ProductId,
principalSchema: "CMS",
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_InventoryItems_Warehouses_WarehouseId",
column: x => x.WarehouseId,
principalSchema: "CMS",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "StockMovements",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
InventoryItemId = table.Column<long>(type: "bigint", nullable: false),
MovementType = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
QuantityBefore = table.Column<int>(type: "int", nullable: false),
QuantityAfter = table.Column<int>(type: "int", nullable: false),
OrderId = table.Column<long>(type: "bigint", nullable: true),
DiscountOrderId = table.Column<long>(type: "bigint", nullable: true),
ReferenceNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
PerformedByUserId = table.Column<long>(type: "bigint", nullable: true),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StockMovements", x => x.Id);
table.CheckConstraint("CK_StockMovement_QuantityAfter_Calculation", "QuantityAfter = QuantityBefore + Quantity");
table.CheckConstraint("CK_StockMovement_QuantityAfter_NonNegative", "QuantityAfter >= 0");
table.CheckConstraint("CK_StockMovement_QuantityBefore_NonNegative", "QuantityBefore >= 0");
table.ForeignKey(
name: "FK_StockMovements_InventoryItems_InventoryItemId",
column: x => x.InventoryItemId,
principalSchema: "CMS",
principalTable: "InventoryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
schema: "CMS",
table: "Warehouses",
columns: new[] { "Id", "Address", "Code", "Created", "CreatedBy", "IsActive", "IsDefault", "IsDeleted", "LastModified", "LastModifiedBy", "Name" },
values: new object[] { 1L, "تهران - انبار مرکزی فروشگاه", "WH-001", new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), "System", true, true, false, null, null, "انبار اصلی" });
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_DiscountProductId",
schema: "CMS",
table: "InventoryItems",
column: "DiscountProductId");
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_ProductId",
schema: "CMS",
table: "InventoryItems",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_ProductType_Quantity",
schema: "CMS",
table: "InventoryItems",
columns: new[] { "ProductType", "Quantity" });
migrationBuilder.CreateIndex(
name: "IX_InventoryItems_WarehouseId",
schema: "CMS",
table: "InventoryItems",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_Created",
schema: "CMS",
table: "StockMovements",
column: "Created");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_DiscountOrderId",
schema: "CMS",
table: "StockMovements",
column: "DiscountOrderId",
filter: "[DiscountOrderId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_InventoryItemId",
schema: "CMS",
table: "StockMovements",
column: "InventoryItemId");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_Item_Type_Date",
schema: "CMS",
table: "StockMovements",
columns: new[] { "InventoryItemId", "MovementType", "Created" });
migrationBuilder.CreateIndex(
name: "IX_StockMovements_MovementType",
schema: "CMS",
table: "StockMovements",
column: "MovementType");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_OrderId",
schema: "CMS",
table: "StockMovements",
column: "OrderId",
filter: "[OrderId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_StockMovements_ReferenceNumber",
schema: "CMS",
table: "StockMovements",
column: "ReferenceNumber",
filter: "[ReferenceNumber] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Warehouses_Code_Unique",
schema: "CMS",
table: "Warehouses",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Warehouses_IsActive",
schema: "CMS",
table: "Warehouses",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_Warehouses_IsDefault",
schema: "CMS",
table: "Warehouses",
column: "IsDefault",
filter: "[IsDefault] = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StockMovements",
schema: "CMS");
migrationBuilder.DropTable(
name: "InventoryItems",
schema: "CMS");
migrationBuilder.DropTable(
name: "Warehouses",
schema: "CMS");
}
}
}
@@ -1503,6 +1503,102 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("NetworkMembershipHistories", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.InventoryItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<long?>("DiscountProductId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastRestockedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastSoldAt")
.HasColumnType("datetime2");
b.Property<int>("LowStockThreshold")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(10);
b.Property<int>("MaxStockLevel")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(1000);
b.Property<long?>("ProductId")
.HasColumnType("bigint");
b.Property<int>("ProductType")
.HasColumnType("int");
b.Property<int>("Quantity")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("ReorderPoint")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(5);
b.Property<int>("ReservedQuantity")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<long>("WarehouseId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(1L);
b.HasKey("Id");
b.HasIndex("DiscountProductId")
.HasDatabaseName("IX_InventoryItems_DiscountProductId");
b.HasIndex("ProductId")
.HasDatabaseName("IX_InventoryItems_ProductId");
b.HasIndex("WarehouseId")
.HasDatabaseName("IX_InventoryItems_WarehouseId");
b.HasIndex("ProductType", "Quantity")
.HasDatabaseName("IX_InventoryItems_ProductType_Quantity");
b.ToTable("InventoryItems", "CMS", t =>
{
t.HasCheckConstraint("CK_InventoryItem_ProductReference", "(ProductId IS NOT NULL AND DiscountProductId IS NULL) OR (ProductId IS NULL AND DiscountProductId IS NOT NULL)");
t.HasCheckConstraint("CK_InventoryItem_ProductType_Match", "(ProductType = 1 AND ProductId IS NOT NULL) OR (ProductType = 2 AND DiscountProductId IS NOT NULL)");
t.HasCheckConstraint("CK_InventoryItem_Quantity_NonNegative", "Quantity >= 0");
t.HasCheckConstraint("CK_InventoryItem_ReservedQuantity_LessOrEqualQuantity", "ReservedQuantity <= Quantity");
t.HasCheckConstraint("CK_InventoryItem_ReservedQuantity_NonNegative", "ReservedQuantity >= 0");
});
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Network.NetworkWeeklyBalance", b =>
{
b.Property<long>("Id")
@@ -2210,6 +2306,97 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("Roles", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.StockMovement", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<long?>("DiscountOrderId")
.HasColumnType("bigint");
b.Property<long>("InventoryItemId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("MovementType")
.HasColumnType("int");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<long?>("OrderId")
.HasColumnType("bigint");
b.Property<long?>("PerformedByUserId")
.HasColumnType("bigint");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("QuantityAfter")
.HasColumnType("int");
b.Property<int>("QuantityBefore")
.HasColumnType("int");
b.Property<string>("ReferenceNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Created")
.HasDatabaseName("IX_StockMovements_Created");
b.HasIndex("DiscountOrderId")
.HasDatabaseName("IX_StockMovements_DiscountOrderId")
.HasFilter("[DiscountOrderId] IS NOT NULL");
b.HasIndex("InventoryItemId")
.HasDatabaseName("IX_StockMovements_InventoryItemId");
b.HasIndex("MovementType")
.HasDatabaseName("IX_StockMovements_MovementType");
b.HasIndex("OrderId")
.HasDatabaseName("IX_StockMovements_OrderId")
.HasFilter("[OrderId] IS NOT NULL");
b.HasIndex("ReferenceNumber")
.HasDatabaseName("IX_StockMovements_ReferenceNumber")
.HasFilter("[ReferenceNumber] IS NOT NULL");
b.HasIndex("InventoryItemId", "MovementType", "Created")
.HasDatabaseName("IX_StockMovements_Item_Type_Date");
b.ToTable("StockMovements", "CMS", t =>
{
t.HasCheckConstraint("CK_StockMovement_QuantityAfter_Calculation", "QuantityAfter = QuantityBefore + Quantity");
t.HasCheckConstraint("CK_StockMovement_QuantityAfter_NonNegative", "QuantityAfter >= 0");
t.HasCheckConstraint("CK_StockMovement_QuantityBefore_NonNegative", "QuantityBefore >= 0");
});
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Tag", b =>
{
b.Property<long>("Id")
@@ -2818,6 +3005,83 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("UserWalletChangeLogs", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Warehouse", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsDefault")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique()
.HasDatabaseName("IX_Warehouses_Code_Unique");
b.HasIndex("IsActive")
.HasDatabaseName("IX_Warehouses_IsActive");
b.HasIndex("IsDefault")
.HasDatabaseName("IX_Warehouses_IsDefault")
.HasFilter("[IsDefault] = 1");
b.ToTable("Warehouses", "CMS");
b.HasData(
new
{
Id = 1L,
Address = "تهران - انبار مرکزی فروشگاه",
Code = "WH-001",
Created = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
CreatedBy = "System",
IsActive = true,
IsDefault = true,
IsDeleted = false,
Name = "انبار اصلی"
});
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.WeekDefinition", b =>
{
b.Property<long>("Id")
@@ -3185,6 +3449,31 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("WeekDefinition");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.InventoryItem", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.DiscountShop.DiscountProduct", "DiscountProduct")
.WithMany()
.HasForeignKey("DiscountProductId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("CMSMicroservice.Domain.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("CMSMicroservice.Domain.Entities.Warehouse", "Warehouse")
.WithMany("InventoryItems")
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("DiscountProduct");
b.Navigation("Product");
b.Navigation("Warehouse");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Network.NetworkWeeklyBalance", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.User", "User")
@@ -3290,6 +3579,17 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("Tag");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.StockMovement", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.InventoryItem", "InventoryItem")
.WithMany("StockMovements")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("InventoryItem");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.User", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.User", "NetworkParent")
@@ -3527,6 +3827,11 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("Cities");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.InventoryItem", b =>
{
b.Navigation("StockMovements");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Package", b =>
{
b.Navigation("UserOrders");
@@ -3611,6 +3916,11 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("UserWalletChangeLogs");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Warehouse", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.WeekDefinition", b =>
{
b.Navigation("CommissionPayoutHistories");
@@ -0,0 +1,454 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Interfaces.Repositories;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation برای مدیریت موجودی محصولات
/// </summary>
public class InventoryItemRepository : IInventoryItemRepository
{
private readonly IApplicationDbContext _context;
public InventoryItemRepository(IApplicationDbContext context)
{
_context = context;
}
#region Read Operations
public async Task<InventoryItem?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Include(i => i.Warehouse)
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
public async Task<InventoryItem?> GetByProductIdAsync(long productId, long warehouseId = 1, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.Warehouse)
.FirstOrDefaultAsync(i => i.ProductId == productId && i.WarehouseId == warehouseId, cancellationToken);
}
public async Task<InventoryItem?> GetByDiscountProductIdAsync(long discountProductId, long warehouseId = 1, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Include(i => i.DiscountProduct)
.Include(i => i.Warehouse)
.FirstOrDefaultAsync(i => i.DiscountProductId == discountProductId && i.WarehouseId == warehouseId, cancellationToken);
}
public async Task<List<InventoryItem>> GetByWarehouseIdAsync(long warehouseId, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Where(i => i.WarehouseId == warehouseId)
.OrderBy(i => i.Product != null ? i.Product.Title : i.DiscountProduct != null ? i.DiscountProduct.Title : "")
.ToListAsync(cancellationToken);
}
public async Task<List<InventoryItem>> GetLowStockItemsAsync(ProductType? productType = null, long warehouseId = 1, CancellationToken cancellationToken = default)
{
var query = _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Where(i => i.WarehouseId == warehouseId && i.Quantity <= i.LowStockThreshold && i.Quantity > 0);
if (productType.HasValue)
{
query = query.Where(i => i.ProductType == productType.Value);
}
return await query
.OrderBy(i => i.Quantity)
.ToListAsync(cancellationToken);
}
public async Task<List<InventoryItem>> GetOutOfStockItemsAsync(ProductType? productType = null, long warehouseId = 1, CancellationToken cancellationToken = default)
{
var query = _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Where(i => i.WarehouseId == warehouseId && i.Quantity == 0);
if (productType.HasValue)
{
query = query.Where(i => i.ProductType == productType.Value);
}
return await query
.OrderBy(i => i.Product != null ? i.Product.Title : i.DiscountProduct != null ? i.DiscountProduct.Title : "")
.ToListAsync(cancellationToken);
}
public async Task<List<InventoryItem>> SearchAsync(
string? searchTerm = null,
ProductType? productType = null,
long? warehouseId = null,
int? minQuantity = null,
int? maxQuantity = null,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default)
{
var query = _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Include(i => i.Warehouse)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(i =>
(i.Product != null && i.Product.Title.ToLower().Contains(term)) ||
(i.DiscountProduct != null && i.DiscountProduct.Title.ToLower().Contains(term)));
}
if (productType.HasValue)
{
query = query.Where(i => i.ProductType == productType.Value);
}
if (warehouseId.HasValue)
{
query = query.Where(i => i.WarehouseId == warehouseId.Value);
}
if (minQuantity.HasValue)
{
query = query.Where(i => i.Quantity >= minQuantity.Value);
}
if (maxQuantity.HasValue)
{
query = query.Where(i => i.Quantity <= maxQuantity.Value);
}
return await query
.OrderBy(i => i.Product != null ? i.Product.Title : i.DiscountProduct != null ? i.DiscountProduct.Title : "")
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<int> CountAsync(
string? searchTerm = null,
ProductType? productType = null,
long? warehouseId = null,
int? minQuantity = null,
int? maxQuantity = null,
CancellationToken cancellationToken = default)
{
var query = _context.InventoryItems.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(i =>
(i.Product != null && i.Product.Title.ToLower().Contains(term)) ||
(i.DiscountProduct != null && i.DiscountProduct.Title.ToLower().Contains(term)));
}
if (productType.HasValue)
{
query = query.Where(i => i.ProductType == productType.Value);
}
if (warehouseId.HasValue)
{
query = query.Where(i => i.WarehouseId == warehouseId.Value);
}
if (minQuantity.HasValue)
{
query = query.Where(i => i.Quantity >= minQuantity.Value);
}
if (maxQuantity.HasValue)
{
query = query.Where(i => i.Quantity <= maxQuantity.Value);
}
return await query.CountAsync(cancellationToken);
}
#endregion
#region Write Operations
public async Task<InventoryItem> AddAsync(InventoryItem inventoryItem, CancellationToken cancellationToken = default)
{
_context.InventoryItems.Add(inventoryItem);
await _context.SaveChangesAsync(cancellationToken);
return inventoryItem;
}
public async Task UpdateAsync(InventoryItem inventoryItem, CancellationToken cancellationToken = default)
{
_context.InventoryItems.Update(inventoryItem);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
var item = await _context.InventoryItems.FindAsync(new object[] { id }, cancellationToken);
if (item != null)
{
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync(cancellationToken);
}
}
public async Task<bool> UpdateQuantityAsync(
long inventoryItemId,
int quantityChange,
StockMovementType movementType,
string? note = null,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var item = await _context.InventoryItems.FindAsync(new object[] { inventoryItemId }, cancellationToken);
if (item == null) return false;
// بررسی اینکه موجودی کافی برای کاهش موجود باشد
if (quantityChange < 0 && item.Quantity + quantityChange < 0)
{
return false;
}
// بروزرسانی موجودی
item.Quantity += quantityChange;
// بروزرسانی تاریخ آخرین فعالیت
if (movementType == StockMovementType.Sale)
{
item.LastSoldAt = DateTime.UtcNow;
}
else if (movementType == StockMovementType.Restock || movementType == StockMovementType.InitialStock)
{
item.LastRestockedAt = DateTime.UtcNow;
}
// ثبت حرکت موجودی
var stockMovement = new StockMovement
{
InventoryItemId = inventoryItemId,
MovementType = movementType,
Quantity = Math.Abs(quantityChange),
Note = note,
ReferenceNumber = referenceNumber,
OrderId = orderId,
DiscountOrderId = discountOrderId,
PerformedByUserId = performedByUserId
};
_context.StockMovements.Add(stockMovement);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> ReserveQuantityAsync(
long inventoryItemId,
int quantity,
string? note = null,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var item = await _context.InventoryItems.FindAsync(new object[] { inventoryItemId }, cancellationToken);
if (item == null) return false;
// بررسی موجودی قابل دسترس
if (item.AvailableQuantity < quantity)
{
return false;
}
// رزرو موجودی
item.ReservedQuantity += quantity;
// ثبت حرکت رزرو
var stockMovement = new StockMovement
{
InventoryItemId = inventoryItemId,
MovementType = StockMovementType.Reserved,
Quantity = quantity,
Note = note ?? "Quantity reserved",
ReferenceNumber = referenceNumber,
OrderId = orderId,
DiscountOrderId = discountOrderId,
PerformedByUserId = performedByUserId
};
_context.StockMovements.Add(stockMovement);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> ReleaseReservedQuantityAsync(
long inventoryItemId,
int quantity,
string? note = null,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var item = await _context.InventoryItems.FindAsync(new object[] { inventoryItemId }, cancellationToken);
if (item == null) return false;
// بررسی اینکه مقدار رزرو شده کافی باشد
if (item.ReservedQuantity < quantity)
{
return false;
}
// آزاد کردن رزرو
item.ReservedQuantity -= quantity;
// ثبت حرکت آزادسازی
var stockMovement = new StockMovement
{
InventoryItemId = inventoryItemId,
MovementType = StockMovementType.Released,
Quantity = quantity,
Note = note ?? "Reserved quantity released",
ReferenceNumber = referenceNumber,
OrderId = orderId,
DiscountOrderId = discountOrderId,
PerformedByUserId = performedByUserId
};
_context.StockMovements.Add(stockMovement);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
#endregion
#region Bulk Operations
public async Task<bool> BulkUpdateQuantityAsync(
List<(long InventoryItemId, int QuantityChange, string? Note)> updates,
StockMovementType movementType,
string? referenceNumber = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var inventoryItemIds = updates.Select(u => u.InventoryItemId).ToList();
var items = await _context.InventoryItems
.Where(i => inventoryItemIds.Contains(i.Id))
.ToListAsync(cancellationToken);
if (items.Count != updates.Count)
{
return false; // برخی آیتم‌ها پیدا نشدند
}
var stockMovements = new List<StockMovement>();
foreach (var update in updates)
{
var item = items.First(i => i.Id == update.InventoryItemId);
// بررسی موجودی کافی
if (update.QuantityChange < 0 && item.Quantity + update.QuantityChange < 0)
{
return false;
}
item.Quantity += update.QuantityChange;
if (movementType == StockMovementType.Sale)
{
item.LastSoldAt = DateTime.UtcNow;
}
else if (movementType == StockMovementType.Restock || movementType == StockMovementType.InitialStock)
{
item.LastRestockedAt = DateTime.UtcNow;
}
stockMovements.Add(new StockMovement
{
InventoryItemId = update.InventoryItemId,
MovementType = movementType,
Quantity = Math.Abs(update.QuantityChange),
Note = update.Note,
ReferenceNumber = referenceNumber,
PerformedByUserId = performedByUserId
});
}
_context.StockMovements.AddRange(stockMovements);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<bool> BulkReserveQuantityAsync(
List<(long InventoryItemId, int Quantity, string? Note)> reservations,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var inventoryItemIds = reservations.Select(r => r.InventoryItemId).ToList();
var items = await _context.InventoryItems
.Where(i => inventoryItemIds.Contains(i.Id))
.ToListAsync(cancellationToken);
if (items.Count != reservations.Count)
{
return false;
}
var stockMovements = new List<StockMovement>();
foreach (var reservation in reservations)
{
var item = items.First(i => i.Id == reservation.InventoryItemId);
// بررسی موجودی قابل دسترس
if (item.AvailableQuantity < reservation.Quantity)
{
return false;
}
item.ReservedQuantity += reservation.Quantity;
stockMovements.Add(new StockMovement
{
InventoryItemId = reservation.InventoryItemId,
MovementType = StockMovementType.Reserved,
Quantity = reservation.Quantity,
Note = reservation.Note ?? "Bulk reservation",
ReferenceNumber = referenceNumber,
OrderId = orderId,
DiscountOrderId = discountOrderId,
PerformedByUserId = performedByUserId
});
}
_context.StockMovements.AddRange(stockMovements);
await _context.SaveChangesAsync(cancellationToken);
return true;
}
#endregion
}
@@ -0,0 +1,430 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Interfaces.Repositories;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation برای مدیریت حرکات موجودی
/// </summary>
public class StockMovementRepository : IStockMovementRepository
{
private readonly IApplicationDbContext _context;
public StockMovementRepository(IApplicationDbContext context)
{
_context = context;
}
#region Read Operations
public async Task<StockMovement?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.FirstOrDefaultAsync(m => m.Id == id, cancellationToken);
}
public async Task<List<StockMovement>> GetByInventoryItemIdAsync(
long inventoryItemId,
StockMovementType? movementType = null,
DateTime? fromDate = null,
DateTime? toDate = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Include(m => m.InventoryItem)
.Where(m => m.InventoryItemId == inventoryItemId);
if (movementType.HasValue)
{
query = query.Where(m => m.MovementType == movementType.Value);
}
if (fromDate.HasValue)
{
query = query.Where(m => m.Created >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(m => m.Created <= toDate.Value);
}
return await query
.OrderByDescending(m => m.Created)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> GetByOrderIdAsync(long orderId, CancellationToken cancellationToken = default)
{
return await _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.OrderId == orderId)
.OrderByDescending(m => m.Created)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> GetByDiscountOrderIdAsync(long discountOrderId, CancellationToken cancellationToken = default)
{
return await _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.DiscountOrderId == discountOrderId)
.OrderByDescending(m => m.Created)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> GetByReferenceNumberAsync(string referenceNumber, CancellationToken cancellationToken = default)
{
return await _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.ReferenceNumber == referenceNumber)
.OrderByDescending(m => m.Created)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> GetByMovementTypeAsync(
StockMovementType movementType,
DateTime? fromDate = null,
DateTime? toDate = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.MovementType == movementType);
if (fromDate.HasValue)
{
query = query.Where(m => m.Created >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(m => m.Created <= toDate.Value);
}
return await query
.OrderByDescending(m => m.Created)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> GetRecentMovementsAsync(
int count = 50,
StockMovementType? movementType = null,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.AsQueryable();
if (movementType.HasValue)
{
query = query.Where(m => m.MovementType == movementType.Value);
}
return await query
.OrderByDescending(m => m.Created)
.Take(count)
.ToListAsync(cancellationToken);
}
public async Task<List<StockMovement>> SearchAsync(
long? inventoryItemId = null,
StockMovementType? movementType = null,
DateTime? fromDate = null,
DateTime? toDate = null,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
int skip = 0,
int take = 100,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.AsQueryable();
if (inventoryItemId.HasValue)
{
query = query.Where(m => m.InventoryItemId == inventoryItemId.Value);
}
if (movementType.HasValue)
{
query = query.Where(m => m.MovementType == movementType.Value);
}
if (fromDate.HasValue)
{
query = query.Where(m => m.Created >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(m => m.Created <= toDate.Value);
}
if (!string.IsNullOrWhiteSpace(referenceNumber))
{
query = query.Where(m => m.ReferenceNumber != null && m.ReferenceNumber.Contains(referenceNumber));
}
if (orderId.HasValue)
{
query = query.Where(m => m.OrderId == orderId.Value);
}
if (discountOrderId.HasValue)
{
query = query.Where(m => m.DiscountOrderId == discountOrderId.Value);
}
if (performedByUserId.HasValue)
{
query = query.Where(m => m.PerformedByUserId == performedByUserId.Value);
}
return await query
.OrderByDescending(m => m.Created)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<int> CountAsync(
long? inventoryItemId = null,
StockMovementType? movementType = null,
DateTime? fromDate = null,
DateTime? toDate = null,
string? referenceNumber = null,
long? orderId = null,
long? discountOrderId = null,
long? performedByUserId = null,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements.AsQueryable();
if (inventoryItemId.HasValue)
{
query = query.Where(m => m.InventoryItemId == inventoryItemId.Value);
}
if (movementType.HasValue)
{
query = query.Where(m => m.MovementType == movementType.Value);
}
if (fromDate.HasValue)
{
query = query.Where(m => m.Created >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(m => m.Created <= toDate.Value);
}
if (!string.IsNullOrWhiteSpace(referenceNumber))
{
query = query.Where(m => m.ReferenceNumber != null && m.ReferenceNumber.Contains(referenceNumber));
}
if (orderId.HasValue)
{
query = query.Where(m => m.OrderId == orderId.Value);
}
if (discountOrderId.HasValue)
{
query = query.Where(m => m.DiscountOrderId == discountOrderId.Value);
}
if (performedByUserId.HasValue)
{
query = query.Where(m => m.PerformedByUserId == performedByUserId.Value);
}
return await query.CountAsync(cancellationToken);
}
#endregion
#region Write Operations
public async Task<StockMovement> AddAsync(StockMovement stockMovement, CancellationToken cancellationToken = default)
{
_context.StockMovements.Add(stockMovement);
await _context.SaveChangesAsync(cancellationToken);
return stockMovement;
}
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
var stockMovement = await _context.StockMovements.FindAsync(new object[] { id }, cancellationToken);
if (stockMovement != null)
{
_context.StockMovements.Remove(stockMovement);
await _context.SaveChangesAsync(cancellationToken);
}
}
public async Task<List<StockMovement>> BulkAddAsync(List<StockMovement> stockMovements, CancellationToken cancellationToken = default)
{
_context.StockMovements.AddRange(stockMovements);
await _context.SaveChangesAsync(cancellationToken);
return stockMovements;
}
#endregion
#region Analytics & Reports
public async Task<Dictionary<StockMovementType, int>> GetMovementSummaryAsync(
DateTime fromDate,
DateTime toDate,
long? inventoryItemId = null,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Where(m => m.Created >= fromDate && m.Created <= toDate);
if (inventoryItemId.HasValue)
{
query = query.Where(m => m.InventoryItemId == inventoryItemId.Value);
}
var movements = await query
.GroupBy(m => m.MovementType)
.Select(g => new { MovementType = g.Key, TotalQuantity = g.Sum(m => m.Quantity) })
.ToListAsync(cancellationToken);
return movements.ToDictionary(x => x.MovementType, x => x.TotalQuantity);
}
public async Task<List<(DateTime Date, int InboundQuantity, int OutboundQuantity)>> GetDailyMovementVolumeAsync(
DateTime fromDate,
DateTime toDate,
long? inventoryItemId = null,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Where(m => m.Created >= fromDate && m.Created <= toDate);
if (inventoryItemId.HasValue)
{
query = query.Where(m => m.InventoryItemId == inventoryItemId.Value);
}
var movements = await query.ToListAsync(cancellationToken);
// نوع‌های ورودی (افزایش موجودی)
var inboundTypes = new[]
{
StockMovementType.InitialStock,
StockMovementType.Restock,
StockMovementType.Return,
StockMovementType.TransferIn,
StockMovementType.AdjustmentPlus,
StockMovementType.Released
};
// نوع‌های خروجی (کاهش موجودی)
var outboundTypes = new[]
{
StockMovementType.Sale,
StockMovementType.Damaged,
StockMovementType.Lost,
StockMovementType.TransferOut,
StockMovementType.AdjustmentMinus,
StockMovementType.Reserved
};
var dailyVolumes = movements
.GroupBy(m => m.Created.Date)
.Select(g => (
Date: g.Key,
InboundQuantity: g.Where(m => inboundTypes.Contains(m.MovementType)).Sum(m => m.Quantity),
OutboundQuantity: g.Where(m => outboundTypes.Contains(m.MovementType)).Sum(m => m.Quantity)
))
.OrderBy(x => x.Date)
.ToList();
return dailyVolumes;
}
public async Task<List<(long InventoryItemId, string ProductName, int MovementCount, int TotalQuantityChange)>> GetTopMovingProductsAsync(
DateTime fromDate,
DateTime toDate,
int count = 10,
StockMovementType? movementType = null,
CancellationToken cancellationToken = default)
{
var query = _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.Created >= fromDate && m.Created <= toDate);
if (movementType.HasValue)
{
query = query.Where(m => m.MovementType == movementType.Value);
}
var movements = await query.ToListAsync(cancellationToken);
var topProducts = movements
.GroupBy(m => m.InventoryItemId)
.Select(g =>
{
var firstItem = g.First().InventoryItem;
var productName = firstItem.Product?.Title ?? firstItem.DiscountProduct?.Title ?? "Unknown";
return (
InventoryItemId: g.Key,
ProductName: productName,
MovementCount: g.Count(),
TotalQuantityChange: g.Sum(m => m.Quantity)
);
})
.OrderByDescending(x => x.MovementCount)
.Take(count)
.ToList();
return topProducts;
}
#endregion
}
@@ -0,0 +1,309 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Interfaces.Repositories;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation برای مدیریت انبارها
/// </summary>
public class WarehouseRepository : IWarehouseRepository
{
private readonly IApplicationDbContext _context;
public WarehouseRepository(IApplicationDbContext context)
{
_context = context;
}
#region Read Operations
public async Task<Warehouse?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.Warehouses
.Include(w => w.InventoryItems)
.ThenInclude(i => i.Product)
.Include(w => w.InventoryItems)
.ThenInclude(i => i.DiscountProduct)
.FirstOrDefaultAsync(w => w.Id == id, cancellationToken);
}
public async Task<Warehouse?> GetByCodeAsync(string code, CancellationToken cancellationToken = default)
{
return await _context.Warehouses
.Include(w => w.InventoryItems)
.ThenInclude(i => i.Product)
.Include(w => w.InventoryItems)
.ThenInclude(i => i.DiscountProduct)
.FirstOrDefaultAsync(w => w.Code == code, cancellationToken);
}
public async Task<Warehouse?> GetDefaultWarehouseAsync(CancellationToken cancellationToken = default)
{
return await _context.Warehouses
.Include(w => w.InventoryItems)
.ThenInclude(i => i.Product)
.Include(w => w.InventoryItems)
.ThenInclude(i => i.DiscountProduct)
.FirstOrDefaultAsync(w => w.IsDefault, cancellationToken);
}
public async Task<List<Warehouse>> GetActiveWarehousesAsync(CancellationToken cancellationToken = default)
{
return await _context.Warehouses
.Where(w => w.IsActive)
.OrderBy(w => w.Name)
.ToListAsync(cancellationToken);
}
public async Task<List<Warehouse>> GetAllAsync(
bool includeInactive = false,
CancellationToken cancellationToken = default)
{
var query = _context.Warehouses.AsQueryable();
if (!includeInactive)
{
query = query.Where(w => w.IsActive);
}
return await query
.OrderBy(w => w.Name)
.ToListAsync(cancellationToken);
}
public async Task<List<Warehouse>> SearchAsync(
string? searchTerm = null,
bool? isActive = null,
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default)
{
var query = _context.Warehouses.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(w =>
w.Name.ToLower().Contains(term) ||
w.Code.ToLower().Contains(term) ||
(w.Address != null && w.Address.ToLower().Contains(term)));
}
if (isActive.HasValue)
{
query = query.Where(w => w.IsActive == isActive.Value);
}
return await query
.OrderBy(w => w.Name)
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<int> CountAsync(
string? searchTerm = null,
bool? isActive = null,
CancellationToken cancellationToken = default)
{
var query = _context.Warehouses.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var term = searchTerm.ToLower();
query = query.Where(w =>
w.Name.ToLower().Contains(term) ||
w.Code.ToLower().Contains(term) ||
(w.Address != null && w.Address.ToLower().Contains(term)));
}
if (isActive.HasValue)
{
query = query.Where(w => w.IsActive == isActive.Value);
}
return await query.CountAsync(cancellationToken);
}
public async Task<bool> ExistsByCodeAsync(string code, long? excludeId = null, CancellationToken cancellationToken = default)
{
var query = _context.Warehouses.Where(w => w.Code == code);
if (excludeId.HasValue)
{
query = query.Where(w => w.Id != excludeId.Value);
}
return await query.AnyAsync(cancellationToken);
}
#endregion
#region Write Operations
public async Task<Warehouse> AddAsync(Warehouse warehouse, CancellationToken cancellationToken = default)
{
// اگر این انبار پیش‌فرض است، سایر انبارها را غیرپیش‌فرض کن
if (warehouse.IsDefault)
{
await RemoveDefaultFromAllWarehousesAsync(cancellationToken);
}
_context.Warehouses.Add(warehouse);
await _context.SaveChangesAsync(cancellationToken);
return warehouse;
}
public async Task UpdateAsync(Warehouse warehouse, CancellationToken cancellationToken = default)
{
// اگر این انبار پیش‌فرض شده، سایر انبارها را غیرپیش‌فرض کن
if (warehouse.IsDefault)
{
await RemoveDefaultFromAllWarehousesAsync(warehouse.Id, cancellationToken);
}
_context.Warehouses.Update(warehouse);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
var warehouse = await _context.Warehouses.FindAsync(new object[] { id }, cancellationToken);
if (warehouse != null)
{
warehouse.IsActive = false;
await _context.SaveChangesAsync(cancellationToken);
}
}
public async Task SetActiveStatusAsync(long id, bool isActive, CancellationToken cancellationToken = default)
{
var warehouse = await _context.Warehouses.FindAsync(new object[] { id }, cancellationToken);
if (warehouse != null)
{
warehouse.IsActive = isActive;
await _context.SaveChangesAsync(cancellationToken);
}
}
public async Task SetAsDefaultAsync(long id, CancellationToken cancellationToken = default)
{
// ابتدا همه انبارها را غیرپیش‌فرض کن
await RemoveDefaultFromAllWarehousesAsync(cancellationToken);
// سپس انبار مورد نظر را پیش‌فرض کن
var warehouse = await _context.Warehouses.FindAsync(new object[] { id }, cancellationToken);
if (warehouse != null)
{
warehouse.IsDefault = true;
await _context.SaveChangesAsync(cancellationToken);
}
}
#endregion
#region Analytics
public async Task<(int TotalProducts, int LowStockProducts, int OutOfStockProducts, decimal TotalValue)> GetWarehouseStatisticsAsync(
long warehouseId,
CancellationToken cancellationToken = default)
{
var inventoryItems = await _context.InventoryItems
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Where(i => i.WarehouseId == warehouseId)
.ToListAsync(cancellationToken);
var totalProducts = inventoryItems.Count;
var lowStockProducts = inventoryItems.Count(i => i.Quantity <= i.LowStockThreshold && i.Quantity > 0);
var outOfStockProducts = inventoryItems.Count(i => i.Quantity == 0);
// محاسبه ارزش کل بر اساس قیمت محصولات
decimal totalValue = 0;
foreach (var item in inventoryItems)
{
if (item.Product != null)
{
totalValue += item.Quantity * item.Product.Price;
}
else if (item.DiscountProduct != null)
{
totalValue += item.Quantity * item.DiscountProduct.Price;
}
}
return (totalProducts, lowStockProducts, outOfStockProducts, totalValue);
}
public async Task<List<(long ProductId, string ProductName, int TotalSold, int CurrentStock)>> GetTopSellingProductsAsync(
long warehouseId,
DateTime fromDate,
DateTime toDate,
int count = 10,
CancellationToken cancellationToken = default)
{
// دریافت حرکات فروش برای این انبار در بازه زمانی مشخص
var salesMovements = await _context.StockMovements
.Include(m => m.InventoryItem)
.ThenInclude(i => i.Product)
.Include(m => m.InventoryItem)
.ThenInclude(i => i.DiscountProduct)
.Where(m => m.InventoryItem.WarehouseId == warehouseId &&
m.MovementType == StockMovementType.Sale &&
m.Created >= fromDate &&
m.Created <= toDate)
.ToListAsync(cancellationToken);
// گروه‌بندی بر اساس محصول و محاسبه تعداد فروش
var topProducts = salesMovements
.GroupBy(m => m.InventoryItemId)
.Select(g =>
{
var firstItem = g.First().InventoryItem;
var productId = firstItem.ProductId ?? firstItem.DiscountProductId ?? 0;
var productName = firstItem.Product?.Title ?? firstItem.DiscountProduct?.Title ?? "Unknown";
var totalSold = g.Sum(m => m.Quantity);
var currentStock = firstItem.Quantity;
return (ProductId: productId, ProductName: productName, TotalSold: totalSold, CurrentStock: currentStock);
})
.OrderByDescending(x => x.TotalSold)
.Take(count)
.ToList();
return topProducts;
}
#endregion
#region Private Methods
private async Task RemoveDefaultFromAllWarehousesAsync(CancellationToken cancellationToken = default)
{
var defaultWarehouses = await _context.Warehouses
.Where(w => w.IsDefault)
.ToListAsync(cancellationToken);
foreach (var warehouse in defaultWarehouses)
{
warehouse.IsDefault = false;
}
}
private async Task RemoveDefaultFromAllWarehousesAsync(long excludeId, CancellationToken cancellationToken = default)
{
var defaultWarehouses = await _context.Warehouses
.Where(w => w.IsDefault && w.Id != excludeId)
.ToListAsync(cancellationToken);
foreach (var warehouse in defaultWarehouses)
{
warehouse.IsDefault = false;
}
}
#endregion
}