feat: implement commission calculation strategy with ORM and SP options
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m43s

This commit is contained in:
masoodafar-web
2025-12-20 03:15:28 +03:30
parent 9864302bab
commit 25476ba120
18 changed files with 1687 additions and 21 deletions
@@ -12,6 +12,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken) public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken)
{ {
var query = _context.ClubMemberships var query = _context.ClubMemberships
.Include(x => x.User)
.ApplyOrder(sortBy: request.SortBy) .ApplyOrder(sortBy: request.SortBy)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
@@ -33,6 +34,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
{ {
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
IsActive = x.IsActive, IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt, ActivatedAt = x.ActivatedAt,
InitialContribution = x.InitialContribution, InitialContribution = x.InitialContribution,
@@ -10,6 +10,7 @@ public class GetAllClubMembershipsResponseModel
{ {
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; } public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; } public long InitialContribution { get; set; }
@@ -8,13 +8,16 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly ICommissionCalculationStrategyFactory _strategyFactory;
public TriggerWeeklyCalculationCommandHandler( public TriggerWeeklyCalculationCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IMediator mediator) IMediator mediator,
ICommissionCalculationStrategyFactory strategyFactory)
{ {
_context = context; _context = context;
_mediator = mediator; _mediator = mediator;
_strategyFactory = strategyFactory;
} }
public async Task<TriggerWeeklyCalculationResponseDto> Handle( public async Task<TriggerWeeklyCalculationResponseDto> Handle(
@@ -26,7 +29,6 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
try try
{ {
// Validate week number format // Validate week number format
if (request.WeekDefinitionId <= 0) if (request.WeekDefinitionId <= 0)
{ {
@@ -41,32 +43,36 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
var steps = new List<string>(); var steps = new List<string>();
// ⭐ دریافت استراتژی براساس Config (ORM یا SP)
var strategy = await _strategyFactory.CreateStrategyAsync(cancellationToken);
var strategyName = strategy.GetType().Name.Contains("StoredProcedure") ? "SP" : "ORM";
// Step 1: Calculate Weekly Balances (تا 15 لول) // Step 1: Calculate Weekly Balances (تا 15 لول)
if (!request.SkipBalances) if (!request.SkipBalances)
{ {
await _mediator.Send(new CalculateWeeklyBalancesCommand var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
{ request.WeekDefinitionId,
WeekDefinitionId = request.WeekDefinitionId, request.ForceRecalculate,
ForceRecalculate = request.ForceRecalculate cancellationToken);
}, cancellationToken);
steps.Add("محاسبه امتیازات هفتگی"); steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
} }
// Step 2: Calculate Pool & Process Payouts (محاسبه استخر + پرداخت کاربران) // Step 2: Calculate Pool & Process Payouts (محاسبه استخر + پرداخت کاربران)
if (!request.SkipPayouts) if (!request.SkipPayouts)
{ {
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
{ request.WeekDefinitionId,
WeekDefinitionId = request.WeekDefinitionId, request.ForceRecalculate,
ForceRecalculate = request.ForceRecalculate cancellationToken);
}, cancellationToken);
steps.Add("محاسبه استخر و پرداخت کاربران"); steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
} }
return new TriggerWeeklyCalculationResponseDto return new TriggerWeeklyCalculationResponseDto
{ {
Success = true, Success = true,
Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد. مراحل: {string.Join(", ", steps)}", Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد [{strategyName}]. مراحل: {string.Join(", ", steps)}",
ExecutionId = executionId, ExecutionId = executionId,
StartedAt = startedAt StartedAt = startedAt
}; };
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// استراتژی محاسبه کمیسیون هفتگی
/// دو پیاده‌سازی دارد: ORM و Stored Procedure
/// </summary>
public interface ICommissionCalculationStrategy
{
/// <summary>
/// محاسبه تعادل‌های هفتگی (مرحله 1)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>تعداد رکوردهای محاسبه شده</returns>
Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
/// <summary>
/// محاسبه استخر کمیسیون و پرداخت‌ها (مرحله 2)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>شناسه Pool ایجاد شده</returns>
Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نوع استراتژی محاسبه کمیسیون
/// </summary>
public enum CommissionCalculationStrategyType
{
/// <summary>
/// استفاده از Entity Framework (ORM) - پیش‌فرض
/// </summary>
Orm = 0,
/// <summary>
/// استفاده از Stored Procedure - بهینه‌تر برای داده‌های زیاد
/// </summary>
StoredProcedure = 1
}
@@ -0,0 +1,20 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Factory برای ایجاد Strategy مناسب براساس Config
/// </summary>
public interface ICommissionCalculationStrategyFactory
{
/// <summary>
/// ایجاد Strategy براساس تنظیمات سیستم
/// </summary>
/// <returns>استراتژی مناسب (ORM یا SP)</returns>
Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default);
/// <summary>
/// ایجاد Strategy مشخص
/// </summary>
/// <param name="strategyType">نوع استراتژی</param>
/// <returns>استراتژی درخواستی</returns>
ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType);
}
@@ -56,16 +56,20 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatis
// Monthly growth (last 6 months) - using Created date // Monthly growth (last 6 months) - using Created date
var sixMonthsAgo = DateTime.Now.AddMonths(-6); var sixMonthsAgo = DateTime.Now.AddMonths(-6);
var monthlyGrowth = await _context.Users var monthlyGrowthRaw = await _context.Users
.Where(x => x.NetworkParentId != null && x.Created >= sixMonthsAgo) .Where(x => x.NetworkParentId != null && x.Created >= sixMonthsAgo)
.GroupBy(x => new { x.Created.Year, x.Created.Month }) .Select(x => new { x.Created.Year, x.Created.Month })
.ToListAsync(cancellationToken);
var monthlyGrowth = monthlyGrowthRaw
.GroupBy(x => new { x.Year, x.Month })
.Select(g => new MonthlyGrowthModel .Select(g => new MonthlyGrowthModel
{ {
Month = $"{g.Key.Year}-{g.Key.Month:D2}", Month = $"{g.Key.Year}-{g.Key.Month:D2}",
NewMembers = g.Count() NewMembers = g.Count()
}) })
.OrderBy(x => x.Month) .OrderBy(x => x.Month)
.ToListAsync(cancellationToken); .ToList();
// Top users by total children count // Top users by total children count
var topUsers = await _context.Users var topUsers = await _context.Users
@@ -6,6 +6,7 @@ using CMSMicroservice.Infrastructure.BackgroundJobs;
using CMSMicroservice.Infrastructure.Services.Monitoring; using CMSMicroservice.Infrastructure.Services.Monitoring;
using CMSMicroservice.Infrastructure.Configuration; using CMSMicroservice.Infrastructure.Configuration;
using CMSMicroservice.Infrastructure.Services.Payment; using CMSMicroservice.Infrastructure.Services.Payment;
using CMSMicroservice.Infrastructure.Services.Commission;
using CMSMicroservice.Infrastructure.Repositories; using CMSMicroservice.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -88,6 +89,9 @@ public static class ConfigureServices
services.AddSingleton<WeekDefinitionRepository>(); services.AddSingleton<WeekDefinitionRepository>();
services.AddSingleton<IWeekDefinitionRepository>(sp => sp.GetRequiredService<WeekDefinitionRepository>()); services.AddSingleton<IWeekDefinitionRepository>(sp => sp.GetRequiredService<WeekDefinitionRepository>());
// Commission Calculation Strategy Factory - برای سوییچ بین ORM و SP
services.AddScoped<ICommissionCalculationStrategyFactory, CommissionCalculationStrategyFactory>();
// Background Workers - Deprecated: Using Hangfire instead // Background Workers - Deprecated: Using Hangfire instead
// services.AddHostedService<WeeklyNetworkCommissionWorker>(); // services.AddHostedService<WeeklyNetworkCommissionWorker>();
services.AddScoped<WeeklyCommissionJob>(); // Hangfire Job (Scoped for DI) services.AddScoped<WeeklyCommissionJob>(); // Hangfire Job (Scoped for DI)
@@ -61,7 +61,7 @@ public class WeekDefinitionSeeder
while (currentDate <= endDate) while (currentDate <= endDate)
{ {
var weekStart = currentDate; // شنبه var weekStart = currentDate; // شنبه
var weekEnd = currentDate.AddDays(6); // جمعه var weekEnd = currentDate.AddDays(6).Date.AddHours(23).AddMinutes(59).AddSeconds(59); // جمعه 23:59:59
// محاسبه شماره ترتیبی هفته (از 1 شروع می‌شود) // محاسبه شماره ترتیبی هفته (از 1 شروع می‌شود)
var weekOrder = CalculateWeekOrder(weekStart); var weekOrder = CalculateWeekOrder(weekStart);
@@ -0,0 +1,40 @@
-- =============================================
-- Insert Configuration for Strategy Selection
-- =============================================
-- Check if config exists, insert if not
IF NOT EXISTS (SELECT 1 FROM CMS.SystemConfigurations WHERE [Key] = 'Commission.CalculationStrategy')
BEGIN
INSERT INTO CMS.SystemConfigurations ([Key], [Value], [Description], IsActive, Created, CreatedBy, LastModified, LastModifiedBy)
VALUES (
'Commission.CalculationStrategy',
'ORM',
N'روش محاسبه کمیسیون: ORM (Entity Framework) یا SP (Stored Procedure)',
1,
GETDATE(),
'System',
GETDATE(),
'System'
);
PRINT 'Configuration Commission.CalculationStrategy created with default value: ORM'
END
ELSE
BEGIN
PRINT 'Configuration Commission.CalculationStrategy already exists'
END
-- Show current value
SELECT [Key], [Value], [Description]
FROM CMS.SystemConfigurations
WHERE [Key] LIKE 'Commission.%'
ORDER BY [Key];
/*
تغییر روش محاسبه:
-- برای استفاده از ORM (Entity Framework):
UPDATE CMS.SystemConfigurations SET Value = 'ORM' WHERE [Key] = 'Commission.CalculationStrategy'
-- برای استفاده از Stored Procedure:
UPDATE CMS.SystemConfigurations SET Value = 'SP' WHERE [Key] = 'Commission.CalculationStrategy'
*/
@@ -0,0 +1,94 @@
-- =============================================
-- SP Deployment Script for Commission Calculation
-- Version: 1.0
-- Created: 2025-12-19
-- =============================================
/*
این اسکریپت برای نصب Stored Procedure های محاسبه کمیسیون استفاده میشود.
قبل از اجرا:
1. مطمئن شوید که به دیتابیس CMS متصل هستید
2. کاربر شما دسترسی CREATE PROCEDURE داشته باشد
ترتیب اجرا:
1. sp_CalculateWeeklyBalances.sql
2. sp_CalculateWeeklyCommissionPool.sql
یا این فایل را اجرا کنید که هر دو SP را نصب میکند.
*/
PRINT '========================================='
PRINT 'Commission Calculation SP Deployment'
PRINT '========================================='
PRINT ''
-- =============================================
-- Check Schema
-- =============================================
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'CMS')
BEGIN
PRINT 'ERROR: Schema CMS does not exist!'
RETURN
END
PRINT 'Schema CMS: OK'
PRINT ''
-- =============================================
-- Drop existing procedures if exist
-- =============================================
IF EXISTS (SELECT 1 FROM sys.procedures WHERE name = 'sp_CalculateWeeklyBalances' AND schema_id = SCHEMA_ID('CMS'))
BEGIN
DROP PROCEDURE CMS.sp_CalculateWeeklyBalances;
PRINT 'Dropped existing sp_CalculateWeeklyBalances'
END
IF EXISTS (SELECT 1 FROM sys.procedures WHERE name = 'sp_CalculateWeeklyCommissionPool' AND schema_id = SCHEMA_ID('CMS'))
BEGIN
DROP PROCEDURE CMS.sp_CalculateWeeklyCommissionPool;
PRINT 'Dropped existing sp_CalculateWeeklyCommissionPool'
END
PRINT ''
PRINT 'Creating Stored Procedures...'
PRINT ''
-- =============================================
-- SP 1: sp_CalculateWeeklyBalances
-- =============================================
GO
-- (content of sp_CalculateWeeklyBalances.sql here)
-- Run sp_CalculateWeeklyBalances.sql separately
GO
-- (content of sp_CalculateWeeklyCommissionPool.sql here)
-- Run sp_CalculateWeeklyCommissionPool.sql separately
GO
PRINT ''
PRINT '========================================='
PRINT 'Deployment Complete!'
PRINT '========================================='
PRINT ''
PRINT 'How to use:'
PRINT ''
PRINT '-- 1. Set calculation strategy in SystemConfigurations:'
PRINT 'UPDATE CMS.SystemConfigurations '
PRINT 'SET Value = ''SP'' '
PRINT 'WHERE [Key] = ''Commission.CalculationStrategy'''
PRINT ''
PRINT '-- Or for ORM mode:'
PRINT 'UPDATE CMS.SystemConfigurations '
PRINT 'SET Value = ''ORM'' '
PRINT 'WHERE [Key] = ''Commission.CalculationStrategy'''
PRINT ''
PRINT '-- 2. Manual execution (optional):'
PRINT 'DECLARE @RowCount INT, @PoolId BIGINT'
PRINT 'EXEC CMS.sp_CalculateWeeklyBalances @WeekDefinitionId = 1, @ForceRecalculate = 0, @RowCount = @RowCount OUTPUT'
PRINT 'PRINT ''Rows affected: '' + CAST(@RowCount AS VARCHAR)'
PRINT ''
PRINT 'EXEC CMS.sp_CalculateWeeklyCommissionPool @WeekDefinitionId = 1, @ForceRecalculate = 0, @PoolId = @PoolId OUTPUT'
PRINT 'PRINT ''Pool ID: '' + CAST(@PoolId AS VARCHAR)'
GO
@@ -0,0 +1,350 @@
-- =============================================
-- Stored Procedure: sp_CalculateWeeklyBalances
-- Description: محاسبه تعادل‌های هفتگی شبکه باینری با استفاده از CTE
-- Author: System
-- Created: 2025-12-19
-- =============================================
CREATE OR ALTER PROCEDURE [CMS].[sp_CalculateWeeklyBalances]
@WeekDefinitionId BIGINT,
@ForceRecalculate BIT = 0,
@RowCount INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ErrorMessage NVARCHAR(4000);
DECLARE @StartDate DATETIME;
DECLARE @EndDate DATETIME;
DECLARE @PreviousWeekDefinitionId BIGINT;
DECLARE @MaxBalancesPerLeg INT = 300;
DECLARE @MaxNetworkLevel INT = 15;
DECLARE @CalculatedAt DATETIME = GETDATE();
BEGIN TRY
BEGIN TRANSACTION;
-- =============================================
-- 1. اعتبارسنجی WeekDefinitionId
-- =============================================
IF NOT EXISTS (SELECT 1 FROM CMS.WeekDefinitions WHERE Id = @WeekDefinitionId)
BEGIN
SET @ErrorMessage = N'هفته ' + CAST(@WeekDefinitionId AS NVARCHAR(20)) + N' در سیستم تعریف نشده است';
RAISERROR(@ErrorMessage, 16, 1);
RETURN;
END
-- دریافت بازه تاریخی هفته
SELECT @StartDate = StartDate, @EndDate = EndDate
FROM CMS.WeekDefinitions
WHERE Id = @WeekDefinitionId;
-- =============================================
-- 2. بررسی محاسبه قبلی
-- =============================================
IF EXISTS (SELECT 1 FROM CMS.NetworkWeeklyBalances WHERE WeekDefinitionId = @WeekDefinitionId)
BEGIN
IF @ForceRecalculate = 0
BEGIN
SET @ErrorMessage = N'تعادل‌های هفته ' + CAST(@WeekDefinitionId AS NVARCHAR(20)) + N' قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید';
RAISERROR(@ErrorMessage, 16, 1);
RETURN;
END
-- حذف محاسبات قبلی
DELETE FROM CMS.NetworkWeeklyBalances WHERE WeekDefinitionId = @WeekDefinitionId;
END
-- =============================================
-- 3. پیدا کردن هفته قبل
-- =============================================
SELECT TOP 1 @PreviousWeekDefinitionId = Id
FROM CMS.WeekDefinitions
WHERE WeekOrder = (SELECT WeekOrder - 1 FROM CMS.WeekDefinitions WHERE Id = @WeekDefinitionId)
AND IsActive = 1;
-- =============================================
-- 4. خواندن Configuration ها
-- =============================================
SELECT @MaxBalancesPerLeg = CAST(Value AS INT)
FROM CMS.SystemConfigurations
WHERE [Key] = 'Commission.MaxWeeklyBalancesPerLeg' AND IsActive = 1;
SELECT @MaxNetworkLevel = CAST(Value AS INT)
FROM CMS.SystemConfigurations
WHERE [Key] = 'Commission.MaxNetworkLevel' AND IsActive = 1;
-- مقادیر پیش‌فرض
SET @MaxBalancesPerLeg = ISNULL(@MaxBalancesPerLeg, 300);
SET @MaxNetworkLevel = ISNULL(@MaxNetworkLevel, 15);
-- =============================================
-- 5. ایجاد جدول موقت برای نتایج
-- =============================================
CREATE TABLE #Balances (
UserId BIGINT PRIMARY KEY,
LeftLegNewMembers INT DEFAULT 0,
RightLegNewMembers INT DEFAULT 0,
LeftLegCarryover INT DEFAULT 0,
RightLegCarryover INT DEFAULT 0,
LeftLegTotal INT DEFAULT 0,
RightLegTotal INT DEFAULT 0,
TotalBalances INT DEFAULT 0,
LeftLegRemainder INT DEFAULT 0,
RightLegRemainder INT DEFAULT 0,
FlushedPerSide INT DEFAULT 0,
TotalFlushed INT DEFAULT 0,
SubordinateBalances INT DEFAULT 0
);
-- =============================================
-- 6. دریافت کاربران فعال باشگاه
-- =============================================
INSERT INTO #Balances (UserId)
SELECT DISTINCT u.Id
FROM CMS.Users u
INNER JOIN CMS.ClubMemberships cm ON cm.UserId = u.Id AND cm.IsActive = 1;
-- =============================================
-- 7. دریافت باقیمانده هفته قبل
-- =============================================
IF @PreviousWeekDefinitionId IS NOT NULL
BEGIN
UPDATE b
SET b.LeftLegCarryover = ISNULL(nb.LeftLegRemainder, 0),
b.RightLegCarryover = ISNULL(nb.RightLegRemainder, 0)
FROM #Balances b
LEFT JOIN CMS.NetworkWeeklyBalances nb ON nb.UserId = b.UserId AND nb.WeekDefinitionId = @PreviousWeekDefinitionId;
END
-- =============================================
-- 8. محاسبه اعضای جدید هر پا با CTE (بهینه!)
-- =============================================
-- CTE برای شمارش اعضای جدید شاخه چپ
;WITH LeftLegCTE AS (
-- فرزند مستقیم چپ (لول 1)
SELECT
u.NetworkParentId AS ParentId,
u.Id AS ChildId,
1 AS Level
FROM CMS.Users u
WHERE u.LegPosition = 0 -- Left
AND u.NetworkParentId IS NOT NULL
UNION ALL
-- زیرمجموعه‌های بازگشتی
SELECT
cte.ParentId,
u.Id AS ChildId,
cte.Level + 1 AS Level
FROM CMS.Users u
INNER JOIN LeftLegCTE cte ON u.NetworkParentId = cte.ChildId
WHERE cte.Level < @MaxNetworkLevel
),
LeftNewMembers AS (
SELECT
cte.ParentId AS UserId,
COUNT(*) AS NewMembersCount
FROM LeftLegCTE cte
INNER JOIN CMS.ClubMemberships cm ON cm.UserId = cte.ChildId AND cm.IsActive = 1
WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate
GROUP BY cte.ParentId
)
UPDATE b
SET b.LeftLegNewMembers = ISNULL(lnm.NewMembersCount, 0)
FROM #Balances b
LEFT JOIN LeftNewMembers lnm ON lnm.UserId = b.UserId
OPTION (MAXRECURSION 100);
-- CTE برای شمارش اعضای جدید شاخه راست
;WITH RightLegCTE AS (
-- فرزند مستقیم راست (لول 1)
SELECT
u.NetworkParentId AS ParentId,
u.Id AS ChildId,
1 AS Level
FROM CMS.Users u
WHERE u.LegPosition = 1 -- Right
AND u.NetworkParentId IS NOT NULL
UNION ALL
-- زیرمجموعه‌های بازگشتی
SELECT
cte.ParentId,
u.Id AS ChildId,
cte.Level + 1 AS Level
FROM CMS.Users u
INNER JOIN RightLegCTE cte ON u.NetworkParentId = cte.ChildId
WHERE cte.Level < @MaxNetworkLevel
),
RightNewMembers AS (
SELECT
cte.ParentId AS UserId,
COUNT(*) AS NewMembersCount
FROM RightLegCTE cte
INNER JOIN CMS.ClubMemberships cm ON cm.UserId = cte.ChildId AND cm.IsActive = 1
WHERE cm.ActivatedAt >= @StartDate AND cm.ActivatedAt <= @EndDate
GROUP BY cte.ParentId
)
UPDATE b
SET b.RightLegNewMembers = ISNULL(rnm.NewMembersCount, 0)
FROM #Balances b
LEFT JOIN RightNewMembers rnm ON rnm.UserId = b.UserId
OPTION (MAXRECURSION 100);
-- =============================================
-- 9. محاسبه تعادل‌ها
-- =============================================
UPDATE #Balances
SET
-- مجموع هر پا
LeftLegTotal = LeftLegNewMembers + LeftLegCarryover,
RightLegTotal = RightLegNewMembers + RightLegCarryover;
UPDATE #Balances
SET
-- تعادل = MIN(چپ، راست)
TotalBalances = CASE
WHEN LeftLegTotal <= RightLegTotal THEN LeftLegTotal
ELSE RightLegTotal
END;
UPDATE #Balances
SET
-- باقیمانده برای هفته بعد
LeftLegRemainder = LeftLegTotal - TotalBalances,
RightLegRemainder = RightLegTotal - TotalBalances,
-- فلش (اگر تعادل > سقف)
FlushedPerSide = CASE
WHEN TotalBalances > @MaxBalancesPerLeg THEN TotalBalances - @MaxBalancesPerLeg
ELSE 0
END;
UPDATE #Balances
SET
-- اعمال سقف
TotalBalances = CASE
WHEN TotalBalances > @MaxBalancesPerLeg THEN @MaxBalancesPerLeg
ELSE TotalBalances
END,
-- مجموع فلش از دو طرف
TotalFlushed = FlushedPerSide * 2;
-- =============================================
-- 10. محاسبه تعادل زیرمجموعه با CTE
-- =============================================
;WITH SubordinateCTE AS (
-- فرزندان مستقیم (لول 1)
SELECT
u.NetworkParentId AS ParentId,
u.Id AS ChildId,
1 AS Level
FROM CMS.Users u
WHERE u.NetworkParentId IS NOT NULL
UNION ALL
-- زیرمجموعه‌های بازگشتی
SELECT
cte.ParentId,
u.Id AS ChildId,
cte.Level + 1 AS Level
FROM CMS.Users u
INNER JOIN SubordinateCTE cte ON u.NetworkParentId = cte.ChildId
WHERE cte.Level < @MaxNetworkLevel
),
SubordinateBalances AS (
SELECT
cte.ParentId AS UserId,
SUM(b.TotalBalances) AS TotalSubordinateBalances
FROM SubordinateCTE cte
INNER JOIN #Balances b ON b.UserId = cte.ChildId
GROUP BY cte.ParentId
)
UPDATE b
SET b.SubordinateBalances = ISNULL(sb.TotalSubordinateBalances, 0)
FROM #Balances b
LEFT JOIN SubordinateBalances sb ON sb.UserId = b.UserId
OPTION (MAXRECURSION 100);
-- =============================================
-- 11. Insert نتایج در جدول اصلی
-- =============================================
INSERT INTO CMS.NetworkWeeklyBalances (
UserId,
WeekDefinitionId,
LeftLegNewMembers,
RightLegNewMembers,
LeftLegCarryover,
RightLegCarryover,
LeftLegTotal,
RightLegTotal,
TotalBalances,
LeftLegRemainder,
RightLegRemainder,
FlushedPerSide,
TotalFlushed,
SubordinateBalances,
LeftLegBalances,
RightLegBalances,
WeeklyPoolContribution,
CalculatedAt,
IsExpired,
IsDeleted,
Created,
CreatedBy,
LastModified,
LastModifiedBy
)
SELECT
UserId,
@WeekDefinitionId,
LeftLegNewMembers,
RightLegNewMembers,
LeftLegCarryover,
RightLegCarryover,
LeftLegTotal,
RightLegTotal,
TotalBalances,
LeftLegRemainder,
RightLegRemainder,
FlushedPerSide,
TotalFlushed,
SubordinateBalances,
LeftLegTotal, -- LeftLegBalances (deprecated)
RightLegTotal, -- RightLegBalances (deprecated)
0, -- WeeklyPoolContribution
@CalculatedAt,
0, -- IsExpired
0, -- IsDeleted
@CalculatedAt, -- Created
'SP', -- CreatedBy
@CalculatedAt, -- LastModified
'SP' -- LastModifiedBy
FROM #Balances;
SET @RowCount = @@ROWCOUNT;
-- پاکسازی
DROP TABLE #Balances;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
-- پاکسازی در صورت خطا
IF OBJECT_ID('tempdb..#Balances') IS NOT NULL
DROP TABLE #Balances;
SET @ErrorMessage = ERROR_MESSAGE();
RAISERROR(@ErrorMessage, 16, 1);
END CATCH
END
GO
@@ -0,0 +1,266 @@
-- =============================================
-- Stored Procedure: sp_CalculateWeeklyCommissionPool
-- Description: محاسبه استخر کمیسیون هفتگی و توزیع بین کاربران
-- Author: System
-- Created: 2025-12-19
-- =============================================
CREATE OR ALTER PROCEDURE [CMS].[sp_CalculateWeeklyCommissionPool]
@WeekDefinitionId BIGINT,
@ForceRecalculate BIT = 0,
@PoolId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ErrorMessage NVARCHAR(4000);
DECLARE @TotalPoolAmount BIGINT;
DECLARE @TotalBalances INT;
DECLARE @ValuePerBalance BIGINT;
DECLARE @CalculatedAt DATETIME = GETDATE();
BEGIN TRY
BEGIN TRANSACTION;
-- =============================================
-- 1. پیدا کردن Pool هفته
-- =============================================
SELECT @PoolId = Id, @TotalPoolAmount = TotalPoolAmount
FROM CMS.WeeklyCommissionPools
WHERE WeekDefinitionId = @WeekDefinitionId;
IF @PoolId IS NULL
BEGIN
SET @ErrorMessage = N'استخر کمیسیون برای هفته ' + CAST(@WeekDefinitionId AS NVARCHAR(20)) + N' تعریف نشده است';
RAISERROR(@ErrorMessage, 16, 1);
RETURN;
END
-- =============================================
-- 2. بررسی محاسبات قبلی
-- =============================================
IF EXISTS (SELECT 1 FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId)
BEGIN
IF @ForceRecalculate = 0
BEGIN
SET @ErrorMessage = N'کمیسیون هفته ' + CAST(@WeekDefinitionId AS NVARCHAR(20)) + N' قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید';
RAISERROR(@ErrorMessage, 16, 1);
RETURN;
END
-- =============================================
-- 3. Revert کیف پول‌ها (در صورت ForceRecalculate)
-- =============================================
-- کم کردن مقادیر قبلی از NetworkBalance
UPDATE uw
SET uw.NetworkBalance = uw.NetworkBalance - ISNULL(
(SELECT SUM(wl.ChangeNerworkValue)
FROM CMS.UserWalletChangeLogs wl
WHERE wl.WalletId = uw.Id
AND wl.RefrenceId IN (SELECT Id FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId)
), 0)
FROM CMS.UserWallets uw
WHERE uw.Id IN (
SELECT DISTINCT wl.WalletId
FROM CMS.UserWalletChangeLogs wl
WHERE wl.RefrenceId IN (SELECT Id FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId)
);
-- حذف لاگ‌های تغییرات قبلی
DELETE FROM CMS.UserWalletChangeLogs
WHERE RefrenceId IN (SELECT Id FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId);
-- حذف تاریخچه پرداخت
DELETE FROM CMS.CommissionPayoutHistories
WHERE UserCommissionPayoutId IN (SELECT Id FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId);
-- حذف پرداخت‌های قبلی
DELETE FROM CMS.UserCommissionPayouts WHERE WeekDefinitionId = @WeekDefinitionId;
-- Reset وضعیت Pool
UPDATE CMS.WeeklyCommissionPools
SET IsCalculated = 0
WHERE Id = @PoolId;
END
-- =============================================
-- 4. بررسی وجود تعادل‌های هفتگی
-- =============================================
IF NOT EXISTS (SELECT 1 FROM CMS.NetworkWeeklyBalances WHERE WeekDefinitionId = @WeekDefinitionId)
BEGIN
SET @ErrorMessage = N'ابتدا باید تعادل‌های هفتگی محاسبه شوند. sp_CalculateWeeklyBalances را اجرا کنید';
RAISERROR(@ErrorMessage, 16, 1);
RETURN;
END
-- =============================================
-- 5. محاسبه مجموع تعادل‌ها
-- =============================================
SELECT @TotalBalances = SUM(TotalBalances)
FROM CMS.NetworkWeeklyBalances
WHERE WeekDefinitionId = @WeekDefinitionId AND TotalBalances > 0;
IF @TotalBalances IS NULL OR @TotalBalances = 0
BEGIN
-- هیچ تعادلی برای توزیع وجود ندارد
UPDATE CMS.WeeklyCommissionPools
SET IsCalculated = 1, CalculatedAt = @CalculatedAt
WHERE Id = @PoolId;
COMMIT TRANSACTION;
RETURN;
END
-- =============================================
-- 6. محاسبه ارزش هر تعادل
-- =============================================
SET @ValuePerBalance = @TotalPoolAmount / @TotalBalances;
-- =============================================
-- 7. ایجاد پرداخت برای هر کاربر
-- =============================================
INSERT INTO CMS.UserCommissionPayouts (
UserId,
WeekDefinitionId,
WeeklyPoolId,
BalancesEarned,
ValuePerBalance,
TotalAmount,
Status,
PaidAt,
IsDeleted,
Created,
CreatedBy,
LastModified,
LastModifiedBy
)
SELECT
nb.UserId,
@WeekDefinitionId,
@PoolId,
nb.TotalBalances,
@ValuePerBalance,
nb.TotalBalances * @ValuePerBalance,
0, -- Status: Pending
NULL, -- PaidAt
0, -- IsDeleted
@CalculatedAt,
'SP',
@CalculatedAt,
'SP'
FROM CMS.NetworkWeeklyBalances nb
WHERE nb.WeekDefinitionId = @WeekDefinitionId AND nb.TotalBalances > 0;
-- =============================================
-- 8. ثبت تاریخچه پرداخت
-- =============================================
INSERT INTO CMS.CommissionPayoutHistories (
UserCommissionPayoutId,
UserId,
WeekDefinitionId,
AmountBefore,
AmountAfter,
OldStatus,
NewStatus,
Action,
PerformedBy,
Reason,
IsDeleted,
Created,
CreatedBy,
LastModified,
LastModifiedBy
)
SELECT
cp.Id, -- UserCommissionPayoutId
cp.UserId, -- UserId
@WeekDefinitionId,
0, -- AmountBefore
cp.TotalAmount, -- AmountAfter
0, -- OldStatus: Pending
0, -- NewStatus: Pending
0, -- Action: Created (assuming enum value)
'SP', -- PerformedBy
N'محاسبه اولیه توسط SP', -- Reason
0, -- IsDeleted
@CalculatedAt,
'SP',
@CalculatedAt,
'SP'
FROM CMS.UserCommissionPayouts cp
WHERE cp.WeekDefinitionId = @WeekDefinitionId;
-- =============================================
-- 9. شارژ کیف پول کاربران
-- =============================================
-- بروزرسانی NetworkBalance
UPDATE uw
SET uw.NetworkBalance = uw.NetworkBalance + cp.TotalAmount
FROM CMS.UserWallets uw
INNER JOIN CMS.UserCommissionPayouts cp ON cp.UserId = uw.UserId
WHERE cp.WeekDefinitionId = @WeekDefinitionId;
-- ثبت لاگ تغییرات کیف پول
INSERT INTO CMS.UserWalletChangeLogs (
WalletId,
CurrentBalance,
ChangeValue,
CurrentNetworkBalance,
ChangeNerworkValue,
CurrentDiscountBalance,
ChangeDiscountValue,
IsIncrease,
RefrenceId,
IsDeleted,
Created,
CreatedBy,
LastModified,
LastModifiedBy
)
SELECT
uw.Id,
uw.Balance, -- CurrentBalance
0, -- ChangeValue (no change to main balance)
uw.NetworkBalance, -- CurrentNetworkBalance (after update)
cp.TotalAmount, -- ChangeNerworkValue
uw.DiscountBalance, -- CurrentDiscountBalance
0, -- ChangeDiscountValue
1, -- IsIncrease: true
cp.Id, -- RefrenceId: PayoutId
0, -- IsDeleted
@CalculatedAt,
'SP',
@CalculatedAt,
'SP'
FROM CMS.UserCommissionPayouts cp
INNER JOIN CMS.UserWallets uw ON uw.UserId = cp.UserId
WHERE cp.WeekDefinitionId = @WeekDefinitionId;
-- =============================================
-- 10. بروزرسانی Pool
-- =============================================
UPDATE CMS.WeeklyCommissionPools
SET
IsCalculated = 1,
CalculatedAt = @CalculatedAt,
TotalBalances = @TotalBalances,
ValuePerBalance = @ValuePerBalance,
LastModified = @CalculatedAt,
LastModifiedBy = 'SP'
WHERE Id = @PoolId;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SET @ErrorMessage = ERROR_MESSAGE();
RAISERROR(@ErrorMessage, 16, 1);
END CATCH
END
GO
@@ -0,0 +1,63 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// Factory برای ایجاد Strategy مناسب براساس Config
/// </summary>
public class CommissionCalculationStrategyFactory : ICommissionCalculationStrategyFactory
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// کلید Config برای انتخاب استراتژی
/// مقدار: "ORM" یا "SP"
/// پیش‌فرض: "ORM"
/// </summary>
private const string ConfigKey = "Commission.CalculationStrategy";
public CommissionCalculationStrategyFactory(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository,
IServiceProvider serviceProvider)
{
_context = context;
_weekRepository = weekRepository;
_serviceProvider = serviceProvider;
}
/// <inheritdoc />
public async Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default)
{
// خواندن Config از دیتابیس
var config = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Key == ConfigKey && x.IsActive, cancellationToken);
var strategyValue = config?.Value?.ToUpperInvariant() ?? "ORM";
var strategyType = strategyValue switch
{
"SP" or "STOREDPROCEDURE" or "STORED_PROCEDURE" => CommissionCalculationStrategyType.StoredProcedure,
_ => CommissionCalculationStrategyType.Orm
};
return CreateStrategy(strategyType);
}
/// <inheritdoc />
public ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType)
{
return strategyType switch
{
CommissionCalculationStrategyType.StoredProcedure =>
new StoredProcedureCommissionCalculationStrategy(_context),
_ => // Orm is default
new OrmCommissionCalculationStrategy(_context, _weekRepository)
};
}
}
@@ -0,0 +1,538 @@
using System.Collections.Generic;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.Network;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// پیاده‌سازی محاسبه کمیسیون با استفاده از Entity Framework (ORM)
/// این روش برای داده‌های کم مناسب است و دیباگ و تست راحت‌تری دارد
/// </summary>
public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public OrmCommissionCalculationStrategy(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{
_context = context;
_weekRepository = weekRepository;
}
/// <inheritdoc />
public async Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {weekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود محاسبه قبلی
var existingBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (existingBalances.Any() && !forceRecalculate)
{
throw new InvalidOperationException($"تعادل‌های هفته {weekDefinitionId} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
}
// حذف محاسبات قبلی در صورت ForceRecalculate
if (existingBalances.Any())
{
_context.NetworkWeeklyBalances.RemoveRange(existingBalances);
await _context.SaveChangesAsync(cancellationToken);
}
// دریافت همه کاربرانی که عضو فعال باشگاه هستند
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
// دریافت کاربران فعال در شبکه که عضو باشگاه هستند
var usersInNetwork = await _context.Users
.Where(x => activeClubMemberUserIds.Contains(x.Id))
.Select(x => new { x.Id })
.ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل
var previousWeekDefinitionId = GetPreviousWeekDefinitionId(weekDefinitionId);
Dictionary<long, (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers;
if (previousWeekDefinitionId.HasValue)
{
previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value)
.ToDictionaryAsync(
x => x.UserId,
x => (x.LeftLegRemainder, x.RightLegRemainder),
cancellationToken);
}
else
{
previousWeekCarryovers = new Dictionary<long, (int, int)>();
}
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// خواندن یکباره Configuration ها
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
foreach (var user in usersInNetwork.OrderBy(o => o.Id))
{
// دریافت باقیمانده هفته قبل
var leftCarryover = 0;
var rightCarryover = 0;
if (previousWeekCarryovers.ContainsKey(user.Id))
{
leftCarryover = previousWeekCarryovers[user.Id].LeftLegRemainder;
rightCarryover = previousWeekCarryovers[user.Id].RightLegRemainder;
}
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, weekDefinitionId, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, weekDefinitionId, maxNetworkLevel, cancellationToken);
// محاسبه مجموع هر پا (جدید + باقیمانده)
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// تعادل = کمترین مقدار بین چپ و راست
var totalBalances = Math.Min(leftTotal, rightTotal);
// باقیمانده برای هفته بعد
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// اعمال سقف 300
var cappedBalances = Math.Min(totalBalances, maxBalancesPerLeg);
// محاسبه فلش
var flushedPerSide = totalBalances - cappedBalances;
var totalFlushed = flushedPerSide * 2;
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekDefinitionId = weekDefinitionId,
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = cappedBalances,
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
FlushedPerSide = flushedPerSide,
TotalFlushed = totalFlushed,
SubordinateBalances = 0,
#pragma warning disable CS0618
LeftLegBalances = leftTotal,
RightLegBalances = rightTotal,
#pragma warning restore CS0618
WeeklyPoolContribution = 0,
CalculatedAt = calculatedAt,
IsExpired = false
};
balancesList.Add(balance);
}
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// محاسبه تعادل زیرمجموعه
var balancesDictionary = balancesList.ToDictionary(x => x.UserId);
foreach (var balance in balancesList)
{
var subordinateBalances = await CalculateSubordinateBalancesAsync(
balance.UserId,
balancesDictionary,
maxNetworkLevel,
cancellationToken
);
balance.SubordinateBalances = subordinateBalances;
}
_context.NetworkWeeklyBalances.UpdateRange(balancesList);
await _context.SaveChangesAsync(cancellationToken);
return balancesList.Count;
}
/// <inheritdoc />
public async Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var weekDefinition = _weekRepository.GetGregorianWeekNumber(weekDefinitionId);
if (weekDefinition == null)
{
throw new InvalidOperationException($"هفته {weekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekDefinitionId == weekDefinitionId, cancellationToken);
if (existingPool == null)
{
throw new InvalidOperationException(
$"Pool هفته {weekDefinitionId} وجود ندارد. " +
"Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد"
);
}
if (existingPool.IsCalculated && !forceRecalculate)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {weekDefinitionId} قبلاً محاسبه شده است");
}
// بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (!weeklyBalances.Any())
{
throw new InvalidOperationException($"تعادل‌های هفته {weekDefinitionId} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
}
var totalPoolAmount = existingPool.TotalPoolAmount;
var totalBalancesInNetwork = weeklyBalances.Sum(x => x.TotalBalances);
long valuePerBalance = 0;
if (totalBalancesInNetwork > 0)
{
valuePerBalance = totalPoolAmount / totalBalancesInNetwork;
}
// به‌روزرسانی Pool
existingPool.TotalBalances = totalBalancesInNetwork;
existingPool.ValuePerBalance = valuePerBalance;
existingPool.IsCalculated = true;
existingPool.CalculatedAt = DateTime.Now;
_context.WeeklyCommissionPools.Update(existingPool);
await _context.SaveChangesAsync(cancellationToken);
// حذف پرداخت‌های قبلی در صورت ForceRecalculate
if (forceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == weekDefinitionId)
.ToListAsync(cancellationToken);
if (oldPayouts.Any())
{
var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList();
// برگرداندن NetworkBalance به حالت قبل (Revert)
await RevertUserWalletsAsync(oldPayoutIds, cancellationToken);
// حذف تاریخچه‌ها
var oldHistories = await _context.CommissionPayoutHistories
.Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId))
.ToListAsync(cancellationToken);
if (oldHistories.Any())
{
_context.CommissionPayoutHistories.RemoveRange(oldHistories);
}
_context.UserCommissionPayouts.RemoveRange(oldPayouts);
await _context.SaveChangesAsync(cancellationToken);
}
}
// ثبت پرداخت برای کاربرانی که تعادل دارند
var payouts = new List<UserCommissionPayout>();
foreach (var balance in weeklyBalances)
{
var userBalance = balance.TotalBalances;
if (userBalance <= 0) continue;
var totalAmount = (long)(userBalance * valuePerBalance);
var payout = new UserCommissionPayout
{
UserId = balance.UserId,
WeekDefinitionId = weekDefinitionId,
WeeklyPoolId = existingPool.Id,
BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance,
TotalAmount = totalAmount,
Status = CommissionPayoutStatus.Pending,
PaidAt = null,
WithdrawalMethod = null,
IbanNumber = null,
WithdrawnAt = null
};
payouts.Add(payout);
}
if (payouts.Any())
{
await _context.UserCommissionPayouts.AddRangeAsync(payouts, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var historyList = payouts.Select(payout => new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekDefinitionId = weekDefinitionId,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
NewStatus = CommissionPayoutStatus.Pending,
Action = CommissionPayoutAction.Created,
PerformedBy = "System",
Reason = "پردازش خودکار کمیسیون هفتگی (ORM)"
}).ToList();
await _context.CommissionPayoutHistories.AddRangeAsync(historyList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// شارژ کیف پول کاربران
await ChargeUserWalletsAsync(payouts, weekDefinitionId, cancellationToken);
}
return existingPool.Id;
}
#region Private Methods
private long? GetPreviousWeekDefinitionId(long currentWeekDefinitionId)
{
var currentWeekNumber = _weekRepository.GetGregorianWeekNumber(currentWeekDefinitionId);
if (currentWeekNumber == null) return null;
var previousWeekNumber = _weekRepository.GetPreviousWeekNumber(currentWeekNumber);
if (previousWeekNumber == null) return null;
return _weekRepository.GetWeekDefinitionId(previousWeekNumber);
}
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, long weekDefinitionId, int maxLevel, CancellationToken cancellationToken)
{
var startDateEndDate = _weekRepository.GetWeekDateRange(weekDefinitionId);
var count = await CountNewMembersRecursive(userId, leg, startDateEndDate.Value.startDate, startDateEndDate.Value.endDate, 0, maxLevel, cancellationToken);
return count;
}
private async Task<int> CountNewMembersRecursive(
long userId,
NetworkLeg leg,
DateTime startDate,
DateTime endDate,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
if (currentLevel >= maxLevel)
{
return 0;
}
var child = await _context.Users
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg, cancellationToken);
if (child == null)
{
return 0;
}
var count = 0;
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
{
count = 1;
}
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
return count + childLeft + childRight;
}
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
{
var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel, cancellationToken);
var totalSubordinateBalances = 0;
foreach (var subordinateId in subordinates)
{
if (allBalances.ContainsKey(subordinateId))
{
totalSubordinateBalances += allBalances[subordinateId].TotalBalances;
}
}
return totalSubordinateBalances;
}
private async Task<List<long>> GetSubordinatesRecursive(
long userId,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
if (currentLevel > maxLevel)
{
return new List<long>();
}
var result = new List<long>();
var children = await _context.Users
.Where(x => x.NetworkParentId == userId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
result.AddRange(children);
foreach (var childId in children)
{
var grandChildren = await GetSubordinatesRecursive(childId, currentLevel + 1, maxLevel, cancellationToken);
result.AddRange(grandChildren);
}
return result;
}
private async Task ChargeUserWalletsAsync(
List<UserCommissionPayout> payouts,
long weekDefinitionId,
CancellationToken cancellationToken)
{
var userIds = payouts.Select(p => p.UserId).Distinct().ToList();
var existingWallets = await _context.UserWallets
.Where(w => userIds.Contains(w.UserId))
.ToDictionaryAsync(w => w.UserId, cancellationToken);
var newWallets = new List<UserWallet>();
var walletLogs = new List<UserWalletChangeLog>();
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
UserWallet wallet;
if (existingWallets.TryGetValue(payout.UserId, out var existingWallet))
{
wallet = existingWallet;
}
else
{
wallet = new UserWallet
{
UserId = payout.UserId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
newWallets.Add(wallet);
existingWallets[payout.UserId] = wallet;
}
}
if (newWallets.Any())
{
await _context.UserWallets.AddRangeAsync(newWallets, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
var wallet = existingWallets[payout.UserId];
wallet.NetworkBalance += payout.TotalAmount;
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = payout.TotalAmount,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = payout.Id
};
walletLogs.Add(walletLog);
}
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
private async Task RevertUserWalletsAsync(
List<long> oldPayoutIds,
CancellationToken cancellationToken)
{
var oldWalletLogs = await _context.UserWalletChangeLogs
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
.ToListAsync(cancellationToken);
if (!oldWalletLogs.Any()) return;
var walletIds = oldWalletLogs.Select(l => l.WalletId).Distinct().ToList();
var wallets = await _context.UserWallets
.Where(w => walletIds.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, cancellationToken);
foreach (var log in oldWalletLogs)
{
if (wallets.TryGetValue(log.WalletId, out var wallet))
{
wallet.NetworkBalance -= log.ChangeNerworkValue;
if (wallet.NetworkBalance < 0)
{
wallet.NetworkBalance = 0;
}
}
}
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
await _context.SaveChangesAsync(cancellationToken);
}
#endregion
}
@@ -0,0 +1,121 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Infrastructure.Services.Commission;
/// <summary>
/// پیاده‌سازی محاسبه کمیسیون با استفاده از Stored Procedure
/// این روش برای داده‌های زیاد بهینه‌تر است و از CTE استفاده می‌کند
/// </summary>
public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculationStrategy
{
private readonly IApplicationDbContext _context;
public StoredProcedureCommissionCalculationStrategy(IApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
// دسترسی به DbContext واقعی برای اجرای SP
var dbContext = _context as DbContext;
if (dbContext == null)
{
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken);
try
{
using var command = connection.CreateCommand();
command.CommandText = "CMS.sp_CalculateWeeklyBalances";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandTimeout = 300; // 5 دقیقه timeout
// پارامترها
var paramWeekDefinitionId = command.CreateParameter();
paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
paramWeekDefinitionId.Value = weekDefinitionId;
command.Parameters.Add(paramWeekDefinitionId);
var paramForceRecalculate = command.CreateParameter();
paramForceRecalculate.ParameterName = "@ForceRecalculate";
paramForceRecalculate.Value = forceRecalculate;
command.Parameters.Add(paramForceRecalculate);
// پارامتر خروجی
var paramRowCount = command.CreateParameter();
paramRowCount.ParameterName = "@RowCount";
paramRowCount.Direction = System.Data.ParameterDirection.Output;
paramRowCount.DbType = System.Data.DbType.Int32;
command.Parameters.Add(paramRowCount);
await command.ExecuteNonQueryAsync(cancellationToken);
var rowCount = (int)(paramRowCount.Value ?? 0);
return rowCount;
}
finally
{
await connection.CloseAsync();
}
}
/// <inheritdoc />
public async Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default)
{
var dbContext = _context as DbContext;
if (dbContext == null)
{
throw new InvalidOperationException("DbContext برای اجرای Stored Procedure در دسترس نیست");
}
var connection = dbContext.Database.GetDbConnection();
await connection.OpenAsync(cancellationToken);
try
{
using var command = connection.CreateCommand();
command.CommandText = "CMS.sp_CalculateWeeklyCommissionPool";
command.CommandType = System.Data.CommandType.StoredProcedure;
command.CommandTimeout = 300; // 5 دقیقه timeout
// پارامترها
var paramWeekDefinitionId = command.CreateParameter();
paramWeekDefinitionId.ParameterName = "@WeekDefinitionId";
paramWeekDefinitionId.Value = weekDefinitionId;
command.Parameters.Add(paramWeekDefinitionId);
var paramForceRecalculate = command.CreateParameter();
paramForceRecalculate.ParameterName = "@ForceRecalculate";
paramForceRecalculate.Value = forceRecalculate;
command.Parameters.Add(paramForceRecalculate);
// پارامتر خروجی - شناسه Pool
var paramPoolId = command.CreateParameter();
paramPoolId.ParameterName = "@PoolId";
paramPoolId.Direction = System.Data.ParameterDirection.Output;
paramPoolId.DbType = System.Data.DbType.Int64;
command.Parameters.Add(paramPoolId);
await command.ExecuteNonQueryAsync(cancellationToken);
var poolId = (long)(paramPoolId.Value ?? 0L);
return poolId;
}
finally
{
await connection.CloseAsync();
}
}
}
@@ -3,7 +3,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.0.155</Version> <Version>0.0.156</Version>
<DebugType>None</DebugType> <DebugType>None</DebugType>
<DebugSymbols>False</DebugSymbols> <DebugSymbols>False</DebugSymbols>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild> <GeneratePackageOnBuild>False</GeneratePackageOnBuild>
@@ -0,0 +1,104 @@
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
using CMSMicroservice.Protobuf.Protos.ClubMembership;
using Google.Protobuf.WellKnownTypes;
using Mapster;
using System.Linq;
namespace CMSMicroservice.WebApi.Common.Mappings;
public class ClubMembershipProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
// =====================
// GetAllClubMemberships
// =====================
// GetAllClubMembershipsRequest -> GetAllClubMembershipsQuery
config.NewConfig<GetAllClubMembershipsRequest, GetAllClubMembershipsQuery>()
.MapWith(src => new GetAllClubMembershipsQuery
{
PaginationState = new Application.Common.Models.PaginationState
{
PageNumber = src.PageIndex > 0 ? src.PageIndex : 1,
PageSize = src.PageSize > 0 ? src.PageSize : 20
},
Filter = new GetAllClubMembershipsFilter
{
UserId = src.UserId,
IsActive = src.IsActive
}
});
// GetAllClubMembershipsResponseDto -> GetAllClubMembershipsResponse
config.NewConfig<GetAllClubMembershipsResponseDto, GetAllClubMembershipsResponse>()
.MapWith(src => new GetAllClubMembershipsResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = src.MetaData.CurrentPage,
PageSize = src.MetaData.PageSize,
TotalCount = src.MetaData.TotalCount,
TotalPage = src.MetaData.TotalPage
},
Models = { src.Models.Select(MapToModel) }
});
// =====================
// GetClubStatistics
// =====================
// GetClubStatisticsRequest -> GetClubStatisticsQuery
config.NewConfig<GetClubStatisticsRequest, GetClubStatisticsQuery>()
.MapWith(src => new GetClubStatisticsQuery());
// GetClubStatisticsResponseDto -> GetClubStatisticsResponse
config.NewConfig<GetClubStatisticsResponseDto, GetClubStatisticsResponse>()
.MapWith(src => new GetClubStatisticsResponse
{
TotalMembers = src.TotalMembers,
ActiveMembers = src.ActiveMembers,
InactiveMembers = src.InactiveMembers,
ExpiredMembers = src.ExpiredMembers,
ActivePercentage = src.ActivePercentage,
TotalRevenue = src.TotalRevenue,
AverageMembershipDurationDays = src.AverageMembershipDurationDays,
ExpiringSoonCount = src.ExpiringSoonCount,
PackageDistribution = { src.PackageDistribution.Select(p => new PackageLevelDistribution
{
PackageId = p.PackageId,
PackageName = p.PackageName ?? string.Empty,
MemberCount = p.MemberCount,
Percentage = p.Percentage
}) },
MonthlyTrend = { src.MonthlyTrend.Select(t => new MonthlyMembershipTrend
{
Month = t.Month ?? string.Empty,
Activations = t.Activations,
Expirations = t.Expirations,
NetChange = t.NetChange
}) }
});
}
private static ClubMembershipModel MapToModel(GetAllClubMembershipsResponseModel src)
{
return new ClubMembershipModel
{
Id = src.Id,
UserId = src.UserId,
UserName = src.UserName ?? string.Empty,
PackageId = 0, // Not available in current entity
PackageName = string.Empty, // Not available in current entity
ActivationCode = string.Empty, // Not available in current entity
ActivatedAt = src.ActivatedAt.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(src.ActivatedAt.Value, DateTimeKind.Utc))
: null,
ExpiresAt = null, // Not available in current entity
IsActive = src.IsActive,
IsExpired = false, // Calculate based on ExpiresAt if available
Created = Timestamp.FromDateTime(src.Created.UtcDateTime)
};
}
}
@@ -1,5 +1,6 @@
using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree; using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition; using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition;
using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStatistics;
using CMSMicroservice.Protobuf.Protos.NetworkMembership; using CMSMicroservice.Protobuf.Protos.NetworkMembership;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
@@ -9,6 +10,10 @@ public class NetworkMembershipProfile : IRegister
{ {
void IRegister.Register(TypeAdapterConfig config) void IRegister.Register(TypeAdapterConfig config)
{ {
// GetNetworkStatisticsRequest -> GetNetworkStatisticsQuery
config.NewConfig<GetNetworkStatisticsRequest, GetNetworkStatisticsQuery>()
.MapWith(src => new GetNetworkStatisticsQuery());
// Request mapping // Request mapping
config.NewConfig<GetNetworkTreeRequest, GetNetworkTreeQuery>() config.NewConfig<GetNetworkTreeRequest, GetNetworkTreeQuery>()
.Map(dest => dest.UserId, src => src.UserId) .Map(dest => dest.UserId, src => src.UserId)