49 KiB
📦 سیستم انبارداری یکپارچه (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.csInfrastructure/Persistence/Configurations/StockMovementConfiguration.csInfrastructure/Persistence/Configurations/WarehouseConfiguration.csInfrastructure/Persistence/Repositories/InventoryItemRepository.csInfrastructure/Persistence/Repositories/StockMovementRepository.csInfrastructure/Services/InventoryService.cs- سرویس اصلی (663 خط)Infrastructure/Persistence/Migrations/20251231234634_AddInventorySystem.cs
Application Layer
Application/Common/Interfaces/IInventoryService.cs- (217 خط)Application/Common/Interfaces/Repositories/IInventoryItemRepository.csApplication/Common/Interfaces/Repositories/IStockMovementRepository.csApplication/Features/InventoryItems/Commands/InventoryItemCommands.csApplication/Features/InventoryItems/Queries/InventoryItemQueries.csApplication/Features/InventoryItems/Handlers/InventoryItemCommandHandlers.csApplication/Features/InventoryItems/Handlers/InventoryItemQueryHandlers.csApplication/Features/Warehouses/Commands/WarehouseCommands.csApplication/Features/Warehouses/Queries/WarehouseQueries.csApplication/Features/Warehouses/Handlers/WarehouseCommandHandlers.csApplication/Features/Warehouses/Handlers/WarehouseQueryHandlers.csApplication/Features/StockMovements/Commands/StockMovementCommands.csApplication/Features/StockMovements/Queries/StockMovementQueries.csApplication/Features/StockMovements/Handlers/StockMovementCommandHandlers.csApplication/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) | ❌ خیر |
| کوئری کمموجودی | ✅ بله | ❌ خیر |
| چک موجودی در سبد | ✅ بله | ✅ بله |
| کسر موجودی در سفارش | ✅ بله | ✅ بله |
مشکلات فعلی
- عدم ردیابی حرکات: هیچ لاگی از تغییرات موجودی نگهداری نمیشود
- عدم رزرو موقت: موجودی در زمان پرداخت کسر میشود، نه در زمان افزودن به سبد
- عدم یکپارچگی: هر فروشگاه مستقل مدیریت میشود
- عدم پشتیبانی چند انبار: برای آینده محدودیت دارد
🏗️ معماری پیشنهادی
Entity های جدید
1. InventoryItem (موجودی کالا)
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 (حرکات انبار)
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 (انبار) - اختیاری برای آینده
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 های جدید
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
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) |
⬜ |
-- 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 |
✅ |
// 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 میکند.
// در 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 هم ساخته میشود:
// 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.RemainingCountsync میشود
رویکرد B: جداگانه (اختیاری برای آینده)
// 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) |
// 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 |
// 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() |
// 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() |
// 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
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 آینده)
public class LowStockNotificationService
{
public async Task CheckAndNotifyAsync()
{
var lowStockItems = await _inventoryService.GetLowStockItemsAsync();
foreach (var item in lowStockItems)
{
// ارسال نوتیفیکیشن به ادمین
// Email / SMS / Dashboard Alert
}
}
}
📈 گزارشات پیشنهادی
- گزارش موجودی فعلی - لیست همه محصولات با موجودی
- گزارش کمموجودی - محصولات زیر آستانه
- گزارش حرکات انبار - ورودی/خروجی در بازه زمانی
- گزارش انبارگردانی - مقایسه موجودی سیستم با فیزیکی
- گزارش ضایعات - کالاهای خراب/مفقود شده
- گزارش گردش کالا - پرفروشترین / کمفروشترین
⚠️ نکات مهم
- Transaction Safety: همه عملیات موجودی باید در Transaction باشند
- Concurrency: استفاده از RowVersion برای جلوگیری از race condition
- Audit Trail: همه تغییرات با جزئیات کامل لاگ شوند
- Performance: Index روی ProductId و ProductType
🔗 فایلهای مرتبط
- BACKLOG.md - لیست کارها
- CMS Domain Entities
- CMS Application
نویسنده: GitHub Copilot
تاریخ آخرین بهروزرسانی: 1 January 2026