Files
docs/01-BUSINESS/club-commission-system-complete.md
T
masoodafar-web 002e99f6bf Implement Persian Date Conversion and Enhance User Network Information Service
- Added PersianDateTimeService for converting Gregorian dates to Persian format in the BackOffice frontend.
- Updated multiple frontend pages (Dashboard, UserPayouts, WorkerControl, UserNetworkInfo) to utilize the new Persian date service.
- Enhanced GetUserNetworkPositionDto with 28+ new fields for comprehensive user network data.
- Updated GetUserNetworkPositionQueryHandler to include new methods for calculating network statistics.
- Modified Protobuf messages to accommodate the new fields, increasing from 14 to 42.
- Refined week number calculation algorithm to ensure consistency across C# and SQL implementations.
- Created new CSV and Excel files for binary plan calculations.
- Ensured all changes are tested and validated for accuracy and performance.
2025-12-20 06:15:59 +03:30

38 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 مرحله + فیلتر باشگاه

پایان مستندات 🎯