- Added PersianDateTimeService for converting Gregorian dates to Persian format in the BackOffice frontend. - Updated multiple frontend pages (Dashboard, UserPayouts, WorkerControl, UserNetworkInfo) to utilize the new Persian date service. - Enhanced GetUserNetworkPositionDto with 28+ new fields for comprehensive user network data. - Updated GetUserNetworkPositionQueryHandler to include new methods for calculating network statistics. - Modified Protobuf messages to accommodate the new fields, increasing from 14 to 42. - Refined week number calculation algorithm to ensure consistency across C# and SQL implementations. - Created new CSV and Excel files for binary plan calculations. - Ensured all changes are tested and validated for accuracy and performance.
16 KiB
Balance Calculation with Carryover Logic - Complete Guide
Date: 2025-12-01
Last Updated: 2025-12-09 (✅ اصلاح نهایی: محاسبات تعادل و فلش)
Status: ✅ Fully Implemented & Verified
Migration: UpdateNetworkWeeklyBalanceWithCarryover
✅ آخرین بهروزرسانی (2025-12-09)
تغییرات اعمال شده:
کد محاسبه تعادل با توضیحات دقیق بیزینس تطبیق داده شد:
-
✅ ترتیب محاسبات اصلاح شد:
- اول تعادل اولیه محاسبه میشود
- بعد باقیمانده (برای هفته بعد)
- سپس سقف 300 اعمال میشود
- در نهایت فلش محاسبه میشود
-
✅ فلش از هر دو طرف:
- اگر تعادل > 300 باشد
- از چپ: (تعادل - 300) فلش میشود
- از راست: (تعادل - 300) فلش میشود
- جمع فلش = (تعادل - 300) × 2
-
✅ باقیمانده جداگانه ذخیره میشود:
LeftLegRemainder: باقیمانده دست چپRightLegRemainder: باقیمانده دست راست
📋 قوانین اصلی بیزینس
| توضیح | منطق فعلی (اشتباه) | منطق صحیح |
|---|---|---|
| سقف | 300 کل | 300 برای هر دست |
| حداکثر تعادل | 300 | MIN(300, 300) = 300 |
| حداکثر کل | 300 | 300 + 300 = 600 (مجموع دو دست) |
تفاوت در محاسبه:
منطق فعلی (اشتباه):
totalBalances = MIN(leftTotal, rightTotal)
cappedBalances = MIN(totalBalances, 300) // ← سقف روی کل
منطق صحیح:
cappedLeftTotal = MIN(leftTotal, 300) // ← سقف روی هر دست
cappedRightTotal = MIN(rightTotal, 300)
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)
مثال عملی:
| سناریو | چپ | راست | منطق فعلی | منطق صحیح |
|---|---|---|---|---|
| 1 | 200 | 250 | 200 | 200 |
| 2 | 350 | 400 | 300 ❌ | 300 ✅ |
| 3 | 500 | 600 | 300 ❌ | 300 ✅ |
توجه: در مثالهای بالا نتیجه یکسان است چون حداکثر یک تعادل همیشه MIN(300,300)=300 است. تفاوت در باقیمانده است:
مثال با چپ=500، راست=600:
| روش | تعادل | باقیمانده چپ | باقیمانده راست |
|---|---|---|---|
| فعلی | 300 | 500 - 150 = 350 | 600 - 150 = 450 |
| صحیح | 300 | 200 (500-300) | 300 (600-300) |
تغییرات Configuration:
// ✅ تغییر نام و مقدار:
// قدیمی:
Key = "Commission.MaxWeeklyBalancesPerUser", Value = "300"
// جدید:
Key = "Commission.MaxWeeklyBalancesPerLeg", Value = "300"
📋 Configuration-Based Calculation
System Configurations Used:
// تمام مقادیر از جدول SystemConfigurations خوانده میشوند
Club.ActivationFee = 25,000,000 ریال (هزینه فعالسازی)
Commission.WeeklyPoolContributionPercent = 20% (سهم استخر)
Commission.MaxWeeklyBalancesPerLeg = 300 (✅ سقف امتیاز نهایی)
نکته مهم: سقف 300 روی امتیاز نهایی اعمال میشود، نه روی تعادل اولیه!
Pool Contribution Calculation:
totalNewMembers = leftNewMembers + rightNewMembers
weeklyPoolContribution = totalNewMembers × activationFee × poolPercent
= totalNewMembers × 25,000,000 × 20%
= totalNewMembers × 5,000,000
مثال:
اگر 10 نفر جدید جذب شوند: 10 × 5,000,000 = 50,000,000 ریال به استخر اضافه میشود.
🚫 MaxWeeklyBalances Cap (محدودیت سقف 300)
Logic صحیح (بهروز شده 2025-12-09):
// ✅ مرحله 1: محاسبه تعادل اولیه (بدون سقف)
totalBalances = MIN(leftTotal, rightTotal)
// ✅ مرحله 2: محاسبه باقیمانده برای هفته بعد
leftRemainder = leftTotal - totalBalances
rightRemainder = rightTotal - totalBalances
// ✅ مرحله 3: اعمال سقف 300 (برای امتیاز نهایی)
cappedBalances = MIN(totalBalances, 300)
// ✅ مرحله 4: محاسبه فلش (از هر دو طرف)
flushedPerSide = totalBalances - cappedBalances
totalFlushed = flushedPerSide × 2
Example (مثال کامل):
leftTotal = 500, rightTotal = 600
مرحله 1️⃣: تعادل اولیه
totalBalances = MIN(500, 600) = 500 ✅
مرحله 2️⃣: باقیمانده برای هفته بعد
leftRemainder = 500 - 500 = 0 ✅
rightRemainder = 600 - 500 = 100 ✅
مرحله 3️⃣: اعمال سقف
cappedBalances = MIN(500, 300) = 300 ✅
مرحله 4️⃣: محاسبه فلش
flushedPerSide = 500 - 300 = 200
از چپ: 200 فلش میشود
از راست: 200 فلش میشود
totalFlushed = 200 × 2 = 400 ✅
نتیجه نهایی:
✅ امتیاز این هفته: 300
✅ باقیمانده چپ: 0
✅ باقیمانده راست: 100
✅ جمع فلش: 400 (از بین میرود)
مقایسه منطق قدیم vs جدید:
// ❌ منطق قدیم (اشتباه):
cappedBalances = MIN(totalBalances, 300) // سقف روی کل
balancesConsumedPerSide = cappedBalances / 2
leftRemainder = leftTotal - balancesConsumedPerSide
// ✅ منطق جدید (صحیح):
cappedLeftTotal = MIN(leftTotal, 300) // سقف روی هر دست
cappedRightTotal = MIN(rightTotal, 300)
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)
leftRemainder = leftTotal - cappedLeftTotal // باقیمانده از سقف هر دست
📊 Problem Statement
❌ Previous (Incorrect) Logic:
// محاسبه تعداد کل اعضا در هر پا
## ✅ **Current (Correct) Logic - Updated 2025-12-09:**
### **Formula (4 مرحله):**
// مرحله 1: جمع با هفته قبل leftTotal = leftNewMembers + leftCarryover rightTotal = rightNewMembers + rightCarryover
// مرحله 2: محاسبه تعادل اولیه totalBalances = MIN(leftTotal, rightTotal)
// مرحله 3: محاسبه باقیمانده برای هفته بعد leftRemainder = leftTotal - totalBalances rightRemainder = rightTotal - totalBalances
// مرحله 4: اعمال سقف 300 cappedBalances = MIN(totalBalances, 300) flushedPerSide = totalBalances - cappedBalances totalFlushed = flushedPerSide × 2
### **Key Principles:**
1. **Only count NEW members** activated in current week
2. **Add carryover** from previous week (جداگانه چپ و راست)
3. **Calculate remainder** for next week (قبل از سقف)
4. **Apply cap 300** on final score (بعد از تعادل)
5. **Flush from both sides** if balance > 300
6. **Recursive counting** through entire tree structure
leftTotal = leftNewMembers + leftCarryover
rightTotal = rightNewMembers + rightCarryover
TotalBalances = MIN(leftTotal, rightTotal)
leftRemainder = leftTotal - TotalBalances
rightRemainder = rightTotal - TotalBalances
Key Principles:
- Only count NEW members activated in current week
- Add carryover from previous week
- Calculate remainder for next week
- Recursive counting through entire tree structure
🔢 Example Calculations
Week 1 (2025-W48):
Tree Structure:
User A (Activated this week - 25M to pool)
├─ Left: User B (Activated this week - 25M)
└─ Right: User C (Activated this week - 25M)
Calculations:
User A:
leftNewMembers = 1 (User B activated)
rightNewMembers = 1 (User C activated)
leftCarryover = 0 (first week)
rightCarryover = 0 (first week)
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
leftRemainder = 1 - 1 = 0
rightRemainder = 1 - 1 = 0
User B: TotalBalances = 0 (no children)
User C: TotalBalances = 0 (no children)
Pool Calculation:
Total Pool = 75M (3 activations × 25M)
Total Balances = 1 (only User A)
Value Per Balance = 75M ÷ 1 = 75M
Commission:
User A = 1 × 75M = 75M
Week 2 (2025-W49):
Tree Structure:
User A
├─ Left: User B
│ ├─ Left: User D (NEW - activated this week - 25M)
│ └─ Right: User E (NEW - activated this week - 25M)
└─ Right: User C
├─ Left: User F (NEW - activated this week - 25M)
└─ Right: User G (NEW - activated this week - 25M)
Calculations:
User B:
leftNewMembers = 1 (User D)
rightNewMembers = 1 (User E)
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
User C:
leftNewMembers = 1 (User F)
rightNewMembers = 1 (User G)
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 1 + 0 = 1
TotalBalances = MIN(1, 1) = 1
User A:
leftNewMembers = 2 (D & E through B)
rightNewMembers = 2 (F & G through C)
leftCarryover = 0 (from week 1)
rightCarryover = 0 (from week 1)
leftTotal = 2 + 0 = 2
rightTotal = 2 + 0 = 2
TotalBalances = MIN(2, 2) = 2 ✅
leftRemainder = 2 - 2 = 0
rightRemainder = 2 - 2 = 0
Pool Calculation:
Total Pool = 100M (4 new activations × 25M)
Total Balances = 4 (A=2, B=1, C=1)
Value Per Balance = 100M ÷ 4 = 25M
Commission:
User A = 2 × 25M = 50M ✅ (not 33.33M!)
User B = 1 × 25M = 25M
User C = 1 × 25M = 25M
Week 3 (2025-W50) - With Carryover:
Tree Structure:
User A
├─ Left: User B
│ ├─ Left: User D
│ │ └─ Left: User H (NEW - 25M)
│ └─ Right: User E
└─ Right: User C
├─ Left: User F
└─ Right: User G
Calculations:
User D:
leftNewMembers = 1 (User H)
rightNewMembers = 0
leftCarryover = 0
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
User B:
leftNewMembers = 1 (H through D)
rightNewMembers = 0
leftCarryover = 0 (from week 2)
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
User A:
leftNewMembers = 1 (H through B→D)
rightNewMembers = 0
leftCarryover = 0 (from week 2)
rightCarryover = 0
leftTotal = 1 + 0 = 1
rightTotal = 0 + 0 = 0
TotalBalances = MIN(1, 0) = 0
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
rightRemainder = 0 - 0 = 0
Pool Calculation:
Total Pool = 25M (1 new activation)
Total Balances = 0 (no balanced pairs)
Value Per Balance = N/A
Commission: None this week
Carryover: User A, B, D each have 1 leftRemainder for week 4
🔄 Database Schema
NetworkWeeklyBalance Table:
ALTER TABLE NetworkWeeklyBalances ADD:
-- New members this week
LeftLegNewMembers INT NOT NULL DEFAULT 0,
RightLegNewMembers INT NOT NULL DEFAULT 0,
-- Carryover from previous week
LeftLegCarryover INT NOT NULL DEFAULT 0,
RightLegCarryover INT NOT NULL DEFAULT 0,
-- Totals (new + carryover)
LeftLegTotal INT NOT NULL DEFAULT 0,
RightLegTotal INT NOT NULL DEFAULT 0,
-- Remainder for next week
LeftLegRemainder INT NOT NULL DEFAULT 0,
RightLegRemainder INT NOT NULL DEFAULT 0
Deprecated Fields:
LeftLegBalances(still exists for backward compatibility)RightLegBalances(still exists for backward compatibility)
💻 Implementation
Handler: CalculateWeeklyBalancesCommandHandler.cs
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{
// 1. Load previous week's carryover
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == previousWeekNumber)
.ToDictionaryAsync(x => x.UserId, x => new { x.LeftLegRemainder, x.RightLegRemainder });
// 2. For each user in network
foreach (var user in usersInNetwork)
{
// Get carryover
var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].LeftLegRemainder : 0;
var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].RightLegRemainder : 0;
// Count NEW members (activated in this week)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber);
// Calculate totals
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// Calculate balance (min)
var totalBalances = Math.Min(leftTotal, rightTotal);
// Calculate remainder
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// Save to database
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekNumber = request.WeekNumber,
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = totalBalances,
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
// ...
};
}
}
private async Task<int> CountNewMembersRecursive(long userId, NetworkLeg leg, DateTime startDate, DateTime endDate)
{
var child = await _context.Users
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg);
if (child == null) return 0;
var count = 0;
// Check if activated in this week
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
{
count = 1;
}
// Recursively count children
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate);
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate);
return count + childLeft + childRight;
}
📝 Key Points
- ✅ Only NEW activations count - filtered by
ActivatedAtdate - ✅ Carryover persists - unused balances roll over to next week
- ✅ Recursive counting - includes entire subtree under each leg
- ✅ Week date ranges - ISO 8601 week format (Saturday to Friday)
- ✅ Idempotent - can recalculate with
ForceRecalculateflag
🚀 Benefits
- Fair commission distribution - rewards balanced growth
- No lost balances - carryover ensures nothing is wasted
- Accurate tracking - distinguishes new vs existing members
- Scalable - works for large networks with recursive algorithm
- Auditable - full history of calculations in database
📞 Reference
- Source Code:
CMSMicroservice.Application/CommissionCQ/Commands/CalculateWeeklyBalances/ - Migration:
20251201144400_UpdateNetworkWeeklyBalanceWithCarryover - Entity:
CMSMicroservice.Domain/Entities/Network/NetworkWeeklyBalance.cs - Discussion: Telegram chat with Dr. Seif (2025-12-01)
Status: ✅ Production Ready
Last Updated: 2025-12-01