1204 lines
49 KiB
Markdown
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
|