67 KiB
سیستم باشگاه مشتریان و کمیسیون شبکه - مستندات کامل و نهایی
تاریخ آخرین بروزرسانی: ۲۱ آذر ۱۴۰۴ (2025-12-12)
وضعیت: ✅ تکمیل شده و عملیاتی
نسخه: 2.0 (بازنگری شده)
📑 فهرست مطالب
- خلاصه اجرایی
- معماری سیستم
- فرآیند فعالسازی باشگاه
- محاسبات Binary Plan
- فرآیند محاسبه کمیسیون هفتگی
- موجودیتهای دامین
- جزئیات پیادهسازی
- مثالهای عملی
خلاصه اجرایی
هدف سیستم
سیستم باشگاه مشتریان (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:
public long? NetworkParentId { get; set; } // پدر در شبکه
public NetworkLeg? LegPosition { get; set; } // چپ یا راست
فرآیند فعالسازی باشگاه
مرحله 1: شارژ کیف پول (پیشنیاز)
کاربر 56,000,000 ریال پرداخت میکند:
// از طریق درگاه یا دایا
User.Balance += 56_000_000;
User.DiscountBalance += 56_000_000;
نکته: دو کیف پول همزمان شارژ میشوند.
مرحله 2: فعالسازی عضویت (ActivateClubMembership)
ورودیها:
{
"userId": 123,
"networkParentId": 45, // پدر در شبکه
"legPosition": "Left" // چپ یا راست
}
فرآیند اجرایی:
2.1. اعتبارسنجی
✅ User.Balance >= ActivationFee (25,000,000)
✅ NetworkParent وجود دارد
✅ موقعیت انتخابی (Left/Right) خالی است
✅ کاربر قبلاً عضو نیست
2.2. کسر از کیف پول
User.Balance -= 25_000_000;
2.3. ایجاد عضویت باشگاه
ClubMembership clubMembership = new()
{
UserId = userId,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 25_000_000,
TotalEarned = 0
};
2.4. قرارگیری در شبکه
User.NetworkParentId = networkParentId;
User.LegPosition = legPosition; // Left or Right
2.5. اضافه به Pool هفتگی ⭐ جدید در v2.0
// دریافت شماره هفته جاری (فرمت: "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. اختصاص امکانات باشگاه
// ۴ فیچر پایه باشگاه
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)
ورودی:
{
"weekNumber": "2025-W48",
"forceRecalculate": false
}
فرآیند:
1.1. فیلتر کاربران شرکتکننده
// فقط اعضای فعال باشگاه (بدون محدودیت زمانی)
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. دریافت باقیمانده هفته قبل
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 ها
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. محاسبه برای هر کاربر
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)
// حالا که همه تعادلها ذخیره شدند، میتوانیم زیرمجموعهها را محاسبه کنیم
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:
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)
ورودی:
{
"weekNumber": "2025-W48",
"forceRecalculate": false
}
فرآیند:
2.1. بررسی وجود Pool
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. دریافت تعادلهای محاسبه شده
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.ToListAsync();
if (!weeklyBalances.Any())
{
throw new InvalidOperationException(
$"تعادلهای هفته {request.WeekNumber} هنوز محاسبه نشده است. " +
"ابتدا CalculateWeeklyBalances را اجرا کنید"
);
}
2.3. محاسبه ارزش هر امتیاز
// مجموع کل 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)
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. ایجاد پرداختها
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. ثبت تاریخچه
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:
{
"weekNumber": "2025-W48",
"forceRecalculate": false,
"skipBalances": false,
"skipPayouts": false
}
Handler:
// 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
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
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
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
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
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)
/// <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}";
}
نکته: هفته از شنبه شروع میشود (تقویم ایرانی).
شمارش بازگشتی اعضای جدید
/// <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 به تاریخ
/// <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:
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
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
migrationBuilder.AddColumn<int>(
name: "SubordinateBalances",
schema: "Commission",
table: "NetworkWeeklyBalances",
type: "int",
nullable: false,
defaultValue: 0);
نکات مهم و Best Practices
✅ Do's
- همیشه Transaction استفاده کنید برای عملیات چند مرحلهای
- ForceRecalculate با احتیاط استفاده شود (حذف داده)
- Week Number را از سیستم محاسبه کنید (نه دستی)
- فیلتر اعضای باشگاه را فراموش نکنید
- FK Constraint را رعایت کنید (History قبل از Payout حذف شود)
❌ Don'ts
- Pool را دستی پُر نکنید (باید از ActivateClubMembership بیاید)
- SubordinateBalances را در Pool استفاده نکنید (تکراری است)
- شرط
NetworkParentId.HasValueنگذارید (ریشه شبکه حذف میشود) - محاسبات را بدون 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) را ارائه میدهد. این سیستم امکان مدیریت سه نوع کیف پول، فروشگاه اختصاصی با تخفیف، و توزیع عادلانه کمیسیون بر اساس تعادل شبکه را فراهم میکند.
۱. مفاهیم کلیدی
۱.۱ کیف پولهای سهگانه
هر کاربر سه نوع کیف پول دارد:
- کیف پول اصلی (Balance): برای خرید از فروشگاه عمومی بازار
- کیف پول تخفیف (DiscountBalance): فقط برای خرید از فروشگاه باشگاه مشتریان (محدود به درصد تخفیف محصولات)
- کیف پول طلایی/کارمزد (NetworkBalance): دریافتی از کمیسیون شبکهای - قابل برداشت نقدی یا خرید الماس از دایا
۱.۲ فعالسازی عضویت
- کاربر ۵۶ میلیون تومان پرداخت میکند (از طریق دایا یا درگاه)
- سیستم به صورت خودکار:
Balance += 56M(کیف پول اصلی)DiscountBalance += 56M(کیف پول تخفیف)
- کاربر دکمه «عضویت در باشگاه» را میزند:
25Mبه استخر کمیسیون هفتگی اضافه میشود- کاربر در شبکه باینری (Binary Tree) قرار میگیرد
۱.۳ شبکه باینری (Binary MLM Plan)
- هر کاربر حداکثر دو زیرمجموعه دارد: دست راست و دست چپ
- تعادل (Balance): زمانی که هر دو شاخه دارای اعضای جدید شوند، یک تعادل ایجاد میشود
- فرمول تعادل:
UserBalances = MIN(LeftLegBalances, RightLegBalances) - تعادلها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست میشوند
۱.۴ محاسبه کمیسیون هفتگی
مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادلهای کل سیستم)
کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز)
مثال:
- کاربر A: خودش ۱ تعادل + زیرمجموعههایش ۲ تعادل = ۳ امتیاز
- استخر هفتگی:
175M - مجموع امتیازهای سیستم:
5 - ارزش هر امتیاز:
175M ÷ 5 = 35M - کمیسیون کاربر A:
3 × 35M = 105M
۲. موجودیتهای جدید (Domain Entities)
۲.۱ ClubMembership (عضویت باشگاه مشتریان)
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 (امکانات باشگاه)
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 (امتیاز/فیچرهای فعال برای کاربر)
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 (تعادل هفتگی شبکه)
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 (استخر کمیسیون هفتگی)
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 (پرداخت کمیسیون به کاربر)
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
لاگ تغییرات مهم روی عضویت باشگاه (فعالسازی، غیرفعالسازی، ویرایش):
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
برای اینکه همیشه بدانیم «چه کسی زیرمجموعهی کی شده، چه زمانی، و اگر بعداً جابهجا شد چه اتفاقی افتاده»:
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
برای لاگ کامل همهی تغییرات روی پرداخت کمیسیونها (ایجاد، ویرایش دستی، تغییر وضعیت، برداشت و ...):
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) برای اینکه بعداً بدانیم در هر زمان چه محدودیتی فعال بوده:
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)
public enum ConfigurationScope
{
System = 0,
Network = 1,
Club = 2,
Commission = 3
}
۲.۸.۲ SystemConfiguration
جدولی برای نگهداری تنظیمات پویا. هم تنظیمات عمومی سیستم، هم تنظیمات مخصوص شبکه، باشگاه و کمیسیون:
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 جدید
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
افزودن فیلدهای مربوط به شبکه باینری و ناوبری:
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
public class UserWallet : BaseAuditableEntity
{
// موجودی ریالی اصلی
public long Balance { get; set; }
// موجودی شبکه/کارمزد (کیف پول طلایی)
public long NetworkBalance { get; set; }
// موجودی تخفیف (فقط برای خرید از فروشگاه باشگاه)
public long DiscountBalance { get; set; }
// ...
}
۳.۳ Products
public class Product : BaseAuditableEntity
{
// ...
// آیا این محصول فقط در فروشگاه باشگاه موجود است
public bool IsClubExclusive { get; set; }
// درصد تخفیف باشگاه (0 تا 100)
public int ClubDiscountPercent { get; set; }
// ...
}
۳.۴ UserWalletChangeLog
افزودن نوع جدید تراکنش:
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):
گام ۱: بستن هفته قبل و ایجاد استخر جدید
var currentWeek = GetCurrentWeekNumber(); // مثلاً "2025-W48"
var previousWeek = GetPreviousWeekNumber();
await CloseWeeklyPool(previousWeek);
await InitializeWeeklyPool(currentWeek);
گام ۲: محاسبه تعادلهای شبکه
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
});
}
الگوریتم بازگشتی محاسبه تعادل شاخه
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;
}
گام ۳: محاسبه استخر و ارزش امتیاز
var totalPoolAmount = await SumPoolContributions(previousWeek);
var totalBalances = await SumTotalBalances(previousWeek);
var valuePerBalance = totalBalances > 0 ? totalPoolAmount / totalBalances : 0;
await UpdatePoolValue(previousWeek, totalPoolAmount, totalBalances, valuePerBalance);
گام ۴: توزیع کمیسیونها
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");
}
گام ۵: ریست تعادلها
await ExpireWeeklyBalances(previousWeek);
۶. لاجیک فروشگاه و سبد خرید
۶.۱ نمایش محصولات
var query = _context.Products.Where(p => !p.IsDeleted);
if (!user.ClubMembership?.IsActive ?? true)
{
query = query.Where(p => !p.IsClubExclusive);
}
// اگر کاربر عضو است، قیمت با تخفیف باشگاه محاسبه میشود
۶.۲ استفاده از کیف پول تخفیف در Checkout
(خلاصهسازی شده – در کد اصلی از DiscountBalance استفاده میشود و ChangeLog ثبت میگردد.)
۷. سناریوی کامل فعالسازی عضویت
مرحله ۱: شارژ اولیه
کاربر → پرداخت ۵۶ میلیون (دایا/درگاه)
↓
UserWallet.Balance += 56,000,000
UserWallet.DiscountBalance += 56,000,000
مرحله ۲: فعالسازی عضویت
کاربر → کلیک روی دکمه «عضویت در باشگاه»
↓
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)
(مطابق بخش ۵)
مرحله ۴: برداشت کمیسیون
کاربر → درخواست برداشت
↓
API: RequestWithdrawal (Cash یا Diamond)
↓
ادمین → تایید درخواست
↓
1. اگر Cash:
- واریز به حساب بانکی
- NetworkBalance -= مبلغ
2. اگر Diamond:
- خرید الماس از دایا
- NetworkBalance -= مبلغ
همراه با ثبت رکورد در CommissionPayoutHistory (Action = WithdrawRequested / Withdrawn).
۸. پروتوباف و gRPC Services
۸.۱ clubmembership.proto
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
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
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)