Files
docs/business/club-commission-system-complete.md
T

2276 lines
67 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 مرحله + فیلتر باشگاه |
---
**پایان مستندات** 🎯
---
# ضمیمه: مشخصات اصلی سیستم (نسخه قبلی)
# سیستم باشگاه مشتریان و محاسبه کمیسیون شبکه
## خلاصه اجرایی
این سند تحلیل جامع و معماری پیشنهادی برای پیاده‌سازی سیستم باشگاه مشتریان (Club Membership) و محاسبه کمیسیون شبکه‌ای (MLM Binary Plan) را ارائه می‌دهد. این سیستم امکان مدیریت سه نوع کیف پول، فروشگاه اختصاصی با تخفیف، و توزیع عادلانه کمیسیون بر اساس تعادل شبکه را فراهم می‌کند.
---
## ۱. مفاهیم کلیدی
### ۱.۱ کیف پول‌های سه‌گانه
هر کاربر سه نوع کیف پول دارد:
1. **کیف پول اصلی (Balance)**: برای خرید از فروشگاه عمومی بازار
2. **کیف پول تخفیف (DiscountBalance)**: فقط برای خرید از فروشگاه باشگاه مشتریان (محدود به درصد تخفیف محصولات)
3. **کیف پول طلایی/کارمزد (NetworkBalance)**: دریافتی از کمیسیون شبکه‌ای - قابل برداشت نقدی یا خرید الماس از دایا
### ۱.۲ فعال‌سازی عضویت
- کاربر ۵۶ میلیون تومان پرداخت می‌کند (از طریق دایا یا درگاه)
- سیستم به صورت خودکار:
- `Balance += 56M` (کیف پول اصلی)
- `DiscountBalance += 56M` (کیف پول تخفیف)
- کاربر دکمه «عضویت در باشگاه» را می‌زند:
- `25M` به استخر کمیسیون هفتگی اضافه می‌شود
- کاربر در شبکه باینری (Binary Tree) قرار می‌گیرد
### ۱.۳ شبکه باینری (Binary MLM Plan)
- هر کاربر حداکثر دو زیرمجموعه دارد: **دست راست** و **دست چپ**
- تعادل (Balance): زمانی که هر دو شاخه دارای اعضای جدید شوند، یک تعادل ایجاد می‌شود
- **فرمول تعادل**: `UserBalances = MIN(LeftLegBalances, RightLegBalances)`
- تعادل‌ها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست می‌شوند
### ۱.۴ محاسبه کمیسیون هفتگی
```text
مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادل‌های کل سیستم)
کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز)
```
**مثال**:
- کاربر A: خودش ۱ تعادل + زیرمجموعه‌هایش ۲ تعادل = **۳ امتیاز**
- استخر هفتگی: `175M`
- مجموع امتیازهای سیستم: `5`
- ارزش هر امتیاز: `175M ÷ 5 = 35M`
- کمیسیون کاربر A: `3 × 35M = 105M`
---
## ۲. موجودیت‌های جدید (Domain Entities)
### ۲.۱ `ClubMembership` (عضویت باشگاه مشتریان)
```csharp
public class ClubMembership : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
// مبلغ اولیه پرداختی برای فعال‌سازی (معمولاً ۲۵ میلیون)
public long InitialContribution { get; set; }
// مجموع درآمد کارمزد تاکنون
public long TotalEarned { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۲.۲ `ClubFeature` (امکانات باشگاه)
```csharp
public class ClubFeature : BaseAuditableEntity
{
public string Title { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
public int? RequiredPoints { get; set; }
public int SortOrder { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۲.۳ `UserClubFeature` (امتیاز/فیچرهای فعال برای کاربر)
```csharp
public class UserClubFeature : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
public long ClubFeatureId { get; set; }
public virtual ClubFeature ClubFeature { get; set; }
public DateTime GrantedAt { get; set; }
public string? Notes { get; set; }
}
```
### ۲.۴ `NetworkWeeklyBalance` (تعادل هفتگی شبکه)
```csharp
public class NetworkWeeklyBalance : BaseAuditableEntity
{
public long UserId { get; set; }
public virtual User User { get; set; }
// مثلاً "2025-W48"
public string WeekNumber { get; set; }
public int LeftLegBalances { get; set; }
public int RightLegBalances { get; set; }
public int TotalBalances { get; set; }
// مبلغی که این کاربر همان هفته به استخر اضافه کرده (معمولاً InitialContribution)
public long WeeklyPoolContribution { get; set; }
public DateTime? CalculatedAt { get; set; }
public bool IsExpired { get; set; }
}
```
### ۲.۵ `WeeklyCommissionPool` (استخر کمیسیون هفتگی)
```csharp
public class WeeklyCommissionPool : BaseAuditableEntity
{
public string WeekNumber { get; set; }
public long TotalPoolAmount { get; set; }
public int TotalBalances { get; set; }
public long ValuePerBalance { get; set; }
public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; }
public virtual ICollection<UserCommissionPayout> UserCommissionPayouts { get; set; }
}
```
### ۲.۶ `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; }
public int BalancesEarned { get; set; }
public long ValuePerBalance { get; set; }
public long TotalAmount { get; set; }
public CommissionPayoutStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
public WithdrawalMethod? WithdrawalMethod { get; set; }
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
}
```
---
### ۲.۷ موجودیت‌های History (جداول لاگ)
#### ۲.۷.۱ `ClubMembershipHistory`
لاگ تغییرات مهم روی عضویت باشگاه (فعال‌سازی، غیرفعال‌سازی، ویرایش):
```csharp
public class ClubMembershipHistory : BaseAuditableEntity
{
public long ClubMembershipId { get; set; }
public long UserId { get; set; }
public bool OldIsActive { get; set; }
public bool NewIsActive { get; set; }
public long? OldInitialContribution { get; set; }
public long? NewInitialContribution { get; set; }
// Activated / Deactivated / Updated / ManualFix
public string Action { get; set; }
public string? Reason { get; set; }
}
```
#### ۲.۷.۲ `NetworkMembershipHistory`
برای اینکه همیشه بدانیم «چه کسی زیرمجموعه‌ی کی شده، چه زمانی، و اگر بعداً جابه‌جا شد چه اتفاقی افتاده»:
```csharp
public class NetworkMembershipHistory : BaseAuditableEntity
{
public long UserId { get; set; }
public long? OldParentId { get; set; }
public long? NewParentId { get; set; }
public NetworkLeg? OldLegPosition { get; set; }
public NetworkLeg? NewLegPosition { get; set; }
// Join / Move / Remove
public string Action { get; set; }
public string? Reason { get; set; }
}
```
- هر بار `RecordNetworkJoin` یا `UpdateNetworkPosition` صدا زده می‌شود، باید یک رکورد در این جدول نوشته شود.
- این جدول مرجع اصلی برای بازسازی درخت شبکه در زمان‌های گذشته است.
#### ۲.۷.۳ `CommissionPayoutHistory`
برای لاگ کامل همه‌ی تغییرات روی پرداخت کمیسیون‌ها (ایجاد، ویرایش دستی، تغییر وضعیت، برداشت و ...):
```csharp
public class CommissionPayoutHistory : BaseAuditableEntity
{
public long UserCommissionPayoutId { get; set; }
public long UserId { 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; }
// Created / Paid / WithdrawRequested / Withdrawn / Cancelled / ManualFix
public string Action { get; set; }
public string? PerformedBy { get; set; } // UserId یا System
public string? Reason { get; set; }
}
```
- اگر بعداً بفهمیم یک پرداخت اشتباه بوده و اصلاحش کنیم، اینجا قابل ردیابی است.
- برای گزارش‌گیری Audit کامل پرداخت‌ها، این جدول استفاده می‌شود.
#### ۲.۷.۴ `SystemConfigurationHistory`
تاریخچه تغییرات تنظیمات (Config) برای این‌که بعداً بدانیم در هر زمان چه محدودیتی فعال بوده:
```csharp
public class SystemConfigurationHistory : BaseAuditableEntity
{
public long ConfigurationId { get; set; }
public ConfigurationScope Scope { get; set; }
public string Key { get; set; }
public string OldValue { get; set; }
public string NewValue { get; set; }
public string? Reason { get; set; }
}
```
---
### ۲.۸ موجودیت‌های Configuration (تنظیمات پویا)
#### ۲.۸.۱ `ConfigurationScope` (Enum)
```csharp
public enum ConfigurationScope
{
System = 0,
Network = 1,
Club = 2,
Commission = 3
}
```
#### ۲.۸.۲ `SystemConfiguration`
جدولی برای نگهداری تنظیمات پویا. هم تنظیمات عمومی سیستم، هم تنظیمات مخصوص شبکه، باشگاه و کمیسیون:
```csharp
public class SystemConfiguration : BaseAuditableEntity
{
public ConfigurationScope Scope { get; set; } // System / Network / Club / Commission
// مثل: "MaxWeeklyBalancesPerUser", "MinContributionAmount", ...
public string Key { get; set; }
// مقدار به‌صورت رشته - تفسیر در لایه Application
public string Value { get; set; }
// برای UI و Validation (Int / Decimal / Bool / String / Json)
public string? DataType { get; set; }
public string? Description { get; set; }
public bool IsActive { get; set; }
}
```
**مثال کانفیگ‌های مرتبط با شبکه:**
- `Scope = Network`, `Key = "MaxWeeklyBalancesPerUser"`, `Value = "300"`
- `Scope = Network`, `Key = "MaxChildrenPerLeg"`, `Value = "1"`
- `Scope = Commission`, `Key = "DefaultInitialContribution"`, `Value = "25000000"`
> نکته: هر بار که مقدار `SystemConfiguration` تغییر می‌کند، یک رکورد در `SystemConfigurationHistory` ثبت می‌شود تا تنظیمات گذشته قابل ردیابی باشد.
---
### ۲.۹ Enums جدید
```csharp
public enum CommissionPayoutStatus
{
Pending = 0,
Paid = 1,
WithdrawRequested = 2,
Withdrawn = 3,
Cancelled = 4
}
public enum WithdrawalMethod
{
Cash = 0,
Diamond = 1
}
public enum NetworkLeg
{
Left = 0,
Right = 1
}
```
---
## ۳. تغییرات در موجودیت‌های موجود
### ۳.۱ `User`
افزودن فیلدهای مربوط به شبکه باینری و ناوبری:
```csharp
public class User : BaseAuditableEntity
{
// ...
public long? NetworkParentId { get; set; }
public virtual User? NetworkParent { get; set; }
public NetworkLeg? LegPosition { get; set; }
public virtual ICollection<User> NetworkChildren { get; set; }
public virtual ClubMembership? ClubMembership { get; set; }
public virtual ICollection<NetworkWeeklyBalance> NetworkWeeklyBalances { get; set; }
public virtual ICollection<UserCommissionPayout> CommissionPayouts { get; set; }
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
}
```
### ۳.۲ `UserWallet`
```csharp
public class UserWallet : BaseAuditableEntity
{
// موجودی ریالی اصلی
public long Balance { get; set; }
// موجودی شبکه/کارمزد (کیف پول طلایی)
public long NetworkBalance { get; set; }
// موجودی تخفیف (فقط برای خرید از فروشگاه باشگاه)
public long DiscountBalance { get; set; }
// ...
}
```
### ۳.۳ `Products`
```csharp
public class Product : BaseAuditableEntity
{
// ...
// آیا این محصول فقط در فروشگاه باشگاه موجود است
public bool IsClubExclusive { get; set; }
// درصد تخفیف باشگاه (0 تا 100)
public int ClubDiscountPercent { get; set; }
// ...
}
```
### ۳.۴ `UserWalletChangeLog`
افزودن نوع جدید تراکنش:
```csharp
public enum TransactionType
{
// ...
NetworkCommission = 10, // دریافت کمیسیون شبکه
ClubActivation = 11, // فعال‌سازی عضویت باشگاه
DiscountWalletCharge = 12, // شارژ کیف پول تخفیف
}
```
---
## ۴. معماری ماژول‌های جدید (Application / CQRS)
### ۴.۱ `ClubMembershipCQ/`
#### Commands
- **ActivateClubMembership**: فعال‌سازی عضویت باشگاه (کسر ۲۵ میلیون و اضافه به استخر)
- **DeactivateClubMembership**: غیرفعال‌سازی عضویت
- **UpdateClubMembership**: به‌روزرسانی اطلاعات عضویت
#### Queries
- **GetUserClubStatus**: دریافت وضعیت عضویت کاربر
- **GetAllClubMembersByFilter**: لیست اعضای باشگاه با فیلتر
### ۴.۲ `ClubFeatureCQ/`
#### Commands
- **CreateClubFeature**: ایجاد فیچر جدید
- **UpdateClubFeature**: ویرایش فیچر
- **DeleteClubFeature**: حذف فیچر
- **GrantFeatureToUser**: فعال‌سازی فیچر برای کاربر
- **RevokeFeatureFromUser**: غیرفعال‌سازی فیچر از کاربر
#### Queries
- **GetAllClubFeatures**: لیست تمام فیچرها
- **GetUserClubFeatures**: لیست فیچرهای فعال یک کاربر
### ۴.۳ `NetworkBalanceCQ/`
#### Commands
- **RecordNetworkJoin**: ثبت ورود کاربر به شبکه باینری (تعیین والد و شاخه)
- حتماً باید یک رکورد در `NetworkMembershipHistory` ایجاد کند.
- **UpdateNetworkPosition**: تغییر موقعیت در شبکه (مدیریتی)
- هر تغییر، یک رکورد History.
- **CalculateWeeklyBalances**: محاسبه تعادل‌های هفتگی (فراخوانی از Worker)
#### Queries
- **GetUserNetworkTree**: دریافت درخت زیرمجموعه‌های کاربر (چند سطح)
- **GetUserWeeklyBalances**: دریافت تعادل‌های هفتگی یک کاربر
- **GetNetworkStatistics**: آمار کلی شبکه (تعداد اعضا، عمق، تعادل)
### ۴.۴ `CommissionPoolCQ/`
#### Commands
- **InitializeWeeklyPool**: ایجاد استخر جدید برای هفته
- **AddToWeeklyPool**: افزودن مبلغ به استخر هفتگی (هنگام فعال‌سازی عضویت)
- **CalculatePoolValue**: محاسبه ارزش هر امتیاز
- **DistributeCommissions**: توزیع کمیسیون‌ها به کاربران (Worker)
- **CloseWeeklyPool**: بستن استخر پس از توزیع
#### Queries
- **GetCurrentWeekPool**: دریافت اطلاعات استخر هفته جاری
- **GetPoolHistory**: تاریخچه استخرهای قبلی با فیلتر
### ۴.۵ `CommissionPayoutCQ/`
#### Commands
- **CreatePayoutRecord**: ثبت پرداخت کمیسیون (اتوماتیک از Worker)
- همراه با ایجاد رکورد در `CommissionPayoutHistory` (Action = Created).
- **RequestWithdrawal**: درخواست برداشت کمیسیون (نقدی یا الماس)
- History با Action = WithdrawRequested.
- **ProcessWithdrawal**: پردازش درخواست برداشت (تایید/رد ادمین)
- تغییر Status + History.
- **CancelPayout**: لغو پرداخت
#### Queries
- **GetUserCommissionHistory**: تاریخچه کمیسیون‌های دریافتی کاربر
- **GetPendingWithdrawals**: لیست درخواست‌های برداشت در انتظار (برای ادمین)
- **GetCommissionSummary**: خلاصه درآمد کمیسیون (مجموع، ماهانه، سالانه)
### ۴.۶ `ConfigurationCQ/`
#### Commands
- **SetConfigurationValue**: ثبت/ویرایش یک تنظیم (SystemConfiguration)
- هر تغییر باید در `SystemConfigurationHistory` ثبت شود.
- **DeactivateConfiguration**: غیرفعال‌سازی یک تنظیم
#### Queries
- **GetConfigurationValue**: دریافت مقدار یک Key
- **GetConfigurationByScope**: لیست تنظیمات یک Scope (مثلاً Network)
---
## ۵. Background Worker/Job (محاسبات هفتگی)
### ۵.۱ `WeeklyNetworkCommissionWorker`
**زمان‌بندی**: هر یکشنبه ساعت ۲۳:۵۹ (یا دوشنبه ۰۰:۰۱)
**مراحل اجرایی (High-level):**
#### گام ۱: بستن هفته قبل و ایجاد استخر جدید
```csharp
var currentWeek = GetCurrentWeekNumber(); // مثلاً "2025-W48"
var previousWeek = GetPreviousWeekNumber();
await CloseWeeklyPool(previousWeek);
await InitializeWeeklyPool(currentWeek);
```
#### گام ۲: محاسبه تعادل‌های شبکه
```csharp
var maxBalancesPerUser = GetConfig<int>("MaxWeeklyBalancesPerUser", scope: ConfigurationScope.Network);
var activeMembers = await GetActiveClubMembers();
foreach (var member in activeMembers)
{
var leftBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Left, previousWeek);
var rightBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Right, previousWeek);
var totalBalances = Math.Min(leftBalances, rightBalances);
// اعمال محدودیت کانفیگ (مثلاً حداکثر 300 تعادل برای هر کاربر)
if (totalBalances > maxBalancesPerUser)
totalBalances = maxBalancesPerUser;
await RecordWeeklyBalance(new NetworkWeeklyBalance {
UserId = member.UserId,
WeekNumber = previousWeek,
LeftLegBalances = leftBalances,
RightLegBalances = rightBalances,
TotalBalances = totalBalances,
WeeklyPoolContribution = member.InitialContribution,
CalculatedAt = DateTime.UtcNow
});
}
```
#### الگوریتم بازگشتی محاسبه تعادل شاخه
```csharp
private async Task<int> CalculateLegBalances(long userId, NetworkLeg leg, string weekNumber)
{
var children = await GetNetworkChildren(userId, leg);
int totalBalances = 0;
foreach (var child in children)
{
var childMembership = await GetClubMembership(child.Id);
if (childMembership != null && IsInWeek(childMembership.ActivatedAt, weekNumber))
{
totalBalances++;
}
var childLeftBalances = await CalculateLegBalances(child.Id, NetworkLeg.Left, weekNumber);
var childRightBalances = await CalculateLegBalances(child.Id, NetworkLeg.Right, weekNumber);
totalBalances += Math.Min(childLeftBalances, childRightBalances);
}
return totalBalances;
}
```
#### گام ۳: محاسبه استخر و ارزش امتیاز
```csharp
var totalPoolAmount = await SumPoolContributions(previousWeek);
var totalBalances = await SumTotalBalances(previousWeek);
var valuePerBalance = totalBalances > 0 ? totalPoolAmount / totalBalances : 0;
await UpdatePoolValue(previousWeek, totalPoolAmount, totalBalances, valuePerBalance);
```
#### گام ۴: توزیع کمیسیون‌ها
```csharp
var weeklyBalances = await GetWeeklyBalances(previousWeek);
foreach (var balance in weeklyBalances.Where(b => b.TotalBalances > 0))
{
var payoutAmount = balance.TotalBalances * valuePerBalance;
var payout = new UserCommissionPayout {
UserId = balance.UserId,
WeekNumber = previousWeek,
BalancesEarned = balance.TotalBalances,
ValuePerBalance = valuePerBalance,
TotalAmount = payoutAmount,
Status = CommissionPayoutStatus.Pending
};
await CreatePayoutRecord(payout); // داخلش CommissionPayoutHistory هم ثبت می‌شود
await AddToNetworkBalance(balance.UserId, payoutAmount);
await RecordWalletChange(new UserWalletChangeLog {
WalletId = balance.UserId,
// PreviousBalance / AfterBalance پر می‌شود
Amount = payoutAmount,
TransactionType = TransactionType.NetworkCommission,
ReferenceId = payout.Id.ToString()
});
payout.Status = CommissionPayoutStatus.Paid;
payout.PaidAt = DateTime.UtcNow;
await UpdatePayout(payout);
await AddCommissionHistory(payout, "Paid");
}
```
#### گام ۵: ریست تعادل‌ها
```csharp
await ExpireWeeklyBalances(previousWeek);
```
---
## ۶. لاجیک فروشگاه و سبد خرید
### ۶.۱ نمایش محصولات
```csharp
var query = _context.Products.Where(p => !p.IsDeleted);
if (!user.ClubMembership?.IsActive ?? true)
{
query = query.Where(p => !p.IsClubExclusive);
}
// اگر کاربر عضو است، قیمت با تخفیف باشگاه محاسبه می‌شود
```
### ۶.۲ استفاده از کیف پول تخفیف در Checkout
(خلاصه‌سازی شده – در کد اصلی از DiscountBalance استفاده می‌شود و ChangeLog ثبت می‌گردد.)
---
## ۷. سناریوی کامل فعال‌سازی عضویت
### مرحله ۱: شارژ اولیه
```text
کاربر → پرداخت ۵۶ میلیون (دایا/درگاه)
UserWallet.Balance += 56,000,000
UserWallet.DiscountBalance += 56,000,000
```
### مرحله ۲: فعال‌سازی عضویت
```text
کاربر → کلیک روی دکمه «عضویت در باشگاه»
API: ActivateClubMembership
1. ایجاد رکورد ClubMembership:
- IsActive = true
- InitialContribution = 25,000,000
2. افزودن به استخر هفتگی:
- WeeklyCommissionPool.TotalPoolAmount += 25,000,000
3. تعیین موقعیت در شبکه:
- User.NetworkParentId = والد
- User.LegPosition = Left یا Right
4. ثبت ChangeLog برای استخر:
- TransactionType = ClubActivation
5. ثبت ClubMembershipHistory:
- Action = "Activated"
```
### مرحله ۳: محاسبه هفتگی (Worker)
(مطابق بخش ۵)
### مرحله ۴: برداشت کمیسیون
```text
کاربر → درخواست برداشت
API: RequestWithdrawal (Cash یا Diamond)
ادمین → تایید درخواست
1. اگر Cash:
- واریز به حساب بانکی
- NetworkBalance -= مبلغ
2. اگر Diamond:
- خرید الماس از دایا
- NetworkBalance -= مبلغ
```
همراه با ثبت رکورد در `CommissionPayoutHistory` (Action = WithdrawRequested / Withdrawn).
---
## ۸. پروتوباف و gRPC Services
### ۸.۱ `clubmembership.proto`
```protobuf
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package clubmembership;
service ClubMembershipService {
rpc ActivateMembership (ActivateMembershipRequest) returns (ActivateMembershipResponse);
rpc GetClubStatus (GetClubStatusRequest) returns (GetClubStatusResponse);
rpc GrantFeature (GrantFeatureRequest) returns (GrantFeatureResponse);
rpc GetUserFeatures (GetUserFeaturesRequest) returns (GetUserFeaturesResponse);
}
message ActivateMembershipRequest {
int64 user_id = 1;
int64 contribution_amount = 2;
int64 network_parent_id = 3;
NetworkLeg leg_position = 4;
}
message ActivateMembershipResponse {
bool success = 1;
string message = 2;
ClubMembershipDto membership = 3;
}
message GetClubStatusRequest {
int64 user_id = 1;
}
message GetClubStatusResponse {
bool is_member = 1;
ClubMembershipDto membership = 2;
}
message ClubMembershipDto {
int64 id = 1;
int64 user_id = 2;
bool is_active = 3;
google.protobuf.Timestamp activated_at = 4;
int64 initial_contribution = 5;
int64 total_earned = 6;
}
enum NetworkLeg {
LEFT = 0;
RIGHT = 1;
}
```
### ۸.۲ `networkbalance.proto`
```protobuf
syntax = "proto3";
package networkbalance;
service NetworkBalanceService {
rpc GetNetworkTree (GetNetworkTreeRequest) returns (GetNetworkTreeResponse);
rpc GetWeeklyBalances (GetWeeklyBalancesRequest) returns (GetWeeklyBalancesResponse);
rpc GetNetworkStats (GetNetworkStatsRequest) returns (GetNetworkStatsResponse);
}
message GetNetworkTreeRequest {
int64 user_id = 1;
int32 max_depth = 2;
}
message GetNetworkTreeResponse {
NetworkNodeDto root = 1;
}
message NetworkNodeDto {
int64 user_id = 1;
string full_name = 2;
NetworkLeg leg_position = 3;
bool is_active = 4;
repeated NetworkNodeDto children = 5;
}
message GetWeeklyBalancesRequest {
int64 user_id = 1;
string week_number = 2;
}
message GetWeeklyBalancesResponse {
int32 left_leg_balances = 1;
int32 right_leg_balances = 2;
int32 total_balances = 3;
int64 pool_contribution = 4;
}
```
### ۸.۳ `commissionpayout.proto`
```protobuf
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package commissionpayout;
service CommissionPayoutService {
rpc RequestWithdrawal (RequestWithdrawalRequest) returns (RequestWithdrawalResponse);
rpc GetCommissionHistory (GetCommissionHistoryRequest) returns (GetCommissionHistoryResponse);
rpc GetPendingWithdrawals (GetPendingWithdrawalsRequest) returns (GetPendingWithdrawalsResponse);
rpc ProcessWithdrawal (ProcessWithdrawalRequest) returns (ProcessWithdrawalResponse);
}
message RequestWithdrawalRequest {
int64 user_id = 1;
int64 amount = 2;
WithdrawalMethod method = 3;
string iban_number = 4;
}
message RequestWithdrawalResponse {
bool success = 1;
string message = 2;
int64 request_id = 3;
}
message GetCommissionHistoryRequest {
int64 user_id = 1;
int32 page_number = 2;
int32 page_size = 3;
}
message GetCommissionHistoryResponse {
repeated CommissionPayoutDto payouts = 1;
int32 total_count = 2;
}
message CommissionPayoutDto {
int64 id = 1;
string week_number = 2;
int32 balances_earned = 3;
int64 value_per_balance = 4;
int64 total_amount = 5;
CommissionPayoutStatus status = 6;
google.protobuf.Timestamp paid_at = 7;
WithdrawalMethod withdrawal_method = 8;
}
enum WithdrawalMethod {
CASH = 0;
DIAMOND = 1;
}
enum CommissionPayoutStatus {
PENDING = 0;
PAID = 1;
WITHDRAW_REQUESTED = 2;
WITHDRAWN = 3;
CANCELLED = 4;
}
```
---
## ۹. نکات حیاتی و بهترین رویه‌ها
### ۹.۱ یکپارچگی شبکه باینری
- هر کاربر حداکثر دو فرزند (یکی Left، یکی Right)
- هنگام اضافه کردن فرزند، کنترل Race Condition
- حذف کاربر نباید ساختار شبکه را خراب کند
### ۹.۲ Transaction Management
- Worker باید تمام مراحل را در یک TransactionScope انجام دهد
- در صورت شکست، Rollback کامل
### ۹.۳ Idempotency
- محاسبه هفتگی برای یک WeekNumber فقط یک‌بار
- بررسی `WeeklyCommissionPool.IsCalculated` قبل از شروع
### ۹.۴ Performance
- Caching درخت شبکه برای کاربران پرحجم
- Index روی `WeekNumber`, `UserId`, `NetworkParentId`
### ۹.۵ Audit و Compliance
- همه تغییرات کیف پول در `UserWalletChangeLog`
- همه پرداخت‌های کمیسیون در `UserCommissionPayout` + `CommissionPayoutHistory`
- تغییرات شبکه در `NetworkMembershipHistory`
- تغییرات تنظیمات در `SystemConfigurationHistory`
### ۹.۶ Security
- محدودیت تعداد درخواست برداشت
- تایید دو مرحله‌ای برای برداشت‌های بالا
- Audit Log برای عملیات حساس
---
## ۱۰. مراحل پیاده‌سازی (Roadmap)
(مطابق نسخه قبلی – فاز ۱ تا ۶)
---
## ۱۱. متریک‌های کلیدی (KPIs)
- تعداد اعضای فعال باشگاه
- مجموع کمیسیون‌های پرداختی هر ماه
- میانگین تعادل هر کاربر در هفته
- نرخ تبدیل به عضویت باشگاه
- زمان اجرای Worker، تعداد خطاها، عمق درخت، حجم داده History و …
---
## ۱۲. سوالات متداول (FAQ)
(همان سوالات قبلی + می‌توان سوالات مربوط به سقف تعادل و تنظیمات را اضافه کرد.)
---
## ۱۳. ضمیمه: مثال عددی کامل
(مثال دو هفته‌ای A, B, C, D, E, F, G مثل نسخه قبلی.)
---
## ۱۴. مسیرهای مرتبط
- Domain: `CMS/src/CMSMicroservice.Domain/Entities/`
- Application: `CMS/src/CMSMicroservice.Application/ClubMembershipCQ/`, `NetworkBalanceCQ/`, `CommissionPoolCQ/`, `CommissionPayoutCQ/`, `ConfigurationCQ/`
- Protobuf: `CMS/src/CMSMicroservice.Protobuf/Protos/`
- Worker: `CMS/src/CMSMicroservice.Infrastructure/BackgroundJobs/`
- مستند حاضر: `CMS/docs/network-club-commission-system.md`
**نسخه**: 1.1
**تاریخ**: 2025-11-29
**نویسنده**: تیم توسعه CMS
**وضعیت**: آماده پیاده‌سازی (با History و Config)