Files
docs/03-BACKEND/INVENTORY-SYSTEM-PLAN.md
T

1204 lines
49 KiB
Markdown

# 📦 سیستم انبارداری یکپارچه (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<StockMovement> StockMovements { get; set; } = new List<StockMovement>();
}
```
#### 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<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
}
```
### 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<InventoryItem?> GetInventoryAsync(long productId, ProductType type, CancellationToken ct = default);
Task<int> GetAvailableQuantityAsync(long productId, ProductType type, CancellationToken ct = default);
// رزرو (برای سفارش pending)
Task<bool> ReserveStockAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default);
Task<bool> ReleaseReservationAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default);
// کسر موجودی (پرداخت موفق)
Task<bool> ConfirmSaleAsync(long productId, ProductType type, int quantity, long? orderId, CancellationToken ct = default);
// برگشت
Task<bool> ProcessReturnAsync(long productId, ProductType type, int quantity, long? orderId, string? note, CancellationToken ct = default);
// ورود کالا
Task<bool> AddStockAsync(long productId, ProductType type, int quantity, string? referenceNumber, string? note, CancellationToken ct = default);
// تعدیل
Task<bool> AdjustStockAsync(long productId, ProductType type, int newQuantity, string? note, CancellationToken ct = default);
// گزارش
Task<List<InventoryItem>> GetLowStockItemsAsync(ProductType? type = null, CancellationToken ct = default);
Task<List<StockMovement>> 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;
}
/// <summary>
/// ایجاد موجودی اولیه برای محصول جدید
/// </summary>
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);
}
/// <summary>
/// رزرو موجودی برای سفارش pending
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// آزادسازی رزرو (لغو سفارش)
/// </summary>
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);
}
/// <summary>
/// تایید فروش و کسر از موجودی (بعد از پرداخت موفق)
/// </summary>
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);
}
/// <summary>
/// تعدیل موجودی (ویرایش دستی توسط ادمین)
/// </summary>
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);
}
/// <summary>
/// ورود کالا به انبار
/// </summary>
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);
}
/// <summary>
/// برگشت کالا از مشتری
/// </summary>
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<InventoryItem?> 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