name: inventory-management
description: >
نظام إدارة المخزون والمواد الاستهلاكية (مختلف عن المستودعات والأصول الثابتة).
استخدم هذا الـ skill عند: إدارة مخزون المواد الاستهلاكية، الأدوات المكتبية،
قطع الغيار، المواد الطبية، إدارة الحد الأدنى والأعلى، التنبؤ بالطلب،
إدارة الموردين، أوامر التوريد التلقائية، تتبع الصلاحية، أو FIFO/LIFO.
Inventory Management System
إدارة المخزون
Domain Model
// === المنتج/الصنف ===
public class InventoryItem : BaseAuditableEntity
{
public int Id { get; set; }
public string ItemCode { get; set; } = string.Empty; // INV-MAT-001
public string NameAr { get; set; } = string.Empty;
public string? NameEn { get; set; }
public string? Description { get; set; }
public string? Barcode { get; set; }
public int CategoryId { get; set; }
public string UnitOfMeasure { get; set; } = string.Empty; // قطعة، كرتون، لتر
// Stock Levels
public decimal CurrentStock { get; set; }
public decimal MinimumStock { get; set; } // الحد الأدنى
public decimal MaximumStock { get; set; } // الحد الأعلى
public decimal ReorderPoint { get; set; } // نقطة إعادة الطلب
public decimal ReorderQuantity { get; set; } // كمية إعادة الطلب
// Costing
public CostingMethod CostingMethod { get; set; }
public decimal AverageCost { get; set; }
public decimal LastPurchasePrice { get; set; }
// Tracking
public bool TrackExpiry { get; set; } // تتبع الصلاحية
public bool TrackBatch { get; set; } // تتبع الدُفعات
public bool TrackSerialNumber { get; set; }
// Status
public bool IsActive { get; set; } = true;
public int? PreferredSupplierId { get; set; }
public int LeadTimeDays { get; set; } // وقت التوريد
public List<StockBatch> Batches { get; set; } = new();
public List<StockMovement> Movements { get; set; } = new();
}
public enum CostingMethod
{
WeightedAverage, // المتوسط المرجح
FIFO, // الوارد أولاً صادر أولاً
LIFO, // الوارد أخيراً صادر أولاً
SpecificCost // تكلفة محددة
}
// === الدُفعات ===
public class StockBatch
{
public int Id { get; set; }
public int ItemId { get; set; }
public string BatchNumber { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitCost { get; set; }
public DateTime ReceivedDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public int WarehouseId { get; set; }
public string? LocationBin { get; set; } // رقم الحاوية/الرف
public BatchStatus Status { get; set; }
}
// === حركات المخزون ===
public class StockMovement : BaseAuditableEntity
{
public int Id { get; set; }
public string MovementNumber { get; set; } = string.Empty;
public int ItemId { get; set; }
public MovementType Type { get; set; }
public MovementDirection Direction { get; set; }
public decimal Quantity { get; set; }
public decimal UnitCost { get; set; }
public decimal TotalCost => Quantity * UnitCost;
public int? BatchId { get; set; }
public int WarehouseId { get; set; }
public int? FromWarehouseId { get; set; }
public int? ToWarehouseId { get; set; }
// Reference
public string? ReferenceType { get; set; } // PO, SO, Transfer
public int? ReferenceId { get; set; }
public int? RequestedById { get; set; }
public int? ApprovedById { get; set; }
public string? Reason { get; set; }
public DateTime MovementDate { get; set; }
// Running totals
public decimal StockBefore { get; set; }
public decimal StockAfter { get; set; }
}
public enum MovementType
{
Receipt, // استلام من مورد
Issue, // صرف
Transfer, // تحويل بين مستودعات
Return, // إرجاع
Adjustment, // تسوية
Disposal, // إتلاف
Consumption, // استهلاك
Production // إنتاج
}
// === طلب صرف مواد ===
public class MaterialRequest : BaseAuditableEntity
{
public int Id { get; set; }
public string RequestNumber { get; set; } = string.Empty;
public int RequestorId { get; set; }
public int DepartmentId { get; set; }
public string PurposeAr { get; set; } = string.Empty;
public MaterialRequestStatus Status { get; set; }
public int? ApprovalWorkflowId { get; set; }
public List<MaterialRequestItem> Items { get; set; } = new();
}
Auto-Reorder Service
public class AutoReorderService
{
// Run daily - check items below reorder point
public async Task CheckAndCreateReordersAsync()
{
var lowStockItems = await _context.InventoryItems
.Where(i => i.IsActive && i.CurrentStock <= i.ReorderPoint)
.Include(i => i.Batches.Where(b => b.Status == BatchStatus.Active))
.ToListAsync();
foreach (var item in lowStockItems)
{
// Check if there's already a pending PO
var hasPendingOrder = await _context.PurchaseOrderItems
.AnyAsync(po => po.ItemId == item.Id &&
po.PurchaseOrder.Status == PurchaseOrderStatus.Pending);
if (hasPendingOrder) continue;
// Calculate order quantity
var orderQty = Math.Max(item.ReorderQuantity, item.MaximumStock - item.CurrentStock);
// Check expiring batches
var expiringBatches = item.Batches
.Where(b => b.ExpiryDate.HasValue &&
b.ExpiryDate.Value <= DateTime.UtcNow.AddDays(30))
.ToList();
await _purchaseService.CreateAutoReorderAsync(new AutoReorderRequest
{
ItemId = item.Id,
Quantity = orderQty,
SupplierId = item.PreferredSupplierId,
Priority = item.CurrentStock <= item.MinimumStock
? OrderPriority.Urgent : OrderPriority.Normal,
ExpiringBatchCount = expiringBatches.Count
});
await _notificationService.SendAsync(new()
{
EventType = "inventory.low_stock",
RecipientRole = "WarehouseManager",
Data = new { item.NameAr, item.CurrentStock, item.MinimumStock }
});
}
}
}
Demand Forecasting
public class DemandForecastService
{
// Simple moving average forecast
public async Task<ForecastResult> ForecastDemandAsync(int itemId, int monthsAhead = 3)
{
var history = await _context.StockMovements
.Where(m => m.ItemId == itemId && m.Direction == MovementDirection.Out)
.Where(m => m.MovementDate >= DateTime.UtcNow.AddMonths(-12))
.GroupBy(m => new { m.MovementDate.Year, m.MovementDate.Month })
.Select(g => new { Period = g.Key, TotalQty = g.Sum(m => m.Quantity) })
.OrderBy(g => g.Period.Year).ThenBy(g => g.Period.Month)
.ToListAsync();
if (history.Count < 3)
return new ForecastResult { Confidence = "Low", Method = "Insufficient data" };
// 3-month weighted moving average
var weights = new[] { 0.5m, 0.3m, 0.2m };
var recent = history.TakeLast(3).Select(h => h.TotalQty).ToArray();
var forecast = recent.Zip(weights, (qty, w) => qty * w).Sum();
return new ForecastResult
{
ItemId = itemId,
ForecastedMonthlyDemand = forecast,
RecommendedStock = forecast * monthsAhead,
Confidence = history.Count >= 6 ? "High" : "Medium",
Method = "Weighted Moving Average"
};
}
}
SQL Schema
CREATE SCHEMA [Inventory];
CREATE TABLE [Inventory].[Items] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[ItemCode] NVARCHAR(50) NOT NULL UNIQUE,
[NameAr] NVARCHAR(300) NOT NULL,
[Barcode] NVARCHAR(50) NULL,
[CategoryId] INT NOT NULL,
[UnitOfMeasure] NVARCHAR(20) NOT NULL,
[CurrentStock] DECIMAL(18,3) NOT NULL DEFAULT 0,
[MinimumStock] DECIMAL(18,3) NOT NULL DEFAULT 0,
[MaximumStock] DECIMAL(18,3) NOT NULL DEFAULT 0,
[ReorderPoint] DECIMAL(18,3) NOT NULL DEFAULT 0,
[CostingMethod] NVARCHAR(20) NOT NULL DEFAULT 'WeightedAverage',
[AverageCost] DECIMAL(18,4) NOT NULL DEFAULT 0,
[TrackExpiry] BIT NOT NULL DEFAULT 0,
[TrackBatch] BIT NOT NULL DEFAULT 0,
[IsActive] BIT NOT NULL DEFAULT 1,
[PreferredSupplierId] INT NULL
);
CREATE TABLE [Inventory].[StockBatches] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[ItemId] INT NOT NULL FOREIGN KEY REFERENCES [Inventory].[Items]([Id]),
[BatchNumber] NVARCHAR(50) NOT NULL,
[Quantity] DECIMAL(18,3) NOT NULL,
[UnitCost] DECIMAL(18,4) NOT NULL,
[ReceivedDate] DATE NOT NULL,
[ExpiryDate] DATE NULL,
[WarehouseId] INT NOT NULL,
[Status] NVARCHAR(20) NOT NULL DEFAULT 'Active'
);
CREATE TABLE [Inventory].[Movements] (
[Id] INT IDENTITY(1,1) PRIMARY KEY,
[MovementNumber] NVARCHAR(50) NOT NULL UNIQUE,
[ItemId] INT NOT NULL FOREIGN KEY REFERENCES [Inventory].[Items]([Id]),
[Type] NVARCHAR(20) NOT NULL,
[Direction] NVARCHAR(5) NOT NULL,
[Quantity] DECIMAL(18,3) NOT NULL,
[UnitCost] DECIMAL(18,4) NOT NULL,
[WarehouseId] INT NOT NULL,
[StockBefore] DECIMAL(18,3) NOT NULL,
[StockAfter] DECIMAL(18,3) NOT NULL,
[MovementDate] DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
INDEX IX_Item_Date ([ItemId], [MovementDate] DESC)
);
-- Expiring Items Alert
CREATE VIEW [Inventory].[vw_ExpiringItems] AS
SELECT b.*, i.NameAr, i.ItemCode
FROM [Inventory].[StockBatches] b
JOIN [Inventory].[Items] i ON i.Id = b.ItemId
WHERE b.ExpiryDate <= DATEADD(DAY, 30, GETDATE())
AND b.Status = 'Active' AND b.Quantity > 0;