From 25476ba120317425828771dbca1aacae2051db44 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Sat, 20 Dec 2025 03:15:28 +0330 Subject: [PATCH] feat: implement commission calculation strategy with ORM and SP options --- .../GetAllClubMembershipsQueryHandler.cs | 2 + .../GetAllClubMembershipsResponseDto.cs | 1 + .../TriggerWeeklyCalculationCommandHandler.cs | 38 +- .../ICommissionCalculationStrategy.cs | 48 ++ .../ICommissionCalculationStrategyFactory.cs | 20 + .../GetNetworkStatisticsQueryHandler.cs | 10 +- .../ConfigureServices.cs | 4 + .../Data/Seeding/WeekDefinitionSeeder.cs | 2 +- .../01-InsertConfiguration.sql | 40 ++ .../StoredProcedures/README-DEPLOY.sql | 94 +++ .../sp_CalculateWeeklyBalances.sql | 350 ++++++++++++ .../sp_CalculateWeeklyCommissionPool.sql | 266 +++++++++ .../CommissionCalculationStrategyFactory.cs | 63 ++ .../OrmCommissionCalculationStrategy.cs | 538 ++++++++++++++++++ ...dProcedureCommissionCalculationStrategy.cs | 121 ++++ .../CMSMicroservice.Protobuf.csproj | 2 +- .../Common/Mappings/ClubMembershipProfile.cs | 104 ++++ .../Mappings/NetworkMembershipProfile.cs | 5 + 18 files changed, 1687 insertions(+), 21 deletions(-) create mode 100644 src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs create mode 100644 src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategyFactory.cs create mode 100644 src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/01-InsertConfiguration.sql create mode 100644 src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/README-DEPLOY.sql create mode 100644 src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyBalances.sql create mode 100644 src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyCommissionPool.sql create mode 100644 src/CMSMicroservice.Infrastructure/Services/Commission/CommissionCalculationStrategyFactory.cs create mode 100644 src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs create mode 100644 src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs create mode 100644 src/CMSMicroservice.WebApi/Common/Mappings/ClubMembershipProfile.cs diff --git a/src/CMSMicroservice.Application/ClubMembershipCQ/Queries/GetAllClubMemberships/GetAllClubMembershipsQueryHandler.cs b/src/CMSMicroservice.Application/ClubMembershipCQ/Queries/GetAllClubMemberships/GetAllClubMembershipsQueryHandler.cs index ce5c075..916c4ab 100644 --- a/src/CMSMicroservice.Application/ClubMembershipCQ/Queries/GetAllClubMemberships/GetAllClubMembershipsQueryHandler.cs +++ b/src/CMSMicroservice.Application/ClubMembershipCQ/Queries/GetAllClubMemberships/GetAllClubMembershipsQueryHandler.cs @@ -12,6 +12,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken) { var query = _context.ClubMemberships + .Include(x => x.User) .ApplyOrder(sortBy: request.SortBy) .AsNoTracking() .AsQueryable(); @@ -33,6 +34,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler Handle( @@ -26,9 +29,8 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler(); + + // ⭐ دریافت استراتژی براساس Config (ORM یا SP) + var strategy = await _strategyFactory.CreateStrategyAsync(cancellationToken); + var strategyName = strategy.GetType().Name.Contains("StoredProcedure") ? "SP" : "ORM"; // Step 1: Calculate Weekly Balances (تا 15 لول) if (!request.SkipBalances) { - await _mediator.Send(new CalculateWeeklyBalancesCommand - { - WeekDefinitionId = request.WeekDefinitionId, - ForceRecalculate = request.ForceRecalculate - }, cancellationToken); - steps.Add("محاسبه امتیازات هفتگی"); + var balancesCount = await strategy.CalculateWeeklyBalancesAsync( + request.WeekDefinitionId, + request.ForceRecalculate, + cancellationToken); + + steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)"); } // Step 2: Calculate Pool & Process Payouts (محاسبه استخر + پرداخت کاربران) if (!request.SkipPayouts) { - await _mediator.Send(new CalculateWeeklyCommissionPoolCommand - { - WeekDefinitionId = request.WeekDefinitionId, - ForceRecalculate = request.ForceRecalculate - }, cancellationToken); - steps.Add("محاسبه استخر و پرداخت کاربران"); + var poolId = await strategy.CalculateWeeklyCommissionPoolAsync( + request.WeekDefinitionId, + request.ForceRecalculate, + cancellationToken); + + steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})"); } return new TriggerWeeklyCalculationResponseDto { Success = true, - Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد. مراحل: {string.Join(", ", steps)}", + Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد [{strategyName}]. مراحل: {string.Join(", ", steps)}", ExecutionId = executionId, StartedAt = startedAt }; diff --git a/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs new file mode 100644 index 0000000..a25bb7a --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategy.cs @@ -0,0 +1,48 @@ +namespace CMSMicroservice.Application.Common.Interfaces; + +/// +/// استراتژی محاسبه کمیسیون هفتگی +/// دو پیاده‌سازی دارد: ORM و Stored Procedure +/// +public interface ICommissionCalculationStrategy +{ + /// + /// محاسبه تعادل‌های هفتگی (مرحله 1) + /// + /// شناسه هفته + /// محاسبه مجدد حتی اگر قبلاً محاسبه شده + /// توکن لغو + /// تعداد رکوردهای محاسبه شده + Task CalculateWeeklyBalancesAsync( + long weekDefinitionId, + bool forceRecalculate, + CancellationToken cancellationToken = default); + + /// + /// محاسبه استخر کمیسیون و پرداخت‌ها (مرحله 2) + /// + /// شناسه هفته + /// محاسبه مجدد حتی اگر قبلاً محاسبه شده + /// توکن لغو + /// شناسه Pool ایجاد شده + Task CalculateWeeklyCommissionPoolAsync( + long weekDefinitionId, + bool forceRecalculate, + CancellationToken cancellationToken = default); +} + +/// +/// نوع استراتژی محاسبه کمیسیون +/// +public enum CommissionCalculationStrategyType +{ + /// + /// استفاده از Entity Framework (ORM) - پیش‌فرض + /// + Orm = 0, + + /// + /// استفاده از Stored Procedure - بهینه‌تر برای داده‌های زیاد + /// + StoredProcedure = 1 +} diff --git a/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategyFactory.cs b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategyFactory.cs new file mode 100644 index 0000000..6246075 --- /dev/null +++ b/src/CMSMicroservice.Application/Common/Interfaces/ICommissionCalculationStrategyFactory.cs @@ -0,0 +1,20 @@ +namespace CMSMicroservice.Application.Common.Interfaces; + +/// +/// Factory برای ایجاد Strategy مناسب براساس Config +/// +public interface ICommissionCalculationStrategyFactory +{ + /// + /// ایجاد Strategy براساس تنظیمات سیستم + /// + /// استراتژی مناسب (ORM یا SP) + Task CreateStrategyAsync(CancellationToken cancellationToken = default); + + /// + /// ایجاد Strategy مشخص + /// + /// نوع استراتژی + /// استراتژی درخواستی + ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType); +} diff --git a/src/CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkStatistics/GetNetworkStatisticsQueryHandler.cs b/src/CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkStatistics/GetNetworkStatisticsQueryHandler.cs index 2ccbfa7..82e6919 100644 --- a/src/CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkStatistics/GetNetworkStatisticsQueryHandler.cs +++ b/src/CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkStatistics/GetNetworkStatisticsQueryHandler.cs @@ -56,16 +56,20 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler 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 { Month = $"{g.Key.Year}-{g.Key.Month:D2}", NewMembers = g.Count() }) .OrderBy(x => x.Month) - .ToListAsync(cancellationToken); + .ToList(); // Top users by total children count var topUsers = await _context.Users diff --git a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs index 065df11..db75668 100644 --- a/src/CMSMicroservice.Infrastructure/ConfigureServices.cs +++ b/src/CMSMicroservice.Infrastructure/ConfigureServices.cs @@ -6,6 +6,7 @@ using CMSMicroservice.Infrastructure.BackgroundJobs; using CMSMicroservice.Infrastructure.Services.Monitoring; using CMSMicroservice.Infrastructure.Configuration; using CMSMicroservice.Infrastructure.Services.Payment; +using CMSMicroservice.Infrastructure.Services.Commission; using CMSMicroservice.Infrastructure.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -88,6 +89,9 @@ public static class ConfigureServices services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + // Commission Calculation Strategy Factory - برای سوییچ بین ORM و SP + services.AddScoped(); + // Background Workers - Deprecated: Using Hangfire instead // services.AddHostedService(); services.AddScoped(); // Hangfire Job (Scoped for DI) diff --git a/src/CMSMicroservice.Infrastructure/Data/Seeding/WeekDefinitionSeeder.cs b/src/CMSMicroservice.Infrastructure/Data/Seeding/WeekDefinitionSeeder.cs index b36e47f..51da2a1 100644 --- a/src/CMSMicroservice.Infrastructure/Data/Seeding/WeekDefinitionSeeder.cs +++ b/src/CMSMicroservice.Infrastructure/Data/Seeding/WeekDefinitionSeeder.cs @@ -61,7 +61,7 @@ public class WeekDefinitionSeeder while (currentDate <= endDate) { var weekStart = currentDate; // شنبه - var weekEnd = currentDate.AddDays(6); // جمعه + var weekEnd = currentDate.AddDays(6).Date.AddHours(23).AddMinutes(59).AddSeconds(59); // جمعه 23:59:59 // محاسبه شماره ترتیبی هفته (از 1 شروع می‌شود) var weekOrder = CalculateWeekOrder(weekStart); diff --git a/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/01-InsertConfiguration.sql b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/01-InsertConfiguration.sql new file mode 100644 index 0000000..19ca022 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/01-InsertConfiguration.sql @@ -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' +*/ diff --git a/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/README-DEPLOY.sql b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/README-DEPLOY.sql new file mode 100644 index 0000000..a6acc15 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/README-DEPLOY.sql @@ -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 diff --git a/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyBalances.sql b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyBalances.sql new file mode 100644 index 0000000..c6803d3 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyBalances.sql @@ -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 diff --git a/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyCommissionPool.sql b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyCommissionPool.sql new file mode 100644 index 0000000..458f6a8 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Persistence/StoredProcedures/sp_CalculateWeeklyCommissionPool.sql @@ -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 diff --git a/src/CMSMicroservice.Infrastructure/Services/Commission/CommissionCalculationStrategyFactory.cs b/src/CMSMicroservice.Infrastructure/Services/Commission/CommissionCalculationStrategyFactory.cs new file mode 100644 index 0000000..c32479e --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/Commission/CommissionCalculationStrategyFactory.cs @@ -0,0 +1,63 @@ +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CMSMicroservice.Infrastructure.Services.Commission; + +/// +/// Factory برای ایجاد Strategy مناسب براساس Config +/// +public class CommissionCalculationStrategyFactory : ICommissionCalculationStrategyFactory +{ + private readonly IApplicationDbContext _context; + private readonly IWeekDefinitionRepository _weekRepository; + private readonly IServiceProvider _serviceProvider; + + /// + /// کلید Config برای انتخاب استراتژی + /// مقدار: "ORM" یا "SP" + /// پیش‌فرض: "ORM" + /// + private const string ConfigKey = "Commission.CalculationStrategy"; + + public CommissionCalculationStrategyFactory( + IApplicationDbContext context, + IWeekDefinitionRepository weekRepository, + IServiceProvider serviceProvider) + { + _context = context; + _weekRepository = weekRepository; + _serviceProvider = serviceProvider; + } + + /// + public async Task 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); + } + + /// + public ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType) + { + return strategyType switch + { + CommissionCalculationStrategyType.StoredProcedure => + new StoredProcedureCommissionCalculationStrategy(_context), + + _ => // Orm is default + new OrmCommissionCalculationStrategy(_context, _weekRepository) + }; + } +} diff --git a/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs b/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs new file mode 100644 index 0000000..5e5eb08 --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/Commission/OrmCommissionCalculationStrategy.cs @@ -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; + +/// +/// پیاده‌سازی محاسبه کمیسیون با استفاده از Entity Framework (ORM) +/// این روش برای داده‌های کم مناسب است و دیباگ و تست راحت‌تری دارد +/// +public class OrmCommissionCalculationStrategy : ICommissionCalculationStrategy +{ + private readonly IApplicationDbContext _context; + private readonly IWeekDefinitionRepository _weekRepository; + + public OrmCommissionCalculationStrategy( + IApplicationDbContext context, + IWeekDefinitionRepository weekRepository) + { + _context = context; + _weekRepository = weekRepository; + } + + /// + public async Task 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 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(); + } + + var balancesList = new List(); + 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; + } + + /// + public async Task 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(); + + 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 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 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 CalculateSubordinateBalancesAsync( + long userId, + Dictionary 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> GetSubordinatesRecursive( + long userId, + int currentLevel, + int maxLevel, + CancellationToken cancellationToken) + { + if (currentLevel > maxLevel) + { + return new List(); + } + + var result = new List(); + + 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 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(); + var walletLogs = new List(); + + 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 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 +} diff --git a/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs b/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs new file mode 100644 index 0000000..221537d --- /dev/null +++ b/src/CMSMicroservice.Infrastructure/Services/Commission/StoredProcedureCommissionCalculationStrategy.cs @@ -0,0 +1,121 @@ +using CMSMicroservice.Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace CMSMicroservice.Infrastructure.Services.Commission; + +/// +/// پیاده‌سازی محاسبه کمیسیون با استفاده از Stored Procedure +/// این روش برای داده‌های زیاد بهینه‌تر است و از CTE استفاده می‌کند +/// +public class StoredProcedureCommissionCalculationStrategy : ICommissionCalculationStrategy +{ + private readonly IApplicationDbContext _context; + + public StoredProcedureCommissionCalculationStrategy(IApplicationDbContext context) + { + _context = context; + } + + /// + public async Task 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(); + } + } + + /// + public async Task 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(); + } + } +} diff --git a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj index f53fc43..9deaeeb 100644 --- a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj +++ b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj @@ -3,7 +3,7 @@ net9.0 enable enable - 0.0.155 + 0.0.156 None False False diff --git a/src/CMSMicroservice.WebApi/Common/Mappings/ClubMembershipProfile.cs b/src/CMSMicroservice.WebApi/Common/Mappings/ClubMembershipProfile.cs new file mode 100644 index 0000000..0512568 --- /dev/null +++ b/src/CMSMicroservice.WebApi/Common/Mappings/ClubMembershipProfile.cs @@ -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() + .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() + .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() + .MapWith(src => new GetClubStatisticsQuery()); + + // GetClubStatisticsResponseDto -> GetClubStatisticsResponse + config.NewConfig() + .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) + }; + } +} diff --git a/src/CMSMicroservice.WebApi/Common/Mappings/NetworkMembershipProfile.cs b/src/CMSMicroservice.WebApi/Common/Mappings/NetworkMembershipProfile.cs index edeeb6d..111ef47 100644 --- a/src/CMSMicroservice.WebApi/Common/Mappings/NetworkMembershipProfile.cs +++ b/src/CMSMicroservice.WebApi/Common/Mappings/NetworkMembershipProfile.cs @@ -1,5 +1,6 @@ using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree; using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition; +using CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStatistics; using CMSMicroservice.Protobuf.Protos.NetworkMembership; using CMSMicroservice.Domain.Enums; @@ -9,6 +10,10 @@ public class NetworkMembershipProfile : IRegister { void IRegister.Register(TypeAdapterConfig config) { + // GetNetworkStatisticsRequest -> GetNetworkStatisticsQuery + config.NewConfig() + .MapWith(src => new GetNetworkStatisticsQuery()); + // Request mapping config.NewConfig() .Map(dest => dest.UserId, src => src.UserId)