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

67 KiB
Raw Blame History

سیستم باشگاه مشتریان و کمیسیون شبکه - مستندات کامل و نهایی

تاریخ آخرین بروزرسانی: ۲۱ آذر ۱۴۰۴ (2025-12-12)
وضعیت: تکمیل شده و عملیاتی
نسخه: 2.0 (بازنگری شده)


📑 فهرست مطالب

  1. خلاصه اجرایی
  2. معماری سیستم
  3. فرآیند فعالسازی باشگاه
  4. محاسبات 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:

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

  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)
  • تعادل‌ها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست می‌شوند

۱.۴ محاسبه کمیسیون هفتگی

مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادل‌های کل سیستم)
کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز)

مثال:

  • کاربر 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)