Files
docs/01-BUSINESS/club-commission-system-complete.md
T
masoodafar-web 002e99f6bf Implement Persian Date Conversion and Enhance User Network Information Service
- 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.
2025-12-20 06:15:59 +03:30

1365 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# سیستم باشگاه مشتریان و کمیسیون شبکه - مستندات کامل و نهایی
**تاریخ آخرین بروزرسانی**: ۲۱ آذر ۱۴۰۴ (2025-12-12)
**وضعیت**: ✅ تکمیل شده و عملیاتی
**نسخه**: 2.0 (بازنگری شده)
---
## 📑 فهرست مطالب
1. [خلاصه اجرایی](#خلاصه-اجرایی)
2. [معماری سیستم](#معماری-سیستم)
3. [فرآیند فعالسازی باشگاه](#فرآیند-فعالسازی-باشگاه)
4. [محاسبات Binary Plan](#محاسبات-binary-plan)
5. [فرآیند محاسبه کمیسیون هفتگی](#فرآیند-محاسبه-کمیسیون-هفتگی)
6. [موجودیت‌های دامین](#موجودیتهای-دامین)
7. [جزئیات پیاده‌سازی](#جزئیات-پیادهسازی)
8. [مثال‌های عملی](#مثالهای-عملی)
---
## خلاصه اجرایی
### هدف سیستم
سیستم باشگاه مشتریان (Club Membership) با پلن شبکه‌ای Binary MLM که امکانات زیر را فراهم می‌کند:
**مدیریت سه نوع کیف پول** برای هر کاربر
**فروشگاه اختصاصی** با تخفیف ویژه اعضای باشگاه
**شبکه باینری** با حداکثر 2 شاخه برای هر کاربر
**محاسبه خودکار کمیسیون** بر اساس تعادل شبکه (هفتگی)
**توزیع عادلانه Pool** بین اعضا بر اساس امتیازات
### تغییرات اساسی نسخه 2.0
| مورد | قبل (v1.0) | بعد (v2.0) |
|------|-----------|-----------|
| **تعداد مراحل محاسبه** | 3 مرحله | 2 مرحله ✅ |
| **منبع Pool هفتگی** | از تعادل‌های کاربران | از فعالسازی باشگاه ✅ |
| **شمول زیرمجموعه** | فقط تعادل شخصی | تعادل + زیرمجموعه (15 لول) ✅ |
| **فیلتر شرکت‌کنندگان** | همه کاربران شبکه | فقط اعضای فعال باشگاه ✅ |
| **ذخیره فلش** | محاسبه در لحظه | ذخیره در دیتابیس ✅ |
---
## معماری سیستم
### 1. کیف پول‌های سه‌گانه
هر کاربر دارای **سه کیف پول مجزا** است:
#### 1️⃣ کیف پول اصلی (`Balance`)
- **کاربری**: خرید از فروشگاه عمومی بازار
- **شارژ**: درگاه پرداخت یا خرید از دایا
- **قابل برداشت**: خیر
#### 2️⃣ کیف پول تخفیف (`DiscountBalance`)
- **کاربری**: خرید از فروشگاه باشگاه مشتریان (با تخفیف ویژه)
- **شارژ**: هنگام فعالسازی عضویت باشگاه
- **محدودیت**: فقط تا سقف درصد تخفیف محصول قابل استفاده
- **قابل برداشت**: خیر
#### 3️⃣ کیف پول طلایی/شبکه (`NetworkBalance`)
- **کاربری**:
- برداشت نقدی
- خرید الماس از دایا
- **شارژ**: دریافت کمیسیون هفتگی از شبکه
- **قابل برداشت**: بله ✅
---
### 2. شبکه باینری (Binary Tree)
```
User A (Root)
/ \
User B (Left) User C (Right)
/ \ / \
User D (L) User E (R) User F (L) User G (R)
```
**قوانین:**
- هر کاربر حداکثر **2 زیرمجموعه مستقیم** دارد
- موقعیت‌ها: `Left` (چپ) یا `Right` (راست)
- عمق شبکه: نامحدود (اما محاسبات فقط تا **15 لول**)
- ریشه شبکه (`NetworkParentId = NULL`): فقط یک نفر
**فیلدهای مرتبط در `User` Entity:**
```csharp
public long? NetworkParentId { get; set; } // پدر در شبکه
public NetworkLeg? LegPosition { get; set; } // چپ یا راست
```
---
## فرآیند فعالسازی باشگاه
### مرحله 1: شارژ کیف پول (پیش‌نیاز)
کاربر **56,000,000 ریال** پرداخت می‌کند:
```csharp
// از طریق درگاه یا دایا
User.Balance += 56_000_000;
User.DiscountBalance += 56_000_000;
```
**نکته**: دو کیف پول همزمان شارژ می‌شوند.
---
### مرحله 2: فعالسازی عضویت (`ActivateClubMembership`)
**ورودی‌ها:**
```csharp
{
"userId": 123,
"networkParentId": 45, // پدر در شبکه
"legPosition": "Left" // چپ یا راست
}
```
**فرآیند اجرایی:**
#### 2.1. اعتبارسنجی
```csharp
User.Balance >= ActivationFee (25,000,000)
NetworkParent وجود دارد
موقعیت انتخابی (Left/Right) خالی است
کاربر قبلاً عضو نیست
```
#### 2.2. کسر از کیف پول
```csharp
User.Balance -= 25_000_000;
```
#### 2.3. ایجاد عضویت باشگاه
```csharp
ClubMembership clubMembership = new()
{
UserId = userId,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 25_000_000,
TotalEarned = 0
};
```
#### 2.4. قرارگیری در شبکه
```csharp
User.NetworkParentId = networkParentId;
User.LegPosition = legPosition; // Left or Right
```
#### 2.5. اضافه به Pool هفتگی ⭐ **جدید در v2.0**
```csharp
// دریافت شماره هفته جاری (فرمت: "2025-W48")
var currentWeekNumber = GetCurrentWeekNumber();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekNumber == currentWeekNumber);
if (weeklyPool == null)
{
// ایجاد Pool جدید برای این هفته
weeklyPool = new WeeklyCommissionPool
{
WeekNumber = currentWeekNumber,
TotalPoolAmount = 25_200_000, // GiftValue
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool);
}
else
{
// اضافه به Pool موجود
weeklyPool.TotalPoolAmount += 25_200_000;
_context.WeeklyCommissionPools.Update(weeklyPool);
}
```
**نکته مهم**:
- کاربر `25,000,000` پرداخت می‌کند
- سیستم `25,200,000` به Pool اضافه می‌کند (Gift از شرکت)
#### 2.6. اختصاص امکانات باشگاه
```csharp
// ۴ فیچر پایه باشگاه
var defaultFeatures = await _context.ClubFeatures
.Where(f => f.IsActive && f.RequiredPoints == null)
.ToListAsync();
foreach (var feature in defaultFeatures)
{
UserClubFeature userFeature = new()
{
UserId = userId,
ClubFeatureId = feature.Id,
GrantedAt = DateTime.Now,
Notes = "فیچر پیش‌فرض عضویت باشگاه"
};
await _context.UserClubFeatures.AddAsync(userFeature);
}
```
---
## محاسبات Binary Plan
### فرمول‌های اصلی
این فرمول‌ها از فایل اکسل کسب‌وکار استخراج شده‌اند:
#### 1️⃣ محاسبه مجموع هر پا
```
LeftTotal = LeftCarryover + LeftNewMembers
RightTotal = RightCarryover + RightNewMembers
```
**مثال:**
```
هفته قبل: چپ=200, راست=0
این هفته: چپ=400, راست=500
LeftTotal = 200 + 400 = 600
RightTotal = 0 + 500 = 500
```
---
#### 2️⃣ محاسبه تعادل اولیه (قبل از سقف)
```
TotalBalances (initial) = MIN(LeftTotal, RightTotal)
```
**مثال:**
```
TotalBalances = MIN(600, 500) = 500
```
**معنی**: کوچکترین پا تعیین‌کننده تعادل است.
---
#### 3️⃣ اعمال سقف (Cap)
```
MaxBalancesPerLeg = 300 (از Config)
CappedBalances = MIN(TotalBalances, MaxBalancesPerLeg)
```
**مثال:**
```
CappedBalances = MIN(500, 300) = 300
```
**معنی**: حداکثر امتیاز قابل دریافت در هر هفته **300** است.
---
#### 4️⃣ محاسبه فلش (Flush - از دست رفته)
```
FlushedPerSide = TotalBalances - CappedBalances
TotalFlushed = FlushedPerSide × 2
```
**مثال:**
```
FlushedPerSide = 500 - 300 = 200
TotalFlushed = 200 × 2 = 400
```
**معنی**:
- از **چپ**: 600 → 300 استفاده شد → **200 فلش**
- از **راست**: 500 → 300 استفاده شد → **200 فلش**
- جمع فلش: **400** (از بین رفت) ❌
---
#### 5️⃣ محاسبه باقیمانده برای هفته بعد
```
LeftRemainder = LeftTotal - TotalBalances
RightRemainder = RightTotal - TotalBalances
```
**مثال:**
```
LeftRemainder = 600 - 500 = 100 ✅ به هفته بعد منتقل می‌شود
RightRemainder = 500 - 500 = 0
```
**نکته**: یکی از دو باقیمانده همیشه **صفر** است.
---
### جدول خلاصه محاسبات (مثال واقعی از Excel)
| متغیر | نماد | مقدار | توضیح |
|-------|------|-------|-------|
| باقیمانده قبل چپ | `LL` | 200 | از هفته قبل |
| باقیمانده قبل راست | `LR` | 0 | از هفته قبل |
| جدید چپ | `NL` | 400 | این هفته |
| جدید راست | `NR` | 500 | این هفته |
| **مجموع چپ** | `SLT` | **600** | LL + NL |
| **مجموع راست** | `SRT` | **500** | LR + NR |
| کمترین | `MinT` | 500 | MIN(SLT, SRT) |
| سقف | `MX` | 300 | از Config |
| **امتیاز نهایی** | `TB` | **300** | MIN(MinT, MX) ✅ |
| فلش چپ | `FL` | 200 | SLT - MX - RNWL |
| فلش راست | `FR` | 200 | SRT - MX - RNWR |
| باقی چپ | `RNWL` | 100 | به هفته بعد |
| باقی راست | `RNWR` | 0 | - |
---
## فرآیند محاسبه کمیسیون هفتگی
### تغییر معماری: 3 مرحله → 2 مرحله
#### ❌ معماری قدیم (v1.0)
```
Step 1: CalculateWeeklyBalances
└─ محاسبه تعادل‌های شخصی
Step 2: CalculateWeeklyCommissionPool
└─ محاسبه Pool از تعادل‌ها ❌ اشتباه بود!
└─ محاسبه تعادل زیرمجموعه (تکراری)
Step 3: ProcessUserPayouts
└─ ایجاد پرداخت‌ها (تکراری)
```
#### ✅ معماری جدید (v2.0)
```
Step 1: CalculateWeeklyBalances
└─ فقط اعضای فعال باشگاه
└─ محاسبه تعادل شخصی (تا 15 لول)
└─ محاسبه تعادل زیرمجموعه (تا 15 لول)
└─ ذخیره فلش
Step 2: CalculateWeeklyCommissionPool
└─ Pool از قبل پُر شده (در ActivateClubMembership)
└─ محاسبه ارزش هر امتیاز
└─ ایجاد UserCommissionPayout
└─ ثبت تاریخچه
```
---
### Step 1: محاسبه تعادل‌های هفتگی (`CalculateWeeklyBalances`)
**ورودی:**
```csharp
{
"weekNumber": "2025-W48",
"forceRecalculate": false
}
```
**فرآیند:**
#### 1.1. فیلتر کاربران شرکت‌کننده
```csharp
// فقط اعضای فعال باشگاه (بدون محدودیت زمانی)
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Select(c => c.UserId)
.ToHashSetAsync();
// دریافت کاربران شبکه که عضو باشگاه هستند
// نکته: شامل ریشه شبکه (NetworkParentId=NULL) هم می‌شود
var usersInNetwork = await _context.Users
.Where(x => activeClubMemberUserIds.Contains(x.Id))
.Select(x => new { x.Id })
.ToListAsync();
```
**چرا فیلتر زمانی نداریم؟**
- همه کسانی که **الان** عضو باشگاه هستند باید کمیسیون بگیرند
- حتی اگر 10 هفته پیش فعال شده باشند
---
#### 1.2. دریافت باقیمانده هفته قبل
```csharp
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
// مثال: "2025-W48" → "2025-W47"
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == previousWeekNumber)
.Select(x => new
{
x.UserId,
x.LeftLegRemainder,
x.RightLegRemainder
})
.ToDictionaryAsync(x => x.UserId);
```
---
#### 1.3. خواندن Config ها
```csharp
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value);
var maxBalancesPerLeg = int.Parse(configs["Commission.MaxWeeklyBalancesPerLeg"]); // 300
var maxNetworkLevel = int.Parse(configs["Commission.MaxNetworkLevel"]); // 15
```
---
#### 1.4. محاسبه برای هر کاربر
```csharp
foreach (var user in usersInNetwork)
{
// 1. دریافت باقیمانده هفته قبل
var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].LeftLegRemainder
: 0;
var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id)
? previousWeekCarryovers[user.Id].RightLegRemainder
: 0;
// 2. شمارش اعضای جدید (تا 15 لول)
var leftNewMembers = await CountNewMembersInLeg(
user.Id, NetworkLeg.Left, weekNumber, maxNetworkLevel);
var rightNewMembers = await CountNewMembersInLeg(
user.Id, NetworkLeg.Right, weekNumber, maxNetworkLevel);
// 3. محاسبه مجموع
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// 4. محاسبه تعادل اولیه
var totalBalances = Math.Min(leftTotal, rightTotal);
// 5. اعمال سقف 300
var cappedBalances = Math.Min(totalBalances, maxBalancesPerLeg);
// 6. محاسبه فلش
var flushedPerSide = totalBalances - cappedBalances;
var totalFlushed = flushedPerSide * 2;
// 7. محاسبه باقیمانده
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// 8. ذخیره در دیتابیس
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekNumber = weekNumber,
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = cappedBalances, // امتیاز نهایی (300)
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
FlushedPerSide = flushedPerSide, // جدید در v2.0
TotalFlushed = totalFlushed, // جدید در v2.0
SubordinateBalances = 0, // محاسبه در مرحله 2
WeeklyPoolContribution = 0,
CalculatedAt = DateTime.Now,
IsExpired = false
};
balancesList.Add(balance);
}
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList);
await _context.SaveChangesAsync();
```
---
#### 1.5. محاسبه تعادل زیرمجموعه (فاز 2)
```csharp
// حالا که همه تعادل‌ها ذخیره شدند، می‌توانیم زیرمجموعه‌ها را محاسبه کنیم
var balancesDictionary = balancesList.ToDictionary(x => x.UserId);
foreach (var balance in balancesList)
{
var subordinateBalances = await CalculateSubordinateBalancesAsync(
balance.UserId,
balancesDictionary,
maxNetworkLevel, // تا 15 لول
cancellationToken
);
balance.SubordinateBalances = subordinateBalances;
}
_context.NetworkWeeklyBalances.UpdateRange(balancesList);
await _context.SaveChangesAsync();
```
**الگوریتم `CalculateSubordinateBalancesAsync`:**
```csharp
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
{
// 1. پیدا کردن همه زیرمجموعه‌ها (تا maxLevel)
var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel);
// 2. جمع تعادل‌های آنها
var totalSubordinateBalances = 0;
foreach (var subordinateId in subordinates)
{
if (allBalances.ContainsKey(subordinateId))
{
totalSubordinateBalances += allBalances[subordinateId].TotalBalances;
}
}
return totalSubordinateBalances;
}
```
**نکته مهم**:
- `SubordinateBalances` فقط برای **گزارش‌گیری** ذخیره می‌شود
- در محاسبه Pool استفاده **نمی‌شود** (چون وقتی همه `TotalBalances` را جمع بزنیم، خودش شامل زیرمجموعه‌ها هم هست)
---
### Step 2: محاسبه Pool و پرداخت‌ها (`CalculateWeeklyCommissionPool`)
**ورودی:**
```csharp
{
"weekNumber": "2025-W48",
"forceRecalculate": false
}
```
**فرآیند:**
#### 2.1. بررسی وجود Pool
```csharp
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber);
if (existingPool == null)
{
throw new InvalidOperationException(
$"Pool هفته {request.WeekNumber} وجود ندارد. " +
"Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد"
);
}
```
**نکته کلیدی**: Pool از قبل توسط `ActivateClubMembership` پُر شده است! ✅
---
#### 2.2. دریافت تعادل‌های محاسبه شده
```csharp
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.ToListAsync();
if (!weeklyBalances.Any())
{
throw new InvalidOperationException(
$"تعادل‌های هفته {request.WeekNumber} هنوز محاسبه نشده است. " +
"ابتدا CalculateWeeklyBalances را اجرا کنید"
);
}
```
---
#### 2.3. محاسبه ارزش هر امتیاز
```csharp
// مجموع کل Pool (از فعالسازی‌های باشگاه)
var totalPoolAmount = existingPool.TotalPoolAmount;
// مجموع کل تعادل‌های شبکه
// نکته: SubordinateBalances اضافه نمی‌کنیم چون وقتی همه TotalBalances را
// جمع بزنیم، خودش شامل تعادل‌های زیرمجموعه‌ها هم هست (تکراری نشود)
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();
```
**مثال عددی:**
```
TotalPoolAmount = 252,000,000 ریال (10 نفر × 25.2M)
TotalBalances = 1,500 امتیاز
ValuePerBalance = 252,000,000 ÷ 1,500 = 168,000 ریال
```
---
#### 2.4. حذف پرداخت‌های قبلی (در صورت ForceRecalculate)
```csharp
if (request.ForceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekNumber == request.WeekNumber)
.ToListAsync();
if (oldPayouts.Any())
{
var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList();
// ⭐ اول تاریخچه‌ها حذف شوند (FK constraint)
var oldHistories = await _context.CommissionPayoutHistories
.Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId))
.ToListAsync();
if (oldHistories.Any())
{
_context.CommissionPayoutHistories.RemoveRange(oldHistories);
}
// بعد پرداخت‌ها
_context.UserCommissionPayouts.RemoveRange(oldPayouts);
await _context.SaveChangesAsync();
}
}
```
---
#### 2.5. ایجاد پرداخت‌ها
```csharp
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,
WeekNumber = request.WeekNumber,
WeeklyPoolId = existingPool.Id,
BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance,
TotalAmount = totalAmount,
Status = CommissionPayoutStatus.Pending,
PaidAt = null,
WithdrawalMethod = null,
IbanNumber = null,
WithdrawnAt = null
};
payouts.Add(payout);
}
await _context.UserCommissionPayouts.AddRangeAsync(payouts);
await _context.SaveChangesAsync();
```
---
#### 2.6. ثبت تاریخچه
```csharp
var historyList = new List<CommissionPayoutHistory>();
foreach (var payout in payouts)
{
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = request.WeekNumber,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
NewStatus = CommissionPayoutStatus.Pending,
Action = CommissionPayoutAction.Created,
PerformedBy = "System",
Reason = "پردازش خودکار کمیسیون هفتگی"
};
historyList.Add(history);
}
await _context.CommissionPayoutHistories.AddRangeAsync(historyList);
await _context.SaveChangesAsync();
```
---
### فرآیند کلی (TriggerWeeklyCalculation)
**Command:**
```csharp
{
"weekNumber": "2025-W48",
"forceRecalculate": false,
"skipBalances": false,
"skipPayouts": false
}
```
**Handler:**
```csharp
// Step 1: محاسبه تعادل‌های هفتگی
if (!request.SkipBalances)
{
await _mediator.Send(new CalculateWeeklyBalancesCommand
{
WeekNumber = request.WeekNumber,
ForceRecalculate = request.ForceRecalculate
});
steps.Add("محاسبه امتیازات هفتگی");
}
// Step 2: محاسبه Pool و پردازش پرداخت‌ها
if (!request.SkipPayouts)
{
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand
{
WeekNumber = request.WeekNumber,
ForceRecalculate = request.ForceRecalculate
});
steps.Add("محاسبه استخر و پرداخت کاربران");
}
```
---
## موجودیت‌های دامین
### 1. `ClubMembership`
```csharp
public class ClubMembership : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
/// <summary>
/// آیا عضویت فعال است؟
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// تاریخ فعال‌سازی عضویت
/// </summary>
public DateTime? ActivatedAt { get; set; }
/// <summary>
/// مبلغ اولیه پرداختی برای فعال‌سازی (25,000,000 ریال)
/// </summary>
public long InitialContribution { get; set; }
/// <summary>
/// مجموع درآمد کارمزد شبکه تاکنون
/// </summary>
public long TotalEarned { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
---
### 2. `NetworkWeeklyBalance`
```csharp
public class NetworkWeeklyBalance : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
/// <summary>
/// شماره هفته (فرمت: "2025-W48")
/// </summary>
public string WeekNumber { get; set; }
// === اطلاعات پای چپ ===
public int LeftLegNewMembers { get; set; } // اعضای جدید این هفته
public int LeftLegCarryover { get; set; } // باقیمانده هفته قبل
public int LeftLegTotal { get; set; } // جمع (جدید + باقیمانده)
public int LeftLegRemainder { get; set; } // باقیمانده برای هفته بعد
// === اطلاعات پای راست ===
public int RightLegNewMembers { get; set; }
public int RightLegCarryover { get; set; }
public int RightLegTotal { get; set; }
public int RightLegRemainder { get; set; }
// === تعادل نهایی ===
/// <summary>
/// امتیاز نهایی بعد از اعمال سقف 300 (CappedBalances)
/// </summary>
public int TotalBalances { get; set; }
/// <summary>
/// مجموع تعادل‌های زیرمجموعه (تا 15 لول)
/// فقط برای گزارش‌گیری - در Pool استفاده نمی‌شود
/// </summary>
public int SubordinateBalances { get; set; }
// === فلش (از دست رفته) ===
/// <summary>
/// مقدار فلش هر طرف (TotalBalances - CappedBalances)
/// </summary>
public int FlushedPerSide { get; set; }
/// <summary>
/// مجموع فلش از دو طرف (FlushedPerSide × 2)
/// </summary>
public int TotalFlushed { get; set; }
// === متا دیتا ===
public long WeeklyPoolContribution { get; set; } // همیشه 0 در v2.0
public DateTime CalculatedAt { get; set; }
public bool IsExpired { get; set; }
// === Deprecated ===
[Obsolete("از LeftLegTotal استفاده کنید")]
public int LeftLegBalances { get; set; }
[Obsolete("از RightLegTotal استفاده کنید")]
public int RightLegBalances { get; set; }
}
```
---
### 3. `WeeklyCommissionPool`
```csharp
public class WeeklyCommissionPool : BaseAuditableEntity
{
/// <summary>
/// شماره هفته (فرمت: "2025-W48")
/// </summary>
public string WeekNumber { get; set; }
/// <summary>
/// مجموع مبلغ Pool (از فعالسازی‌های باشگاه)
/// </summary>
public long TotalPoolAmount { get; set; }
/// <summary>
/// مجموع تعادل‌های کل شبکه
/// </summary>
public int TotalBalances { get; set; }
/// <summary>
/// ارزش ریالی هر امتیاز (TotalPoolAmount ÷ TotalBalances)
/// </summary>
public long ValuePerBalance { get; set; }
/// <summary>
/// آیا Pool محاسبه و توزیع شده است؟
/// </summary>
public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; }
public virtual ICollection<UserCommissionPayout> Payouts { get; set; }
}
```
---
### 4. `UserCommissionPayout`
```csharp
public class UserCommissionPayout : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public string WeekNumber { get; set; }
public long WeeklyPoolId { get; set; }
public virtual WeeklyCommissionPool WeeklyPool { get; set; }
/// <summary>
/// تعداد امتیازهای کسب شده (تعادل شخصی)
/// </summary>
public int BalancesEarned { get; set; }
/// <summary>
/// ارزش ریالی هر امتیاز
/// </summary>
public long ValuePerBalance { get; set; }
/// <summary>
/// مبلغ کل کمیسیون (BalancesEarned × ValuePerBalance)
/// </summary>
public long TotalAmount { get; set; }
/// <summary>
/// وضعیت: Pending, Approved, Paid, Rejected
/// </summary>
public CommissionPayoutStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
public string? WithdrawalMethod { get; set; }
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
public virtual ICollection<CommissionPayoutHistory> Histories { get; set; }
}
```
---
### 5. `CommissionPayoutHistory`
```csharp
public class CommissionPayoutHistory : BaseAuditableEntity
{
public long UserCommissionPayoutId { get; set; }
public virtual UserCommissionPayout UserCommissionPayout { get; set; }
public long UserId { get; set; }
public virtual User User { get; set; }
public string WeekNumber { get; set; }
public long AmountBefore { get; set; }
public long AmountAfter { get; set; }
public CommissionPayoutStatus OldStatus { get; set; }
public CommissionPayoutStatus NewStatus { get; set; }
public CommissionPayoutAction Action { get; set; }
public string PerformedBy { get; set; } // UserId یا "System"
public string? Reason { get; set; }
}
```
---
## جزئیات پیاده‌سازی
### محاسبه شماره هفته (Week Number)
```csharp
/// <summary>
/// محاسبه شماره هفته جاری (شنبه محور)
/// فرمت: "YYYY-Www" (مثال: "2025-W48")
/// </summary>
private string GetCurrentWeekNumber()
{
var now = DateTime.Now;
var culture = new CultureInfo("fa-IR");
var calendar = culture.Calendar;
var year = calendar.GetYear(now);
var jan1 = new DateTime(year, 1, 1);
// محاسبه اولین شنبه سال
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek;
if (daysOffset < 0) daysOffset += 7;
var firstSaturday = jan1.AddDays(daysOffset);
// محاسبه تعداد روزهای گذشته از اولین شنبه
var daysSinceFirstSaturday = (now - firstSaturday).Days;
// محاسبه شماره هفته
var weekNumber = (daysSinceFirstSaturday / 7) + 1;
return $"{year}-W{weekNumber:D2}";
}
```
**نکته**: هفته از **شنبه** شروع می‌شود (تقویم ایرانی).
---
### شمارش بازگشتی اعضای جدید
```csharp
/// <summary>
/// شمارش اعضای جدیدی که در یک هفته مشخص به یک پا اضافه شدند
/// </summary>
private async Task<int> CountNewMembersInLeg(
long userId,
NetworkLeg leg,
string weekNumber,
int maxLevel,
CancellationToken cancellationToken)
{
var (startDate, endDate) = GetWeekDateRange(weekNumber);
return await CountNewMembersRecursive(
userId, leg, startDate, endDate,
currentLevel: 0,
maxLevel: maxLevel,
cancellationToken
);
}
private async Task<int> CountNewMembersRecursive(
long userId,
NetworkLeg leg,
DateTime startDate,
DateTime endDate,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
// محدودیت عمق: تا 15 لول
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;
}
```
---
### تبدیل WeekNumber به تاریخ
```csharp
/// <summary>
/// تبدیل شماره هفته به بازه تاریخی (شنبه تا جمعه)
/// </summary>
private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
{
// Parse: "2025-W48"
var parts = weekNumber.Split('-');
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
// محاسبه اولین شنبه سال
var jan1 = new DateTime(year, 1, 1);
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek;
if (daysOffset < 0) daysOffset += 7;
var firstSaturday = jan1.AddDays(daysOffset);
// محاسبه شنبه این هفته
var weekStart = firstSaturday.AddDays((week - 1) * 7);
// جمعه همان هفته (23:59:59)
var weekEnd = weekStart.AddDays(6)
.AddHours(23)
.AddMinutes(59)
.AddSeconds(59);
return (weekStart, weekEnd);
}
```
---
## مثال‌های عملی
### مثال 1: فعالسازی ساده
**وضعیت اولیه:**
```
User A (Root)
└─ خالی
```
**فعالسازی User B:**
```csharp
Request:
{
"userId": 2, // User B
"networkParentId": 1, // User A
"legPosition": "Left"
}
Result:
User B عضو باشگاه شد
25M از Balance کسر شد
25.2M به Pool هفته جاری اضافه شد
User B زیر User A قرار گرفت (چپ)
```
**ساختار شبکه بعد:**
```
User A (Root)
├─ User B (Left) ✅
└─ خالی (Right)
```
---
### مثال 2: محاسبه تعادل
**ساختار شبکه:**
```
User A
├─ Left: User B, User C (2 نفر)
└─ Right: User D (1 نفر)
```
**محاسبات User A:**
```
LeftTotal = 2 (عضو جدید این هفته)
RightTotal = 1
TotalBalances (initial) = MIN(2, 1) = 1
CappedBalances = MIN(1, 300) = 1
FlushedPerSide = 1 - 1 = 0
TotalFlushed = 0 × 2 = 0
LeftRemainder = 2 - 1 = 1 ✅ به هفته بعد
RightRemainder = 1 - 1 = 0
Result:
امتیاز این هفته: 1
باقیمانده چپ: 1
```
---
### مثال 3: توزیع Pool
**فرض:**
- Pool هفته: `252,000,000` ریال (10 فعالسازی × 25.2M)
- مجموع تعادل‌های شبکه: `1,500` امتیاز
**محاسبات:**
```
ValuePerBalance = 252,000,000 ÷ 1,500 = 168,000 ریال/امتیاز
```
**کاربران:**
| کاربر | امتیاز شخصی | کمیسیون |
|-------|-------------|---------|
| User A | 300 | 300 × 168,000 = **50,400,000** ریال |
| User B | 150 | 150 × 168,000 = **25,200,000** ریال |
| User C | 50 | 50 × 168,000 = **8,400,000** ریال |
| **جمع** | **1,500** | **252,000,000** ریال ✅ |
---
### مثال 4: فلش (Overflow)
**User X:**
```
هفته قبل:
چپ = 250 باقیمانده
راست = 0
این هفته:
چپ = 400 عضو جدید
راست = 500 عضو جدید
محاسبات:
LeftTotal = 250 + 400 = 650
RightTotal = 0 + 500 = 500
TotalBalances (initial) = MIN(650, 500) = 500
CappedBalances = MIN(500, 300) = 300 ⭐
FlushedPerSide = 500 - 300 = 200
TotalFlushed = 200 × 2 = 400 ❌ (از دست رفت)
LeftRemainder = 650 - 500 = 150 ✅
RightRemainder = 500 - 500 = 0
Result:
امتیاز این هفته: 300
فلش: 400 (از بین رفت)
باقیمانده چپ: 150 (به هفته بعد)
```
**نکته**: حتی با 650 چپ و 500 راست، فقط **300 امتیاز** می‌گیرد (سقف).
---
## Configuration های سیستم
### جدول تنظیمات
| Key | Value | توضیحات |
|-----|-------|---------|
| `Club.ActivationFee` | `25000000` | هزینه فعالسازی باشگاه (25M ریال) |
| `Club.GiftValue` | `25200000` | مبلغ اضافه به Pool (25.2M ریال) |
| `Club.InitialBalance` | `56000000` | شارژ اولیه کیف پول (56M ریال) |
| `Commission.MaxWeeklyBalancesPerLeg` | `300` | سقف امتیاز هر پا |
| `Commission.MaxNetworkLevel` | `15` | حداکثر عمق شبکه برای محاسبات |
---
## Migration های ایجاد شده
### 1. `AddFlushedFieldsToNetworkWeeklyBalance`
```csharp
migrationBuilder.AddColumn<int>(
name: "FlushedPerSide",
schema: "Commission",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "TotalFlushed",
schema: "Commission",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
```
### 2. `AddSubordinateBalancesToNetworkWeeklyBalance`
```csharp
migrationBuilder.AddColumn<int>(
name: "SubordinateBalances",
schema: "Commission",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
```
---
## نکات مهم و Best Practices
### ✅ Do's
1. **همیشه Transaction استفاده کنید** برای عملیات چند مرحله‌ای
2. **ForceRecalculate با احتیاط** استفاده شود (حذف داده)
3. **Week Number** را از سیستم محاسبه کنید (نه دستی)
4. **فیلتر اعضای باشگاه** را فراموش نکنید
5. **FK Constraint** را رعایت کنید (History قبل از Payout حذف شود)
### ❌ Don'ts
1. **Pool را دستی پُر نکنید** (باید از ActivateClubMembership بیاید)
2. **SubordinateBalances را در Pool استفاده نکنید** (تکراری است)
3. **شرط `NetworkParentId.HasValue` نگذارید** (ریشه شبکه حذف می‌شود)
4. **محاسبات را بدون Lock اجرا نکنید** (امکان Race Condition)
---
## خلاصه فرآیند نهایی
```
1. کاربر شارژ می‌کند (56M)
├─ Balance += 56M
└─ DiscountBalance += 56M
2. کاربر «عضو باشگاه» می‌شود
├─ Balance -= 25M
├─ Pool += 25.2M ⭐
├─ قرار گرفتن در شبکه
└─ دریافت 4 امکان پایه
3. هر هفته: CalculateWeeklyBalances
├─ فقط اعضای فعال باشگاه
├─ محاسبه تعادل (تا 15 لول)
├─ محاسبه زیرمجموعه (تا 15 لول)
└─ ذخیره فلش
4. هر هفته: CalculateWeeklyCommissionPool
├─ Pool از قبل پُر شده
├─ ارزش هر امتیاز = Pool ÷ مجموع تعادل‌ها
├─ ایجاد UserCommissionPayout
└─ ثبت تاریخچه
5. کاربر درخواست برداشت
└─ NetworkBalance → حساب بانکی
```
---
## تاریخچه تغییرات
| تاریخ | نسخه | تغییرات |
|-------|------|---------|
| 2025-12-04 | 1.0 | نسخه اولیه سیستم |
| 2025-12-10 | 1.5 | اصلاح Pool (از فعالسازی) |
| 2025-12-12 | 2.0 | ساده‌سازی به 2 مرحله + فیلتر باشگاه |
---
**پایان مستندات** 🎯