# 📦 سیستم انبارداری یکپارچه (Unified Inventory Management) > **تاریخ ایجاد:** ۱۲ دی ۱۴۰۴ (1 January 2026) > **تاریخ تکمیل:** ۱۲ دی ۱۴۰۴ (1 January 2026) > **وضعیت:** ✅ **COMPLETED** > **اولویت:** 🟡 Medium --- ## ✅ خلاصه اجرایی (Implementation Summary) ### فایل‌های ایجاد شده در CMS: #### Domain Layer - `Domain/Entities/InventoryItem.cs` - موجودیت اصلی انبار - `Domain/Entities/StockMovement.cs` - تاریخچه حرکات انبار - `Domain/Entities/Warehouse.cs` - انبارها - `Domain/Enums/StockMovementType.cs` - انواع حرکات انبار - `Domain/Enums/ProductType.cs` - نوع محصول (معمولی/تخفیفی) #### Infrastructure Layer - `Infrastructure/Persistence/Configurations/InventoryItemConfiguration.cs` - `Infrastructure/Persistence/Configurations/StockMovementConfiguration.cs` - `Infrastructure/Persistence/Configurations/WarehouseConfiguration.cs` - `Infrastructure/Persistence/Repositories/InventoryItemRepository.cs` - `Infrastructure/Persistence/Repositories/StockMovementRepository.cs` - `Infrastructure/Services/InventoryService.cs` - سرویس اصلی (663 خط) - `Infrastructure/Persistence/Migrations/20251231234634_AddInventorySystem.cs` #### Application Layer - `Application/Common/Interfaces/IInventoryService.cs` - (217 خط) - `Application/Common/Interfaces/Repositories/IInventoryItemRepository.cs` - `Application/Common/Interfaces/Repositories/IStockMovementRepository.cs` - `Application/Features/InventoryItems/Commands/InventoryItemCommands.cs` - `Application/Features/InventoryItems/Queries/InventoryItemQueries.cs` - `Application/Features/InventoryItems/Handlers/InventoryItemCommandHandlers.cs` - `Application/Features/InventoryItems/Handlers/InventoryItemQueryHandlers.cs` - `Application/Features/Warehouses/Commands/WarehouseCommands.cs` - `Application/Features/Warehouses/Queries/WarehouseQueries.cs` - `Application/Features/Warehouses/Handlers/WarehouseCommandHandlers.cs` - `Application/Features/Warehouses/Handlers/WarehouseQueryHandlers.cs` - `Application/Features/StockMovements/Commands/StockMovementCommands.cs` - `Application/Features/StockMovements/Queries/StockMovementQueries.cs` - `Application/Features/StockMovements/Handlers/StockMovementCommandHandlers.cs` - `Application/Features/StockMovements/Handlers/StockMovementQueryHandlers.cs` #### gRPC Layer - `CMSMicroservice.Protobuf/Protos/inventory.proto` - (530 خط) - `WebApi/Services/InventoryService.cs` - gRPC service (235 خط) ### قابلیت‌های اصلی: - ✅ مدیریت موجودی محصولات معمولی و تخفیفی - ✅ رزرو موجودی برای سفارشات pending - ✅ تایید فروش و کسر موجودی - ✅ برگشت کالا و افزایش موجودی - ✅ ورود کالا به انبار (Restock) - ✅ تعدیل موجودی (Adjustment) - ✅ ثبت ضایعات و مفقودی - ✅ لیست محصولات کم‌موجود (Low Stock) - ✅ تاریخچه کامل حرکات انبار - ✅ همگام‌سازی خودکار با `Product.RemainingCount` - ✅ پشتیبانی از چند انبار (Multi-Warehouse ready) ### یکپارچه‌سازی با Handlers: - ✅ `CreateProductCommandHandler` - ایجاد خودکار InventoryItem - ✅ `CreateDiscountProductCommandHandler` - ایجاد خودکار InventoryItem - ✅ `PlaceOrderCommandHandler` - رزرو موجودی - ✅ `CompleteOrderPaymentCommandHandler` - تایید فروش - ✅ `CancelOrderCommandHandler` - آزادسازی رزرو ### باقیمانده (نیاز به تکمیل): - ⬜ Migration داده‌های موجود به سیستم جدید (Phase 5) - ⬜ تست‌های واحد و یکپارچه‌سازی (Phase 7) - ⬜ UI در BackOffice (صفحات راه ندارند - فقط منو در NavMenu اضافه شده) ### BFF Layer: - ✅ `Application/ProductsCQ/Queries/GetLowStockProducts/GetLowStockProductsQuery.cs` - ✅ `Application/ProductsCQ/Queries/GetLowStockProducts/GetLowStockProductsQueryHandler.cs` ### BackOffice (Frontend): - ✅ منوی "انبارداری" در `NavMenu.razor` اضافه شده - ⬜ صفحه `/inventory/low-stock` - نیاز به ایجاد - ⬜ صفحه `/products/bulk-edit` - موجود ولی نیاز به بررسی --- ## 🎯 هدف ایجاد یک سیستم انبارداری مرکزی که موجودی هر دو فروشگاه (معمولی و تخفیفی) را از یک نقطه مدیریت کند، با قابلیت: - ردیابی حرکات موجودی (ورود، خروج، تعدیل) - رزرو موقت برای سفارشات pending - هشدار کمبود موجودی - گزارش‌گیری جامع --- ## 📊 وضعیت فعلی ### ساختار موجود | جنبه | فروشگاه معمولی (Product) | فروشگاه تخفیفی (DiscountProduct) | |------|--------------------------|----------------------------------| | فیلد موجودی | `RemainingCount` | `RemainingCount` | | موجودی اولیه | `remaining_count` در Create | `initial_count` در Create | | آپدیت دسته‌جمعی | ✅ بله (SET/ADD/SUBTRACT) | ❌ خیر | | کوئری کم‌موجودی | ✅ بله | ❌ خیر | | چک موجودی در سبد | ✅ بله | ✅ بله | | کسر موجودی در سفارش | ✅ بله | ✅ بله | ### مشکلات فعلی 1. **عدم ردیابی حرکات:** هیچ لاگی از تغییرات موجودی نگهداری نمی‌شود 2. **عدم رزرو موقت:** موجودی در زمان پرداخت کسر می‌شود، نه در زمان افزودن به سبد 3. **عدم یکپارچگی:** هر فروشگاه مستقل مدیریت می‌شود 4. **عدم پشتیبانی چند انبار:** برای آینده محدودیت دارد --- ## 🏗️ معماری پیشنهادی ### Entity های جدید #### 1. InventoryItem (موجودی کالا) ```csharp public class InventoryItem : BaseAuditableEntity { // شناسه محصول (یکی از دو فیلد زیر پر است) public long? ProductId { get; set; } public Product? Product { get; set; } public long? DiscountProductId { get; set; } public DiscountProduct? DiscountProduct { get; set; } // نوع محصول public ProductType ProductType { get; set; } // موجودی public int Quantity { get; set; } // موجودی فعلی public int ReservedQuantity { get; set; } // رزرو شده برای سفارشات pending public int AvailableQuantity => Quantity - ReservedQuantity; // قابل فروش // تنظیمات public int LowStockThreshold { get; set; } = 10; // آستانه هشدار کم‌موجودی public int ReorderPoint { get; set; } = 5; // نقطه سفارش مجدد public int MaxStockLevel { get; set; } = 1000; // حداکثر موجودی // آمار public DateTime? LastRestockedAt { get; set; } // آخرین ورود کالا public DateTime? LastSoldAt { get; set; } // آخرین فروش // انبار (برای آینده) public long WarehouseId { get; set; } = 1; // پیش‌فرض: انبار اصلی public Warehouse? Warehouse { get; set; } // Navigation public ICollection StockMovements { get; set; } = new List(); } ``` #### 2. StockMovement (حرکات انبار) ```csharp public class StockMovement : BaseAuditableEntity { public long InventoryItemId { get; set; } public InventoryItem InventoryItem { get; set; } = null!; // نوع حرکت public StockMovementType MovementType { get; set; } // مقادیر public int Quantity { get; set; } // مقدار تغییر (مثبت یا منفی) public int QuantityBefore { get; set; } // موجودی قبل public int QuantityAfter { get; set; } // موجودی بعد // مرجع public long? OrderId { get; set; } // شناسه سفارش (برای فروش/برگشت) public long? DiscountOrderId { get; set; } // شناسه سفارش تخفیفی public string? ReferenceNumber { get; set; } // شماره مرجع (فاکتور ورود و...) // توضیحات public string? Note { get; set; } // یادداشت // کاربر public long? PerformedByUserId { get; set; } // کاربری که عملیات را انجام داد } ``` #### 3. Warehouse (انبار) - اختیاری برای آینده ```csharp public class Warehouse : BaseAuditableEntity { public string Name { get; set; } = null!; // نام انبار public string Code { get; set; } = null!; // کد انبار public string? Address { get; set; } // آدرس public bool IsDefault { get; set; } // انبار پیش‌فرض public bool IsActive { get; set; } = true; // Navigation public ICollection InventoryItems { get; set; } = new List(); } ``` ### Enum های جدید ```csharp public enum ProductType { RegularProduct = 1, // محصول فروشگاه معمولی DiscountProduct = 2 // محصول فروشگاه تخفیفی } public enum StockMovementType { // ورودی InitialStock = 1, // موجودی اولیه Restock = 2, // ورود کالا Return = 3, // برگشت از مشتری TransferIn = 4, // انتقال از انبار دیگر // خروجی Sale = 10, // فروش Damaged = 11, // ضایعات Lost = 12, // مفقودی TransferOut = 13, // انتقال به انبار دیگر // تعدیل AdjustmentPlus = 20, // تعدیل افزایشی AdjustmentMinus = 21, // تعدیل کاهشی // رزرو Reserved = 30, // رزرو برای سفارش pending Released = 31 // آزادسازی رزرو (لغو سفارش) } ``` --- ## 🔧 سرویس‌ها و Handlers ### IInventoryService Interface ```csharp public interface IInventoryService { // موجودی Task GetInventoryAsync(long productId, ProductType type, CancellationToken ct = default); Task GetAvailableQuantityAsync(long productId, ProductType type, CancellationToken ct = default); // رزرو (برای سفارش pending) Task ReserveStockAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default); Task ReleaseReservationAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default); // کسر موجودی (پرداخت موفق) Task ConfirmSaleAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default); // برگشت Task ProcessReturnAsync(long productId, ProductType type, int quantity, long? orderId, string? note, CancellationToken ct = default); // ورود کالا Task AddStockAsync(long productId, ProductType type, int quantity, string? referenceNumber, string? note, CancellationToken ct = default); // تعدیل Task AdjustStockAsync(long productId, ProductType type, int newQuantity, string? note, CancellationToken ct = default); // گزارش Task> GetLowStockItemsAsync(ProductType? type = null, CancellationToken ct = default); Task> GetStockMovementsAsync(long productId, ProductType type, DateTime? from, DateTime? to, CancellationToken ct = default); } ``` ### CQRS Handlers #### Commands | Command | توضیح | |---------|-------| | `AddStockCommand` | ورود کالا به انبار | | `AdjustStockCommand` | تعدیل موجودی | | `ReserveStockCommand` | رزرو برای سفارش | | `ReleaseStockCommand` | آزادسازی رزرو | | `ConfirmSaleCommand` | تایید فروش و کسر از موجودی | | `ProcessReturnCommand` | برگشت کالا | | `BulkUpdateStockCommand` | آپدیت دسته‌جمعی | #### Queries | Query | توضیح | |-------|-------| | `GetInventoryItemQuery` | دریافت موجودی یک محصول | | `GetInventoryItemsQuery` | لیست موجودی‌ها با فیلتر | | `GetLowStockItemsQuery` | آیتم‌های کم‌موجودی | | `GetStockMovementsQuery` | تاریخچه حرکات انبار | | `GetInventoryReportQuery` | گزارش انبارگردانی | --- ## 📋 مراحل پیاده‌سازی (Implementation Roadmap) > **مجموع تخمینی:** 10-12 روز کاری > **پیش‌نیاز:** آشنایی با معماری CQRS و ساختار پروژه CMS --- ### 🔵 Phase 1: زیرساخت Domain (2 روز) **هدف:** ایجاد Entity ها و Enum ها در لایه Domain #### Day 1: Enums و Entity های اصلی | # | تسک | فایل | وضعیت | |---|------|------|--------| | 1.1 | ایجاد `ProductType` enum | `Domain/Enums/ProductType.cs` | ✅ | | 1.2 | ایجاد `StockMovementType` enum | `Domain/Enums/StockMovementType.cs` | ✅ | | 1.3 | ایجاد `InventoryItem` entity | `Domain/Entities/InventoryItem.cs` | ✅ | | 1.4 | ایجاد `StockMovement` entity | `Domain/Entities/StockMovement.cs` | ✅ | | 1.5 | ایجاد `Warehouse` entity (اختیاری) | `Domain/Entities/Warehouse.cs` | ✅ | #### Day 2: EF Core Configurations | # | تسک | فایل | وضعیت | |---|------|------|--------| | 1.6 | ایجاد `InventoryItemConfiguration` | `Infrastructure/Data/Configurations/InventoryItemConfiguration.cs` | ✅ | | 1.7 | ایجاد `StockMovementConfiguration` | `Infrastructure/Data/Configurations/StockMovementConfiguration.cs` | ✅ | | 1.8 | ایجاد `WarehouseConfiguration` | `Infrastructure/Data/Configurations/WarehouseConfiguration.cs` | ✅ | | 1.9 | اضافه کردن DbSet ها به `ApplicationDbContext` | `Infrastructure/Data/ApplicationDbContext.cs` | ✅ | | 1.10 | ایجاد Migration | `dotnet ef migrations add AddInventorySystem` | ✅ | | 1.11 | اعمال Migration | `dotnet ef database update` | ✅ | **خروجی Phase 1:** - ✅ جداول `InventoryItems`, `StockMovements`, `Warehouses` در دیتابیس - ✅ Index های لازم روی `ProductId`, `DiscountProductId`, `ProductType` --- ### 🟢 Phase 2: سرویس Inventory (2 روز) **هدف:** پیاده‌سازی `IInventoryService` و متدهای اصلی #### Day 3: Interface و متدهای اصلی | # | تسک | فایل | وضعیت | |---|------|------|--------| | 2.1 | ایجاد `IInventoryService` interface | `Application/Common/Interfaces/IInventoryService.cs` | ✅ | | 2.2 | ایجاد `InventoryService` class | `Infrastructure/Services/InventoryService.cs` | ✅ | | 2.3 | پیاده‌سازی `InitializeInventoryAsync` | در `InventoryService.cs` | ✅ | | 2.4 | پیاده‌سازی `GetInventoryAsync` | در `InventoryService.cs` | ✅ | | 2.5 | پیاده‌سازی `GetAvailableQuantityAsync` | در `InventoryService.cs` | ✅ | #### Day 4: متدهای عملیاتی | # | تسک | فایل | وضعیت | |---|------|------|--------| | 2.6 | پیاده‌سازی `AddStockAsync` | در `InventoryService.cs` | ✅ | | 2.7 | پیاده‌سازی `AdjustStockAsync` | در `InventoryService.cs` | ✅ | | 2.8 | پیاده‌سازی `ReserveStockAsync` | در `InventoryService.cs` | ✅ | | 2.9 | پیاده‌سازی `ReleaseReservationAsync` | در `InventoryService.cs` | ✅ | | 2.10 | پیاده‌سازی `ConfirmSaleAsync` | در `InventoryService.cs` | ✅ | | 2.11 | پیاده‌سازی `ProcessReturnAsync` | در `InventoryService.cs` | ✅ | | 2.12 | پیاده‌سازی `SyncRemainingCountAsync` (private) | در `InventoryService.cs` | ✅ | | 2.13 | پیاده‌سازی `LogMovementAsync` (private) | در `InventoryService.cs` | ✅ | | 2.14 | ثبت سرویس در DI | `Infrastructure/DependencyInjection.cs` | ✅ | **خروجی Phase 2:** - ✅ `InventoryService` کامل و قابل استفاده - ✅ همه متدها با Sync به `RemainingCount` --- ### 🟡 Phase 3: CQRS Handlers (2 روز) **هدف:** ایجاد Commands و Queries برای Inventory #### Day 5: Commands | # | تسک | فایل | وضعیت | |---|------|------|--------| | 3.1 | `AddStockCommand` + Handler + Validator | `Application/Inventory/Commands/AddStock/` | ✅ | | 3.2 | `AdjustStockCommand` + Handler + Validator | `Application/Inventory/Commands/AdjustStock/` | ✅ | | 3.3 | `ReserveStockCommand` + Handler + Validator | `Application/Inventory/Commands/ReserveStock/` | ✅ | | 3.4 | `ReleaseStockCommand` + Handler + Validator | `Application/Inventory/Commands/ReleaseStock/` | ✅ | | 3.5 | `ConfirmSaleCommand` + Handler + Validator | `Application/Inventory/Commands/ConfirmSale/` | ✅ | | 3.6 | `ProcessReturnCommand` + Handler + Validator | `Application/Inventory/Commands/ProcessReturn/` | ✅ | | 3.7 | `BulkAdjustStockCommand` + Handler + Validator | `Application/Inventory/Commands/BulkAdjustStock/` | ✅ | #### Day 6: Queries | # | تسک | فایل | وضعیت | |---|------|------|--------| | 3.8 | `GetInventoryItemQuery` + Handler | `Application/Inventory/Queries/GetInventoryItem/` | ✅ | | 3.9 | `GetInventoryItemsQuery` + Handler (با Pagination) | `Application/Inventory/Queries/GetInventoryItems/` | ✅ | | 3.10 | `GetLowStockItemsQuery` + Handler | `Application/Inventory/Queries/GetLowStockItems/` | ✅ | | 3.11 | `GetStockMovementsQuery` + Handler | `Application/Inventory/Queries/GetStockMovements/` | ✅ | | 3.12 | `GetInventoryReportQuery` + Handler | `Application/Inventory/Queries/GetInventoryReport/` | ✅ | **خروجی Phase 3:** - ✅ 7 Command با Validator - ✅ 5 Query با Pagination --- ### 🟠 Phase 4: یکپارچه‌سازی با Product Handlers (1.5 روز) **هدف:** تغییر Handler های موجود برای استفاده از `InventoryService` #### Day 7: Product Handlers | # | تسک | توضیح | وضعیت | |---|------|-------|--------| | 4.1 | آپدیت `CreateProductCommandHandler` | اضافه کردن `InitializeInventoryAsync` | ✅ | | 4.2 | آپدیت `CreateDiscountProductCommandHandler` | اضافه کردن `InitializeInventoryAsync` | ✅ | | 4.3 | آپدیت `UpdateProductCommandHandler` | اضافه کردن `AdjustStockAsync` (اگر موجودی تغییر کرد) | ⬜ | | 4.4 | آپدیت `UpdateDiscountProductCommandHandler` | اضافه کردن `AdjustStockAsync` | ⬜ | | 4.5 | آپدیت `BulkUpdateProductStockCommandHandler` | استفاده از `BulkAdjustStockAsync` | ⬜ | #### Day 7.5: Order Handlers | # | تسک | توضیح | وضعیت | |---|------|-------|--------| | 4.6 | آپدیت `PlaceOrderCommandHandler` | اضافه کردن `ReserveStockAsync` | ✅ | | 4.7 | آپدیت `PlaceDiscountOrderCommandHandler` | اضافه کردن `ReserveStockAsync` | ✅ | | 4.8 | آپدیت `CompleteOrderPaymentCommandHandler` | تغییر به `ConfirmSaleAsync` | ✅ | | 4.9 | آپدیت `CompleteDiscountOrderPaymentCommandHandler` | تغییر به `ConfirmSaleAsync` | ✅ | | 4.10 | آپدیت `CancelOrderCommandHandler` | اضافه کردن `ReleaseReservationAsync` | ✅ | | 4.11 | آپدیت `CancelDiscountOrderCommandHandler` | اضافه کردن `ReleaseReservationAsync` | ⬜ | **خروجی Phase 4:** - ✅ همه Handler های Product از `InventoryService` استفاده می‌کنند - ✅ همه Handler های Order از Reserve/Confirm/Release استفاده می‌کنند --- ### 🔴 Phase 5: Migration داده‌ها (0.5 روز) **هدف:** انتقال داده‌های موجود به سیستم جدید #### Day 8 (نیمه اول): | # | تسک | توضیح | وضعیت | |---|------|-------|--------| | 5.1 | ایجاد Seed Script برای Warehouse پیش‌فرض | `INSERT INTO Warehouses (Name, Code, IsDefault) VALUES ('انبار اصلی', 'WH-001', 1)` | ⬜ | | 5.2 | ایجاد Migration Script برای Products | انتقال `RemainingCount` به `InventoryItem` | ⬜ | | 5.3 | ایجاد Migration Script برای DiscountProducts | انتقال `RemainingCount` به `InventoryItem` | ⬜ | | 5.4 | ایجاد StockMovement های اولیه | یک رکورد `InitialStock` برای هر محصول | ⬜ | | 5.5 | تست صحت داده‌ها | `SELECT COUNT(*) FROM Products WHERE RemainingCount != (SELECT Quantity FROM InventoryItems WHERE ProductId = Products.Id)` | ⬜ | ```sql -- Migration Script نمونه: INSERT INTO InventoryItems (ProductId, DiscountProductId, ProductType, Quantity, ReservedQuantity, WarehouseId, CreatedAt) SELECT Id, NULL, 1, RemainingCount, 0, 1, GETUTCDATE() FROM Products WHERE NOT EXISTS (SELECT 1 FROM InventoryItems WHERE ProductId = Products.Id); INSERT INTO InventoryItems (ProductId, DiscountProductId, ProductType, Quantity, ReservedQuantity, WarehouseId, CreatedAt) SELECT NULL, Id, 2, RemainingCount, 0, 1, GETUTCDATE() FROM DiscountProducts WHERE NOT EXISTS (SELECT 1 FROM InventoryItems WHERE DiscountProductId = DiscountProducts.Id); ``` **خروجی Phase 5:** - ✅ همه محصولات موجود `InventoryItem` دارند - ✅ داده‌ها sync و سالم هستند --- ### 🟣 Phase 6: Proto و gRPC (1 روز) **هدف:** Expose کردن API های Inventory از طریق gRPC #### Day 8 (نیمه دوم) + Day 9: | # | تسک | فایل | وضعیت | |---|------|------|--------| | 6.1 | ایجاد `inventory.proto` | `Protobufs/inventory.proto` | ✅ | | 6.2 | کامپایل Proto | `pack-protos.sh` | ✅ | | 6.3 | ایجاد `InventoryService.cs` در WebApi | `WebApi/Services/InventoryService.cs` | ✅ | | 6.4 | ایجاد Mapping Profile | `Application/Common/Mappings/InventoryMappingProfile.cs` | ✅ | | 6.5 | ثبت سرویس gRPC در `Program.cs` | `WebApi/Program.cs` | ✅ | ```protobuf // inventory.proto syntax = "proto3"; package cms.inventory; service InventoryService { rpc GetInventoryItem(GetInventoryItemRequest) returns (InventoryItemResponse); rpc GetInventoryItems(GetInventoryItemsRequest) returns (GetInventoryItemsResponse); rpc AddStock(AddStockRequest) returns (OperationResponse); rpc AdjustStock(AdjustStockRequest) returns (OperationResponse); rpc GetLowStockItems(GetLowStockItemsRequest) returns (GetLowStockItemsResponse); rpc GetStockMovements(GetStockMovementsRequest) returns (GetStockMovementsResponse); } ``` **خروجی Phase 6:** - ✅ gRPC endpoint برای Inventory - ✅ قابل استفاده توسط BackOffice و BFF --- ### ⚪ Phase 7: تست و QA (1 روز) **هدف:** اطمینان از صحت عملکرد سیستم #### Day 10: | # | تسک | توضیح | وضعیت | |---|------|-------|--------| | 7.1 | Unit Test برای `InventoryService` | تست همه متدها | ⬜ | | 7.2 | Unit Test برای Validators | تست validation rules | ⬜ | | 7.3 | Integration Test برای CQRS Handlers | تست end-to-end | ⬜ | | 7.4 | Manual Test: ایجاد محصول | چک کردن ایجاد خودکار InventoryItem | ⬜ | | 7.5 | Manual Test: ثبت سفارش | چک کردن Reserve → Confirm flow | ⬜ | | 7.6 | Manual Test: لغو سفارش | چک کردن Release | ⬜ | | 7.7 | Manual Test: Sync | چک کردن sync به `RemainingCount` | ⬜ | **خروجی Phase 7:** - ✅ پوشش تست > 80% - ✅ همه سناریوها تست شده --- ## 📊 خلاصه فازبندی | Phase | عنوان | مدت | تسک‌ها | وابستگی | |-------|-------|-----|--------|---------| | 1 | زیرساخت Domain | 2 روز | 11 | - | | 2 | سرویس Inventory | 2 روز | 14 | Phase 1 | | 3 | CQRS Handlers | 2 روز | 12 | Phase 2 | | 4 | یکپارچه‌سازی | 1.5 روز | 11 | Phase 2 | | 5 | Migration داده | 0.5 روز | 5 | Phase 1, 2 | | 6 | Proto و gRPC | 1 روز | 5 | Phase 3 | | 7 | تست و QA | 1 روز | 7 | همه | **مجموع:** ~10 روز کاری | **65 تسک** --- ## 🚀 ترتیب اجرا (Critical Path) ``` Day 1-2: Phase 1 (زیرساخت) │ Day 3-4: Phase 2 (سرویس) ─────────┐ │ │ Day 5-6: Phase 3 (Handlers) │ │ │ Day 7: Phase 4 (یکپارچه‌سازی) ◄──┘ │ Day 8: Phase 5 (Migration) + Phase 6 شروع │ Day 9: Phase 6 (Proto) ادامه │ Day 10: Phase 7 (تست) ``` --- ## 🔄 Backward Compatibility و رابطه با `RemainingCount` ### ❓ سوال کلیدی: تکلیف `RemainingCount` فعلی چیست؟ الان در جداول `Product` و `DiscountProduct` فیلد `RemainingCount` داریم که مستقیم موجودی رو نگه می‌داره. سه گزینه داریم: | گزینه | توضیح | مزایا | معایب | |-------|-------|-------|-------| | **A: حذف کامل** | `RemainingCount` از Product حذف شود | Clean architecture | همه کدها باید تغییر کنند! | | **B: Cache (پیشنهادی) ✅** | `RemainingCount` حفظ شود و sync شود | Backward compatible | داده duplicate | | **C: Computed** | `RemainingCount` به property محاسباتی تبدیل شود | بدون duplicate | نیاز به Join در همه Query ها | ### گزینه انتخاب شده: B - حفظ `RemainingCount` به عنوان Cache ``` Product InventoryItem ├── Title ├── ProductId → Product.Id ├── Price ├── Quantity = 50 ← منبع اصلی (Source of Truth) └── RemainingCount = 50 (sync) └── ReservedQuantity = 5 ↑ همیشه sync می‌شود ``` **قانون طلایی:** > ⚠️ **هیچ‌وقت** مستقیم `RemainingCount` را آپدیت نکنید. فقط از `InventoryService` استفاده کنید که خودش sync می‌کند. ```csharp // در InventoryService - بعد از هر تغییر موجودی: private async Task SyncRemainingCountAsync(InventoryItem item, CancellationToken ct) { if (item.ProductType == ProductType.RegularProduct) { var product = await _context.Products.FindAsync(item.ProductId); if (product != null) product.RemainingCount = item.Quantity; } else if (item.ProductType == ProductType.DiscountProduct) { var product = await _context.DiscountProducts.FindAsync(item.DiscountProductId); if (product != null) product.RemainingCount = item.Quantity; } } ``` ### مزایا: - ✅ کدهای موجود بدون تغییر کار می‌کنند - ✅ Proto ها نیاز به تغییر ندارند - ✅ Admin UI و FrontOffice بدون تغییر - ✅ Query های سریع روی Product (بدون Join) - ✅ Migration تدریجی امکان‌پذیر است ### معایب: - ⚠️ داده duplicate (ولی sync می‌شود) - ⚠️ کمی overhead در هر عملیات --- ## 📌 اصل ایجاد موجودی - Product First Principle ### قانون اساسی: > ⚠️ **InventoryItem وابسته به Product است.** محصول باید ابتدا در جدول `Product` یا `DiscountProduct` ایجاد شود، سپس رکورد `InventoryItem` متصل به آن ایجاد می‌شود. ``` ایجاد Product (Id = 1) → ایجاد InventoryItem (ProductId = 1) → StockMovement(InitialStock) ↑ ↑ ↑ Step 1 Step 2 Step 3 ``` ### ❓ سوال: برای ورود کالا به انبار، اول باید محصول ثبت شده باشد؟ **بله، حتماً!** چون `InventoryItem.ProductId` یک Foreign Key به جدول `Product` است. ### دو رویکرد پیاده‌سازی: | رویکرد | توضیح | مزایا | معایب | |--------|-------|-------|-------| | **A: خودکار (پیشنهادی) ✅** | موقع CreateProduct خودکار InventoryItem ساخته می‌شود | ساده، یکپارچه، بدون ریسک | - | | **B: جداگانه** | اول Product با InitialCount=0، بعد جداگانه AddStock | انعطاف بیشتر | پیچیدگی UI، ریسک محصول بدون موجودی | --- ### رویکرد A: خودکار (پیشنهادی ✅) در `CreateProductCommandHandler` بلافاصله بعد از ایجاد Product، رکورد `InventoryItem` هم ساخته می‌شود: ```csharp // CreateProductCommandHandler: var product = new Product { Title = ..., Price = ... }; await _context.Products.AddAsync(product); await _context.SaveChangesAsync(); // ✅ خودکار InventoryItem ایجاد می‌شود await _inventoryService.InitializeInventoryAsync( product.Id, ProductType.RegularProduct, request.InitialCount, // می‌تواند 0 باشد ct); ``` **نتیجه:** - `Product` ایجاد می‌شود - `InventoryItem` با `Quantity = InitialCount` ایجاد می‌شود - `StockMovement(InitialStock)` ثبت می‌شود - `Product.RemainingCount` sync می‌شود --- ### رویکرد B: جداگانه (اختیاری برای آینده) ```csharp // Step 1: ایجاد محصول با موجودی 0 var product = new Product { Title = ..., RemainingCount = 0 }; // InventoryItem با Quantity = 0 ایجاد می‌شود // Step 2: بعداً از UI انبار، ورود کالا await _inventoryService.AddStockAsync( productId, ProductType.RegularProduct, 100, // مقدار ورودی "INV-001", // شماره فاکتور "ورود اولیه از تامین‌کننده", ct); ``` **کاربرد:** وقتی می‌خواهید محصول را ثبت کنید ولی کالا هنوز به انبار نرسیده. --- ### 📊 مکانیزم همگام‌سازی (Sync) هر وقت موجودی در `InventoryItem` تغییر کند، **خودکار** به `Product.RemainingCount` sync می‌شود: | عملیات | تغییر در InventoryItem | Sync به Product | |--------|----------------------|-----------------| | **ورود کالا (Restock)** | `Quantity += X` | `RemainingCount = Quantity` | | **فروش (Sale)** | `Quantity -= X` | `RemainingCount = Quantity` | | **تعدیل (Adjust)** | `Quantity = NewValue` | `RemainingCount = NewValue` | | **برگشت (Return)** | `Quantity += X` | `RemainingCount = Quantity` | | **رزرو (Reserve)** | فقط `ReservedQuantity` تغییر | ❌ sync نمی‌شود | > 💡 **نکته:** رزرو فقط `ReservedQuantity` را تغییر می‌دهد و `Quantity` ثابت می‌ماند. بنابراین `RemainingCount` تغییر نمی‌کند تا موقع پرداخت. --- ## 🔄 سناریوهای تغییر موجودی - قبل و بعد ### 1️⃣ ایجاد محصول جدید | مرحله | قبل (فعلی) | بعد (با Inventory) | |-------|-----------|-------------------| | **Admin UI** | `InitialCount` وارد می‌کنه | ❌ بدون تغییر | | **Proto** | `initial_count` در Request | ❌ بدون تغییر | | **Handler** | `Product.RemainingCount = initial_count` | ✅ `InventoryService.InitializeInventoryAsync()` | | **نتیجه** | فقط Product آپدیت | Product + InventoryItem + StockMovement(InitialStock) | ```csharp // CreateProductCommandHandler - بعد از تغییر: var product = new Product { Title = request.Title, Price = request.Price, ... }; await _context.Products.AddAsync(product, cancellationToken); await _context.SaveChangesAsync(cancellationToken); // ایجاد رکورد انبار + sync به RemainingCount await _inventoryService.InitializeInventoryAsync( product.Id, ProductType.RegularProduct, request.InitialCount, cancellationToken); ``` --- ### 2️⃣ ویرایش محصول (تغییر موجودی دستی توسط ادمین) | مرحله | قبل (فعلی) | بعد (با Inventory) | |-------|-----------|-------------------| | **Admin UI** | `RemainingCount` ویرایش می‌کنه | ❌ بدون تغییر | | **Proto** | `remaining_count` در Request | ❌ بدون تغییر | | **Handler** | `Product.RemainingCount = new_value` | ✅ `InventoryService.AdjustStockAsync()` | | **نتیجه** | فقط Product آپدیت | InventoryItem + StockMovement(Adjustment) + Product sync | ```csharp // UpdateProductCommandHandler - بعد از تغییر: var product = await _context.Products.FindAsync(request.Id, cancellationToken); // اگر موجودی تغییر کرده: if (request.RemainingCount.HasValue && request.RemainingCount != product.RemainingCount) { await _inventoryService.AdjustStockAsync( product.Id, ProductType.RegularProduct, request.RemainingCount.Value, "تعدیل دستی توسط ادمین", cancellationToken); // این متد خودش Product.RemainingCount رو هم sync می‌کنه } // بقیه فیلدها (بدون تغییر) product.Title = request.Title; product.Price = request.Price; // ... ``` --- ### 3️⃣ خرید مشتری (سفارش از FrontOffice) | مرحله | قبل (فعلی) | بعد (با Inventory) | |-------|-----------|-------------------| | **FrontOffice UI** | سفارش ثبت می‌شه | ❌ بدون تغییر | | **PlaceOrder** | هیچ کاری با موجودی نمی‌کنه! 😱 | ✅ `ReserveStockAsync()` | | **CompletePayment** | `Product.RemainingCount -= qty` | ✅ `ConfirmSaleAsync()` | | **CancelOrder** | هیچ کاری نمی‌کنه | ✅ `ReleaseReservationAsync()` | ```csharp // PlaceOrderCommandHandler - بعد از تغییر: foreach (var item in cartItems) { // رزرو موجودی (سفارش pending) var reserved = await _inventoryService.ReserveStockAsync( item.ProductId, ProductType.RegularProduct, item.Quantity, order.Id, cancellationToken); if (!reserved) throw new InsufficientStockException($"موجودی کافی برای محصول {item.ProductId} وجود ندارد"); } // CompleteOrderPaymentCommandHandler - بعد از تغییر: foreach (var item in order.OrderDetails) { // تبدیل رزرو به فروش واقعی + کسر از موجودی await _inventoryService.ConfirmSaleAsync( item.ProductId, ProductType.RegularProduct, item.Quantity, order.Id, cancellationToken); } // CancelOrderCommandHandler - بعد از تغییر: foreach (var item in order.OrderDetails) { // آزادسازی رزرو await _inventoryService.ReleaseReservationAsync( item.ProductId, ProductType.RegularProduct, item.Quantity, order.Id, cancellationToken); } ``` --- ### 4️⃣ Bulk Update موجودی (آپدیت دسته‌جمعی) | مرحله | قبل (فعلی) | بعد (با Inventory) | |-------|-----------|-------------------| | **Admin UI** | لیست محصولات + مقدار جدید | ❌ بدون تغییر | | **Handler** | مستقیم `RemainingCount` آپدیت | ✅ `BulkAdjustStockAsync()` | | **نتیجه** | فقط Products آپدیت | برای هر محصول: StockMovement ثبت + sync | --- ### 5️⃣ برگشت کالا (Return) - قابلیت جدید | مرحله | قبل (فعلی) | بعد (با Inventory) | |-------|-----------|-------------------| | **وضعیت** | پیاده‌سازی نشده ❌ | ✅ `ProcessReturnAsync()` | ```csharp // ProcessReturnCommandHandler - جدید: await _inventoryService.ProcessReturnAsync( request.ProductId, ProductType.RegularProduct, request.Quantity, request.OrderId, request.Note, cancellationToken); ``` --- ## 📋 خلاصه تغییرات لازم در Handlers ### ❌ نیاز به تغییر ندارند: - Admin UI (فرم‌های ایجاد/ویرایش محصول) - Proto definitions (همه فیلدها حفظ می‌شوند) - FrontOffice UI (صفحات خرید) - API signatures ### ✅ نیاز به تغییر دارند: | Handler | نوع تغییر | توضیح | |---------|----------|-------| | `CreateProductCommandHandler` | اضافه | `InitializeInventoryAsync()` | | `CreateDiscountProductCommandHandler` | اضافه | `InitializeInventoryAsync()` | | `UpdateProductCommandHandler` | اضافه | `AdjustStockAsync()` (اگر موجودی تغییر کرد) | | `UpdateDiscountProductCommandHandler` | اضافه | `AdjustStockAsync()` | | `BulkUpdateProductStockCommandHandler` | تغییر | استفاده از `BulkAdjustStockAsync()` | | `PlaceOrderCommandHandler` | اضافه | `ReserveStockAsync()` | | `PlaceDiscountOrderCommandHandler` | اضافه | `ReserveStockAsync()` | | `CompleteOrderPaymentCommandHandler` | تغییر | از `ConfirmSaleAsync()` به جای direct update | | `CompleteDiscountOrderPaymentCommandHandler` | تغییر | از `ConfirmSaleAsync()` | | `CancelOrderCommandHandler` | اضافه | `ReleaseReservationAsync()` | | `CancelDiscountOrderCommandHandler` | اضافه | `ReleaseReservationAsync()` | --- ## 🔧 پیاده‌سازی InventoryService ```csharp public class InventoryService : IInventoryService { private readonly IApplicationDbContext _context; public InventoryService(IApplicationDbContext context) { _context = context; } /// /// ایجاد موجودی اولیه برای محصول جدید /// public async Task InitializeInventoryAsync( long productId, ProductType type, int quantity, CancellationToken ct) { var item = new InventoryItem { ProductId = type == ProductType.RegularProduct ? productId : null, DiscountProductId = type == ProductType.DiscountProduct ? productId : null, ProductType = type, Quantity = quantity, ReservedQuantity = 0, LastRestockedAt = DateTime.UtcNow }; await _context.InventoryItems.AddAsync(item, ct); // لاگ حرکت انبار await LogMovementAsync(item, StockMovementType.InitialStock, quantity, null, "موجودی اولیه", ct); // Sync به Product.RemainingCount await SyncRemainingCountAsync(item, ct); await _context.SaveChangesAsync(ct); } /// /// رزرو موجودی برای سفارش pending /// public async Task ReserveStockAsync( long productId, ProductType type, int quantity, long? orderId, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null || item.AvailableQuantity < quantity) return false; item.ReservedQuantity += quantity; await LogMovementAsync(item, StockMovementType.Reserved, quantity, orderId, null, ct); await _context.SaveChangesAsync(ct); return true; } /// /// آزادسازی رزرو (لغو سفارش) /// public async Task ReleaseReservationAsync( long productId, ProductType type, int quantity, long? orderId, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null) return; item.ReservedQuantity = Math.Max(0, item.ReservedQuantity - quantity); await LogMovementAsync(item, StockMovementType.Released, quantity, orderId, "آزادسازی رزرو", ct); await _context.SaveChangesAsync(ct); } /// /// تایید فروش و کسر از موجودی (بعد از پرداخت موفق) /// public async Task ConfirmSaleAsync( long productId, ProductType type, int quantity, long? orderId, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null) throw new InventoryNotFoundException(productId, type); var quantityBefore = item.Quantity; item.Quantity -= quantity; item.ReservedQuantity = Math.Max(0, item.ReservedQuantity - quantity); item.LastSoldAt = DateTime.UtcNow; await LogMovementAsync(item, StockMovementType.Sale, -quantity, orderId, null, ct); await SyncRemainingCountAsync(item, ct); await _context.SaveChangesAsync(ct); } /// /// تعدیل موجودی (ویرایش دستی توسط ادمین) /// public async Task AdjustStockAsync( long productId, ProductType type, int newQuantity, string? note, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null) { // اگر InventoryItem نداره، ایجادش کن await InitializeInventoryAsync(productId, type, newQuantity, ct); return; } var difference = newQuantity - item.Quantity; var movementType = difference >= 0 ? StockMovementType.AdjustmentPlus : StockMovementType.AdjustmentMinus; item.Quantity = newQuantity; await LogMovementAsync(item, movementType, difference, null, note, ct); await SyncRemainingCountAsync(item, ct); await _context.SaveChangesAsync(ct); } /// /// ورود کالا به انبار /// public async Task AddStockAsync( long productId, ProductType type, int quantity, string? referenceNumber, string? note, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null) { await InitializeInventoryAsync(productId, type, quantity, ct); return; } item.Quantity += quantity; item.LastRestockedAt = DateTime.UtcNow; await LogMovementAsync(item, StockMovementType.Restock, quantity, null, note, ct, referenceNumber); await SyncRemainingCountAsync(item, ct); await _context.SaveChangesAsync(ct); } /// /// برگشت کالا از مشتری /// public async Task ProcessReturnAsync( long productId, ProductType type, int quantity, long? orderId, string? note, CancellationToken ct) { var item = await GetInventoryItemAsync(productId, type, ct); if (item == null) throw new InventoryNotFoundException(productId, type); item.Quantity += quantity; await LogMovementAsync(item, StockMovementType.Return, quantity, orderId, note, ct); await SyncRemainingCountAsync(item, ct); await _context.SaveChangesAsync(ct); } // ============ Private Helpers ============ private async Task GetInventoryItemAsync(long productId, ProductType type, CancellationToken ct) { return type == ProductType.RegularProduct ? await _context.InventoryItems.FirstOrDefaultAsync(x => x.ProductId == productId, ct) : await _context.InventoryItems.FirstOrDefaultAsync(x => x.DiscountProductId == productId, ct); } private async Task SyncRemainingCountAsync(InventoryItem item, CancellationToken ct) { if (item.ProductType == ProductType.RegularProduct && item.ProductId.HasValue) { var product = await _context.Products.FindAsync(new object[] { item.ProductId.Value }, ct); if (product != null) product.RemainingCount = item.Quantity; } else if (item.ProductType == ProductType.DiscountProduct && item.DiscountProductId.HasValue) { var product = await _context.DiscountProducts.FindAsync(new object[] { item.DiscountProductId.Value }, ct); if (product != null) product.RemainingCount = item.Quantity; } } private async Task LogMovementAsync( InventoryItem item, StockMovementType type, int quantity, long? orderId, string? note, CancellationToken ct, string? referenceNumber = null) { var movement = new StockMovement { InventoryItemId = item.Id, MovementType = type, Quantity = quantity, QuantityBefore = item.Quantity - quantity, QuantityAfter = item.Quantity, OrderId = item.ProductType == ProductType.RegularProduct ? orderId : null, DiscountOrderId = item.ProductType == ProductType.DiscountProduct ? orderId : null, ReferenceNumber = referenceNumber, Note = note }; await _context.StockMovements.AddAsync(movement, ct); } } ``` --- ## 📊 مدل داده - ERD ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ Warehouse │ │ InventoryItem │ │ StockMovement │ ├─────────────────┤ ├──────────────────┤ ├─────────────────┤ │ Id │──────<│ WarehouseId │ │ Id │ │ Name │ │ Id │──────<│ InventoryItemId │ │ Code │ │ ProductId? │ │ MovementType │ │ IsDefault │ │ DiscountProductId│ │ Quantity │ │ IsActive │ │ ProductType │ │ QuantityBefore │ └─────────────────┘ │ Quantity │ │ QuantityAfter │ │ ReservedQuantity │ │ OrderId? │ │ LowStockThreshold│ │ ReferenceNumber │ ┌─────────────────┐ │ LastRestockedAt │ │ Note │ │ Product │──────>│ LastSoldAt │ │ PerformedByUserId│ ├─────────────────┤ └──────────────────┘ │ CreatedAt │ │ Id │ └─────────────────┘ │ RemainingCount │ (sync) └─────────────────┘ ┌─────────────────┐ │ DiscountProduct │──────> ├─────────────────┤ │ Id │ │ RemainingCount │ (sync) └─────────────────┘ ``` --- ## 🔔 هشدارها و نوتیفیکیشن ### Low Stock Alert (اختیاری - Phase آینده) ```csharp public class LowStockNotificationService { public async Task CheckAndNotifyAsync() { var lowStockItems = await _inventoryService.GetLowStockItemsAsync(); foreach (var item in lowStockItems) { // ارسال نوتیفیکیشن به ادمین // Email / SMS / Dashboard Alert } } } ``` --- ## 📈 گزارشات پیشنهادی 1. **گزارش موجودی فعلی** - لیست همه محصولات با موجودی 2. **گزارش کم‌موجودی** - محصولات زیر آستانه 3. **گزارش حرکات انبار** - ورودی/خروجی در بازه زمانی 4. **گزارش انبارگردانی** - مقایسه موجودی سیستم با فیزیکی 5. **گزارش ضایعات** - کالاهای خراب/مفقود شده 6. **گزارش گردش کالا** - پرفروش‌ترین / کم‌فروش‌ترین --- ## ⚠️ نکات مهم 1. **Transaction Safety:** همه عملیات موجودی باید در Transaction باشند 2. **Concurrency:** استفاده از RowVersion برای جلوگیری از race condition 3. **Audit Trail:** همه تغییرات با جزئیات کامل لاگ شوند 4. **Performance:** Index روی ProductId و ProductType --- ## 🔗 فایل‌های مرتبط - [BACKLOG.md](../05-TASKS/BACKLOG.md) - لیست کارها - [CMS Domain Entities](../../CMS/src/CMSMicroservice.Domain/Entities/) - [CMS Application](../../CMS/src/CMSMicroservice.Application/) --- **نویسنده:** GitHub Copilot **تاریخ آخرین به‌روزرسانی:** 1 January 2026