Files
docs/03-BACKEND/INVENTORY-SYSTEM-PLAN.md
T
masoodafar-web 73e1971cc3 feat: Implement Discount Shop Completion Plan with Product Image Gallery, Admin APIs, VAT Calculation, and Sales Reports
- Added DiscountProductImage entity and related configurations for product image gallery.
- Created commands and queries for managing product images.
- Developed GetAllDiscountOrders API for admin order management with various filters.
- Implemented VAT calculation service and integrated it into order processing.
- Created Sales Reports API with support for daily, weekly, and monthly reports.
- Completed gRPC services for BackOffice.BFF to expose new APIs.
- Updated Proto files and project references accordingly.
2026-01-02 00:46:08 +03:30

44 KiB

📦 سیستم انبارداری یکپارچه (Unified Inventory Management)

تاریخ ایجاد: ۱۲ دی ۱۴۰۴ (1 January 2026)
وضعیت: 📋 Planning
اولویت: 🟡 Medium


🎯 هدف

ایجاد یک سیستم انبارداری مرکزی که موجودی هر دو فروشگاه (معمولی و تخفیفی) را از یک نقطه مدیریت کند، با قابلیت:

  • ردیابی حرکات موجودی (ورود، خروج، تعدیل)
  • رزرو موقت برای سفارشات pending
  • هشدار کمبود موجودی
  • گزارش‌گیری جامع

📊 وضعیت فعلی

ساختار موجود

جنبه فروشگاه معمولی (Product) فروشگاه تخفیفی (DiscountProduct)
فیلد موجودی RemainingCount RemainingCount
موجودی اولیه remaining_count در Create initial_count در Create
آپدیت دسته‌جمعی بله (SET/ADD/SUBTRACT) خیر
کوئری کم‌موجودی بله خیر
چک موجودی در سبد بله بله
کسر موجودی در سفارش بله بله

مشکلات فعلی

  1. عدم ردیابی حرکات: هیچ لاگی از تغییرات موجودی نگهداری نمی‌شود
  2. عدم رزرو موقت: موجودی در زمان پرداخت کسر می‌شود، نه در زمان افزودن به سبد
  3. عدم یکپارچگی: هر فروشگاه مستقل مدیریت می‌شود
  4. عدم پشتیبانی چند انبار: برای آینده محدودیت دارد

🏗️ معماری پیشنهادی

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.RemainingCount sync می‌شود

رویکرد 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
        }
    }
}

📈 گزارشات پیشنهادی

  1. گزارش موجودی فعلی - لیست همه محصولات با موجودی
  2. گزارش کم‌موجودی - محصولات زیر آستانه
  3. گزارش حرکات انبار - ورودی/خروجی در بازه زمانی
  4. گزارش انبارگردانی - مقایسه موجودی سیستم با فیزیکی
  5. گزارش ضایعات - کالاهای خراب/مفقود شده
  6. گزارش گردش کالا - پرفروش‌ترین / کم‌فروش‌ترین

⚠️ نکات مهم

  1. Transaction Safety: همه عملیات موجودی باید در Transaction باشند
  2. Concurrency: استفاده از RowVersion برای جلوگیری از race condition
  3. Audit Trail: همه تغییرات با جزئیات کامل لاگ شوند
  4. Performance: Index روی ProductId و ProductType

🔗 فایل‌های مرتبط


نویسنده: GitHub Copilot
تاریخ آخرین به‌روزرسانی: 1 January 2026