# سیستم باشگاه مشتریان و کمیسیون شبکه - مستندات کامل و نهایی **تاریخ آخرین بروزرسانی**: ۲۱ آذر ۱۴۰۴ (2025-12-12) **وضعیت**: ✅ تکمیل شده و عملیاتی **نسخه**: 2.0 (بازنگری شده) --- ## 📑 فهرست مطالب 1. [خلاصه اجرایی](#خلاصه-اجرایی) 2. [معماری سیستم](#معماری-سیستم) 3. [فرآیند فعالسازی باشگاه](#فرآیند-فعالسازی-باشگاه) 4. [محاسبات Binary Plan](#محاسبات-binary-plan) 5. [فرآیند محاسبه کمیسیون هفتگی](#فرآیند-محاسبه-کمیسیون-هفتگی) 6. [موجودیت‌های دامین](#موجودیتهای-دامین) 7. [جزئیات پیاده‌سازی](#جزئیات-پیادهسازی) 8. [مثال‌های عملی](#مثالهای-عملی) --- ## خلاصه اجرایی ### هدف سیستم سیستم باشگاه مشتریان (Club Membership) با پلن شبکه‌ای Binary MLM که امکانات زیر را فراهم می‌کند: ✅ **مدیریت سه نوع کیف پول** برای هر کاربر ✅ **فروشگاه اختصاصی** با تخفیف ویژه اعضای باشگاه ✅ **شبکه باینری** با حداکثر 2 شاخه برای هر کاربر ✅ **محاسبه خودکار کمیسیون** بر اساس تعادل شبکه (هفتگی) ✅ **توزیع عادلانه Pool** بین اعضا بر اساس امتیازات ### تغییرات اساسی نسخه 2.0 | مورد | قبل (v1.0) | بعد (v2.0) | |------|-----------|-----------| | **تعداد مراحل محاسبه** | 3 مرحله | 2 مرحله ✅ | | **منبع Pool هفتگی** | از تعادل‌های کاربران | از فعالسازی باشگاه ✅ | | **شمول زیرمجموعه** | فقط تعادل شخصی | تعادل + زیرمجموعه (15 لول) ✅ | | **فیلتر شرکت‌کنندگان** | همه کاربران شبکه | فقط اعضای فعال باشگاه ✅ | | **ذخیره فلش** | محاسبه در لحظه | ذخیره در دیتابیس ✅ | --- ## معماری سیستم ### 1. کیف پول‌های سه‌گانه هر کاربر دارای **سه کیف پول مجزا** است: #### 1️⃣ کیف پول اصلی (`Balance`) - **کاربری**: خرید از فروشگاه عمومی بازار - **شارژ**: درگاه پرداخت یا خرید از دایا - **قابل برداشت**: خیر #### 2️⃣ کیف پول تخفیف (`DiscountBalance`) - **کاربری**: خرید از فروشگاه باشگاه مشتریان (با تخفیف ویژه) - **شارژ**: هنگام فعالسازی عضویت باشگاه - **محدودیت**: فقط تا سقف درصد تخفیف محصول قابل استفاده - **قابل برداشت**: خیر #### 3️⃣ کیف پول طلایی/شبکه (`NetworkBalance`) - **کاربری**: - برداشت نقدی - خرید الماس از دایا - **شارژ**: دریافت کمیسیون هفتگی از شبکه - **قابل برداشت**: بله ✅ --- ### 2. شبکه باینری (Binary Tree) ``` User A (Root) / \ User B (Left) User C (Right) / \ / \ User D (L) User E (R) User F (L) User G (R) ``` **قوانین:** - هر کاربر حداکثر **2 زیرمجموعه مستقیم** دارد - موقعیت‌ها: `Left` (چپ) یا `Right` (راست) - عمق شبکه: نامحدود (اما محاسبات فقط تا **15 لول**) - ریشه شبکه (`NetworkParentId = NULL`): فقط یک نفر **فیلدهای مرتبط در `User` Entity:** ```csharp public long? NetworkParentId { get; set; } // پدر در شبکه public NetworkLeg? LegPosition { get; set; } // چپ یا راست ``` --- ## فرآیند فعالسازی باشگاه ### مرحله 1: شارژ کیف پول (پیش‌نیاز) کاربر **56,000,000 ریال** پرداخت می‌کند: ```csharp // از طریق درگاه یا دایا User.Balance += 56_000_000; User.DiscountBalance += 56_000_000; ``` **نکته**: دو کیف پول همزمان شارژ می‌شوند. --- ### مرحله 2: فعالسازی عضویت (`ActivateClubMembership`) **ورودی‌ها:** ```csharp { "userId": 123, "networkParentId": 45, // پدر در شبکه "legPosition": "Left" // چپ یا راست } ``` **فرآیند اجرایی:** #### 2.1. اعتبارسنجی ```csharp ✅ User.Balance >= ActivationFee (25,000,000) ✅ NetworkParent وجود دارد ✅ موقعیت انتخابی (Left/Right) خالی است ✅ کاربر قبلاً عضو نیست ``` #### 2.2. کسر از کیف پول ```csharp User.Balance -= 25_000_000; ``` #### 2.3. ایجاد عضویت باشگاه ```csharp ClubMembership clubMembership = new() { UserId = userId, IsActive = true, ActivatedAt = DateTime.Now, InitialContribution = 25_000_000, TotalEarned = 0 }; ``` #### 2.4. قرارگیری در شبکه ```csharp User.NetworkParentId = networkParentId; User.LegPosition = legPosition; // Left or Right ``` #### 2.5. اضافه به Pool هفتگی ⭐ **جدید در v2.0** ```csharp // دریافت شماره هفته جاری (فرمت: "2025-W48") var currentWeekNumber = GetCurrentWeekNumber(); var weeklyPool = await _context.WeeklyCommissionPools .FirstOrDefaultAsync(p => p.WeekNumber == currentWeekNumber); if (weeklyPool == null) { // ایجاد Pool جدید برای این هفته weeklyPool = new WeeklyCommissionPool { WeekNumber = currentWeekNumber, TotalPoolAmount = 25_200_000, // GiftValue TotalBalances = 0, ValuePerBalance = 0, IsCalculated = false }; await _context.WeeklyCommissionPools.AddAsync(weeklyPool); } else { // اضافه به Pool موجود weeklyPool.TotalPoolAmount += 25_200_000; _context.WeeklyCommissionPools.Update(weeklyPool); } ``` **نکته مهم**: - کاربر `25,000,000` پرداخت می‌کند - سیستم `25,200,000` به Pool اضافه می‌کند (Gift از شرکت) #### 2.6. اختصاص امکانات باشگاه ```csharp // ۴ فیچر پایه باشگاه var defaultFeatures = await _context.ClubFeatures .Where(f => f.IsActive && f.RequiredPoints == null) .ToListAsync(); foreach (var feature in defaultFeatures) { UserClubFeature userFeature = new() { UserId = userId, ClubFeatureId = feature.Id, GrantedAt = DateTime.Now, Notes = "فیچر پیش‌فرض عضویت باشگاه" }; await _context.UserClubFeatures.AddAsync(userFeature); } ``` --- ## محاسبات Binary Plan ### فرمول‌های اصلی این فرمول‌ها از فایل اکسل کسب‌وکار استخراج شده‌اند: #### 1️⃣ محاسبه مجموع هر پا ``` LeftTotal = LeftCarryover + LeftNewMembers RightTotal = RightCarryover + RightNewMembers ``` **مثال:** ``` هفته قبل: چپ=200, راست=0 این هفته: چپ=400, راست=500 LeftTotal = 200 + 400 = 600 RightTotal = 0 + 500 = 500 ``` --- #### 2️⃣ محاسبه تعادل اولیه (قبل از سقف) ``` TotalBalances (initial) = MIN(LeftTotal, RightTotal) ``` **مثال:** ``` TotalBalances = MIN(600, 500) = 500 ``` **معنی**: کوچکترین پا تعیین‌کننده تعادل است. --- #### 3️⃣ اعمال سقف (Cap) ``` MaxBalancesPerLeg = 300 (از Config) CappedBalances = MIN(TotalBalances, MaxBalancesPerLeg) ``` **مثال:** ``` CappedBalances = MIN(500, 300) = 300 ``` **معنی**: حداکثر امتیاز قابل دریافت در هر هفته **300** است. --- #### 4️⃣ محاسبه فلش (Flush - از دست رفته) ``` FlushedPerSide = TotalBalances - CappedBalances TotalFlushed = FlushedPerSide × 2 ``` **مثال:** ``` FlushedPerSide = 500 - 300 = 200 TotalFlushed = 200 × 2 = 400 ``` **معنی**: - از **چپ**: 600 → 300 استفاده شد → **200 فلش** - از **راست**: 500 → 300 استفاده شد → **200 فلش** - جمع فلش: **400** (از بین رفت) ❌ --- #### 5️⃣ محاسبه باقیمانده برای هفته بعد ``` LeftRemainder = LeftTotal - TotalBalances RightRemainder = RightTotal - TotalBalances ``` **مثال:** ``` LeftRemainder = 600 - 500 = 100 ✅ به هفته بعد منتقل می‌شود RightRemainder = 500 - 500 = 0 ``` **نکته**: یکی از دو باقیمانده همیشه **صفر** است. --- ### جدول خلاصه محاسبات (مثال واقعی از Excel) | متغیر | نماد | مقدار | توضیح | |-------|------|-------|-------| | باقیمانده قبل چپ | `LL` | 200 | از هفته قبل | | باقیمانده قبل راست | `LR` | 0 | از هفته قبل | | جدید چپ | `NL` | 400 | این هفته | | جدید راست | `NR` | 500 | این هفته | | **مجموع چپ** | `SLT` | **600** | LL + NL | | **مجموع راست** | `SRT` | **500** | LR + NR | | کمترین | `MinT` | 500 | MIN(SLT, SRT) | | سقف | `MX` | 300 | از Config | | **امتیاز نهایی** | `TB` | **300** | MIN(MinT, MX) ✅ | | فلش چپ | `FL` | 200 | SLT - MX - RNWL | | فلش راست | `FR` | 200 | SRT - MX - RNWR | | باقی چپ | `RNWL` | 100 | به هفته بعد | | باقی راست | `RNWR` | 0 | - | --- ## فرآیند محاسبه کمیسیون هفتگی ### تغییر معماری: 3 مرحله → 2 مرحله #### ❌ معماری قدیم (v1.0) ``` Step 1: CalculateWeeklyBalances └─ محاسبه تعادل‌های شخصی Step 2: CalculateWeeklyCommissionPool └─ محاسبه Pool از تعادل‌ها ❌ اشتباه بود! └─ محاسبه تعادل زیرمجموعه (تکراری) Step 3: ProcessUserPayouts └─ ایجاد پرداخت‌ها (تکراری) ``` #### ✅ معماری جدید (v2.0) ``` Step 1: CalculateWeeklyBalances └─ فقط اعضای فعال باشگاه └─ محاسبه تعادل شخصی (تا 15 لول) └─ محاسبه تعادل زیرمجموعه (تا 15 لول) └─ ذخیره فلش Step 2: CalculateWeeklyCommissionPool └─ Pool از قبل پُر شده (در ActivateClubMembership) └─ محاسبه ارزش هر امتیاز └─ ایجاد UserCommissionPayout └─ ثبت تاریخچه ``` --- ### Step 1: محاسبه تعادل‌های هفتگی (`CalculateWeeklyBalances`) **ورودی:** ```csharp { "weekNumber": "2025-W48", "forceRecalculate": false } ``` **فرآیند:** #### 1.1. فیلتر کاربران شرکت‌کننده ```csharp // فقط اعضای فعال باشگاه (بدون محدودیت زمانی) var activeClubMemberUserIds = await _context.ClubMemberships .Where(c => c.IsActive) .Select(c => c.UserId) .ToHashSetAsync(); // دریافت کاربران شبکه که عضو باشگاه هستند // نکته: شامل ریشه شبکه (NetworkParentId=NULL) هم می‌شود var usersInNetwork = await _context.Users .Where(x => activeClubMemberUserIds.Contains(x.Id)) .Select(x => new { x.Id }) .ToListAsync(); ``` **چرا فیلتر زمانی نداریم؟** - همه کسانی که **الان** عضو باشگاه هستند باید کمیسیون بگیرند - حتی اگر 10 هفته پیش فعال شده باشند --- #### 1.2. دریافت باقیمانده هفته قبل ```csharp var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber); // مثال: "2025-W48" → "2025-W47" var previousWeekCarryovers = await _context.NetworkWeeklyBalances .Where(x => x.WeekNumber == previousWeekNumber) .Select(x => new { x.UserId, x.LeftLegRemainder, x.RightLegRemainder }) .ToDictionaryAsync(x => x.UserId); ``` --- #### 1.3. خواندن Config ها ```csharp var configs = await _context.SystemConfigurations .Where(x => x.IsActive && ( x.Key == "Commission.MaxWeeklyBalancesPerLeg" || x.Key == "Commission.MaxNetworkLevel")) .ToDictionaryAsync(x => x.Key, x => x.Value); var maxBalancesPerLeg = int.Parse(configs["Commission.MaxWeeklyBalancesPerLeg"]); // 300 var maxNetworkLevel = int.Parse(configs["Commission.MaxNetworkLevel"]); // 15 ``` --- #### 1.4. محاسبه برای هر کاربر ```csharp foreach (var user in usersInNetwork) { // 1. دریافت باقیمانده هفته قبل var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id) ? previousWeekCarryovers[user.Id].LeftLegRemainder : 0; var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id) ? previousWeekCarryovers[user.Id].RightLegRemainder : 0; // 2. شمارش اعضای جدید (تا 15 لول) var leftNewMembers = await CountNewMembersInLeg( user.Id, NetworkLeg.Left, weekNumber, maxNetworkLevel); var rightNewMembers = await CountNewMembersInLeg( user.Id, NetworkLeg.Right, weekNumber, maxNetworkLevel); // 3. محاسبه مجموع var leftTotal = leftNewMembers + leftCarryover; var rightTotal = rightNewMembers + rightCarryover; // 4. محاسبه تعادل اولیه var totalBalances = Math.Min(leftTotal, rightTotal); // 5. اعمال سقف 300 var cappedBalances = Math.Min(totalBalances, maxBalancesPerLeg); // 6. محاسبه فلش var flushedPerSide = totalBalances - cappedBalances; var totalFlushed = flushedPerSide * 2; // 7. محاسبه باقیمانده var leftRemainder = leftTotal - totalBalances; var rightRemainder = rightTotal - totalBalances; // 8. ذخیره در دیتابیس var balance = new NetworkWeeklyBalance { UserId = user.Id, WeekNumber = weekNumber, LeftLegNewMembers = leftNewMembers, RightLegNewMembers = rightNewMembers, LeftLegCarryover = leftCarryover, RightLegCarryover = rightCarryover, LeftLegTotal = leftTotal, RightLegTotal = rightTotal, TotalBalances = cappedBalances, // امتیاز نهایی (300) LeftLegRemainder = leftRemainder, RightLegRemainder = rightRemainder, FlushedPerSide = flushedPerSide, // جدید در v2.0 TotalFlushed = totalFlushed, // جدید در v2.0 SubordinateBalances = 0, // محاسبه در مرحله 2 WeeklyPoolContribution = 0, CalculatedAt = DateTime.Now, IsExpired = false }; balancesList.Add(balance); } await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList); await _context.SaveChangesAsync(); ``` --- #### 1.5. محاسبه تعادل زیرمجموعه (فاز 2) ```csharp // حالا که همه تعادل‌ها ذخیره شدند، می‌توانیم زیرمجموعه‌ها را محاسبه کنیم var balancesDictionary = balancesList.ToDictionary(x => x.UserId); foreach (var balance in balancesList) { var subordinateBalances = await CalculateSubordinateBalancesAsync( balance.UserId, balancesDictionary, maxNetworkLevel, // تا 15 لول cancellationToken ); balance.SubordinateBalances = subordinateBalances; } _context.NetworkWeeklyBalances.UpdateRange(balancesList); await _context.SaveChangesAsync(); ``` **الگوریتم `CalculateSubordinateBalancesAsync`:** ```csharp private async Task CalculateSubordinateBalancesAsync( long userId, Dictionary allBalances, int maxLevel, CancellationToken cancellationToken) { // 1. پیدا کردن همه زیرمجموعه‌ها (تا maxLevel) var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel); // 2. جمع تعادل‌های آنها var totalSubordinateBalances = 0; foreach (var subordinateId in subordinates) { if (allBalances.ContainsKey(subordinateId)) { totalSubordinateBalances += allBalances[subordinateId].TotalBalances; } } return totalSubordinateBalances; } ``` **نکته مهم**: - `SubordinateBalances` فقط برای **گزارش‌گیری** ذخیره می‌شود - در محاسبه Pool استفاده **نمی‌شود** (چون وقتی همه `TotalBalances` را جمع بزنیم، خودش شامل زیرمجموعه‌ها هم هست) --- ### Step 2: محاسبه Pool و پرداخت‌ها (`CalculateWeeklyCommissionPool`) **ورودی:** ```csharp { "weekNumber": "2025-W48", "forceRecalculate": false } ``` **فرآیند:** #### 2.1. بررسی وجود Pool ```csharp var existingPool = await _context.WeeklyCommissionPools .FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber); if (existingPool == null) { throw new InvalidOperationException( $"Pool هفته {request.WeekNumber} وجود ندارد. " + "Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد" ); } ``` **نکته کلیدی**: Pool از قبل توسط `ActivateClubMembership` پُر شده است! ✅ --- #### 2.2. دریافت تعادل‌های محاسبه شده ```csharp var weeklyBalances = await _context.NetworkWeeklyBalances .Where(x => x.WeekNumber == request.WeekNumber) .ToListAsync(); if (!weeklyBalances.Any()) { throw new InvalidOperationException( $"تعادل‌های هفته {request.WeekNumber} هنوز محاسبه نشده است. " + "ابتدا CalculateWeeklyBalances را اجرا کنید" ); } ``` --- #### 2.3. محاسبه ارزش هر امتیاز ```csharp // مجموع کل Pool (از فعالسازی‌های باشگاه) var totalPoolAmount = existingPool.TotalPoolAmount; // مجموع کل تعادل‌های شبکه // نکته: SubordinateBalances اضافه نمی‌کنیم چون وقتی همه TotalBalances را // جمع بزنیم، خودش شامل تعادل‌های زیرمجموعه‌ها هم هست (تکراری نشود) var totalBalancesInNetwork = weeklyBalances.Sum(x => x.TotalBalances); // ارزش هر امتیاز long valuePerBalance = 0; if (totalBalancesInNetwork > 0) { valuePerBalance = totalPoolAmount / totalBalancesInNetwork; } // به‌روزرسانی Pool existingPool.TotalBalances = totalBalancesInNetwork; existingPool.ValuePerBalance = valuePerBalance; existingPool.IsCalculated = true; existingPool.CalculatedAt = DateTime.Now; _context.WeeklyCommissionPools.Update(existingPool); await _context.SaveChangesAsync(); ``` **مثال عددی:** ``` TotalPoolAmount = 252,000,000 ریال (10 نفر × 25.2M) TotalBalances = 1,500 امتیاز ValuePerBalance = 252,000,000 ÷ 1,500 = 168,000 ریال ``` --- #### 2.4. حذف پرداخت‌های قبلی (در صورت ForceRecalculate) ```csharp if (request.ForceRecalculate) { var oldPayouts = await _context.UserCommissionPayouts .Where(p => p.WeekNumber == request.WeekNumber) .ToListAsync(); if (oldPayouts.Any()) { var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList(); // ⭐ اول تاریخچه‌ها حذف شوند (FK constraint) var oldHistories = await _context.CommissionPayoutHistories .Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId)) .ToListAsync(); if (oldHistories.Any()) { _context.CommissionPayoutHistories.RemoveRange(oldHistories); } // بعد پرداخت‌ها _context.UserCommissionPayouts.RemoveRange(oldPayouts); await _context.SaveChangesAsync(); } } ``` --- #### 2.5. ایجاد پرداخت‌ها ```csharp var payouts = new List(); foreach (var balance in weeklyBalances) { // فقط تعادل شخصی (نه زیرمجموعه) var userBalance = balance.TotalBalances; // اگر تعادل صفر است، رد شود if (userBalance <= 0) continue; // محاسبه مبلغ کمیسیون var totalAmount = (long)(userBalance * valuePerBalance); var payout = new UserCommissionPayout { UserId = balance.UserId, WeekNumber = request.WeekNumber, WeeklyPoolId = existingPool.Id, BalancesEarned = userBalance, ValuePerBalance = valuePerBalance, TotalAmount = totalAmount, Status = CommissionPayoutStatus.Pending, PaidAt = null, WithdrawalMethod = null, IbanNumber = null, WithdrawnAt = null }; payouts.Add(payout); } await _context.UserCommissionPayouts.AddRangeAsync(payouts); await _context.SaveChangesAsync(); ``` --- #### 2.6. ثبت تاریخچه ```csharp var historyList = new List(); foreach (var payout in payouts) { var history = new CommissionPayoutHistory { UserCommissionPayoutId = payout.Id, UserId = payout.UserId, WeekNumber = request.WeekNumber, AmountBefore = 0, AmountAfter = payout.TotalAmount, OldStatus = default(CommissionPayoutStatus), NewStatus = CommissionPayoutStatus.Pending, Action = CommissionPayoutAction.Created, PerformedBy = "System", Reason = "پردازش خودکار کمیسیون هفتگی" }; historyList.Add(history); } await _context.CommissionPayoutHistories.AddRangeAsync(historyList); await _context.SaveChangesAsync(); ``` --- ### فرآیند کلی (TriggerWeeklyCalculation) **Command:** ```csharp { "weekNumber": "2025-W48", "forceRecalculate": false, "skipBalances": false, "skipPayouts": false } ``` **Handler:** ```csharp // Step 1: محاسبه تعادل‌های هفتگی if (!request.SkipBalances) { await _mediator.Send(new CalculateWeeklyBalancesCommand { WeekNumber = request.WeekNumber, ForceRecalculate = request.ForceRecalculate }); steps.Add("محاسبه امتیازات هفتگی"); } // Step 2: محاسبه Pool و پردازش پرداخت‌ها if (!request.SkipPayouts) { await _mediator.Send(new CalculateWeeklyCommissionPoolCommand { WeekNumber = request.WeekNumber, ForceRecalculate = request.ForceRecalculate }); steps.Add("محاسبه استخر و پرداخت کاربران"); } ``` --- ## موجودیت‌های دامین ### 1. `ClubMembership` ```csharp public class ClubMembership : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } /// /// آیا عضویت فعال است؟ /// public bool IsActive { get; set; } /// /// تاریخ فعال‌سازی عضویت /// public DateTime? ActivatedAt { get; set; } /// /// مبلغ اولیه پرداختی برای فعال‌سازی (25,000,000 ریال) /// public long InitialContribution { get; set; } /// /// مجموع درآمد کارمزد شبکه تاکنون /// public long TotalEarned { get; set; } public virtual ICollection UserClubFeatures { get; set; } } ``` --- ### 2. `NetworkWeeklyBalance` ```csharp public class NetworkWeeklyBalance : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } /// /// شماره هفته (فرمت: "2025-W48") /// public string WeekNumber { get; set; } // === اطلاعات پای چپ === public int 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; } // === تعادل نهایی === /// /// امتیاز نهایی بعد از اعمال سقف 300 (CappedBalances) /// public int TotalBalances { get; set; } /// /// مجموع تعادل‌های زیرمجموعه (تا 15 لول) /// فقط برای گزارش‌گیری - در Pool استفاده نمی‌شود /// public int SubordinateBalances { get; set; } // === فلش (از دست رفته) === /// /// مقدار فلش هر طرف (TotalBalances - CappedBalances) /// public int FlushedPerSide { get; set; } /// /// مجموع فلش از دو طرف (FlushedPerSide × 2) /// public int TotalFlushed { get; set; } // === متا دیتا === public long WeeklyPoolContribution { get; set; } // همیشه 0 در v2.0 public DateTime CalculatedAt { get; set; } public bool IsExpired { get; set; } // === Deprecated === [Obsolete("از LeftLegTotal استفاده کنید")] public int LeftLegBalances { get; set; } [Obsolete("از RightLegTotal استفاده کنید")] public int RightLegBalances { get; set; } } ``` --- ### 3. `WeeklyCommissionPool` ```csharp public class WeeklyCommissionPool : BaseAuditableEntity { /// /// شماره هفته (فرمت: "2025-W48") /// public string WeekNumber { get; set; } /// /// مجموع مبلغ Pool (از فعالسازی‌های باشگاه) /// public long TotalPoolAmount { get; set; } /// /// مجموع تعادل‌های کل شبکه /// public int TotalBalances { get; set; } /// /// ارزش ریالی هر امتیاز (TotalPoolAmount ÷ TotalBalances) /// public long ValuePerBalance { get; set; } /// /// آیا Pool محاسبه و توزیع شده است؟ /// public bool IsCalculated { get; set; } public DateTime? CalculatedAt { get; set; } public virtual ICollection Payouts { get; set; } } ``` --- ### 4. `UserCommissionPayout` ```csharp public class UserCommissionPayout : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } public string WeekNumber { get; set; } public long WeeklyPoolId { get; set; } public virtual WeeklyCommissionPool WeeklyPool { get; set; } /// /// تعداد امتیازهای کسب شده (تعادل شخصی) /// public int BalancesEarned { get; set; } /// /// ارزش ریالی هر امتیاز /// public long ValuePerBalance { get; set; } /// /// مبلغ کل کمیسیون (BalancesEarned × ValuePerBalance) /// public long TotalAmount { get; set; } /// /// وضعیت: Pending, Approved, Paid, Rejected /// 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 Histories { get; set; } } ``` --- ### 5. `CommissionPayoutHistory` ```csharp public class CommissionPayoutHistory : BaseAuditableEntity { public long UserCommissionPayoutId { get; set; } public virtual UserCommissionPayout UserCommissionPayout { get; set; } public long UserId { get; set; } public virtual User User { get; set; } public string WeekNumber { get; set; } public long AmountBefore { get; set; } public long AmountAfter { get; set; } public CommissionPayoutStatus OldStatus { get; set; } public CommissionPayoutStatus NewStatus { get; set; } public CommissionPayoutAction Action { get; set; } public string PerformedBy { get; set; } // UserId یا "System" public string? Reason { get; set; } } ``` --- ## جزئیات پیاده‌سازی ### محاسبه شماره هفته (Week Number) ```csharp /// /// محاسبه شماره هفته جاری (شنبه محور) /// فرمت: "YYYY-Www" (مثال: "2025-W48") /// private string GetCurrentWeekNumber() { var now = DateTime.Now; var culture = new CultureInfo("fa-IR"); var calendar = culture.Calendar; var year = calendar.GetYear(now); var jan1 = new DateTime(year, 1, 1); // محاسبه اولین شنبه سال var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek; if (daysOffset < 0) daysOffset += 7; var firstSaturday = jan1.AddDays(daysOffset); // محاسبه تعداد روزهای گذشته از اولین شنبه var daysSinceFirstSaturday = (now - firstSaturday).Days; // محاسبه شماره هفته var weekNumber = (daysSinceFirstSaturday / 7) + 1; return $"{year}-W{weekNumber:D2}"; } ``` **نکته**: هفته از **شنبه** شروع می‌شود (تقویم ایرانی). --- ### شمارش بازگشتی اعضای جدید ```csharp /// /// شمارش اعضای جدیدی که در یک هفته مشخص به یک پا اضافه شدند /// private async Task 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 CountNewMembersRecursive( long userId, NetworkLeg leg, DateTime startDate, DateTime endDate, int currentLevel, int maxLevel, CancellationToken cancellationToken) { // محدودیت عمق: تا 15 لول if (currentLevel >= maxLevel) return 0; // پیدا کردن فرزند مستقیم var child = await _context.Users .FirstOrDefaultAsync( x => x.NetworkParentId == userId && x.LegPosition == leg, cancellationToken ); if (child == null) return 0; var count = 0; // بررسی فعالسازی باشگاه در این هفته var membership = await _context.ClubMemberships .FirstOrDefaultAsync( x => x.UserId == child.Id && x.IsActive, cancellationToken ); if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate) { count = 1; } // جمع کردن از زیرشاخه‌های چپ و راست var childLeft = await CountNewMembersRecursive( child.Id, NetworkLeg.Left, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken ); var childRight = await CountNewMembersRecursive( child.Id, NetworkLeg.Right, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken ); return count + childLeft + childRight; } ``` --- ### تبدیل WeekNumber به تاریخ ```csharp /// /// تبدیل شماره هفته به بازه تاریخی (شنبه تا جمعه) /// private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber) { // Parse: "2025-W48" var parts = weekNumber.Split('-'); var year = int.Parse(parts[0]); var week = int.Parse(parts[1].Replace("W", "")); // محاسبه اولین شنبه سال var jan1 = new DateTime(year, 1, 1); var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek; if (daysOffset < 0) daysOffset += 7; var firstSaturday = jan1.AddDays(daysOffset); // محاسبه شنبه این هفته var weekStart = firstSaturday.AddDays((week - 1) * 7); // جمعه همان هفته (23:59:59) var weekEnd = weekStart.AddDays(6) .AddHours(23) .AddMinutes(59) .AddSeconds(59); return (weekStart, weekEnd); } ``` --- ## مثال‌های عملی ### مثال 1: فعالسازی ساده **وضعیت اولیه:** ``` User A (Root) └─ خالی ``` **فعالسازی User B:** ```csharp Request: { "userId": 2, // User B "networkParentId": 1, // User A "legPosition": "Left" } Result: ✅ User B عضو باشگاه شد ✅ 25M از Balance کسر شد ✅ 25.2M به Pool هفته جاری اضافه شد ✅ User B زیر User A قرار گرفت (چپ) ``` **ساختار شبکه بعد:** ``` User A (Root) ├─ User B (Left) ✅ └─ خالی (Right) ``` --- ### مثال 2: محاسبه تعادل **ساختار شبکه:** ``` User A ├─ Left: User B, User C (2 نفر) └─ Right: User D (1 نفر) ``` **محاسبات User A:** ``` LeftTotal = 2 (عضو جدید این هفته) RightTotal = 1 TotalBalances (initial) = MIN(2, 1) = 1 CappedBalances = MIN(1, 300) = 1 FlushedPerSide = 1 - 1 = 0 TotalFlushed = 0 × 2 = 0 LeftRemainder = 2 - 1 = 1 ✅ به هفته بعد RightRemainder = 1 - 1 = 0 Result: امتیاز این هفته: 1 باقیمانده چپ: 1 ``` --- ### مثال 3: توزیع Pool **فرض:** - Pool هفته: `252,000,000` ریال (10 فعالسازی × 25.2M) - مجموع تعادل‌های شبکه: `1,500` امتیاز **محاسبات:** ``` ValuePerBalance = 252,000,000 ÷ 1,500 = 168,000 ریال/امتیاز ``` **کاربران:** | کاربر | امتیاز شخصی | کمیسیون | |-------|-------------|---------| | User A | 300 | 300 × 168,000 = **50,400,000** ریال | | User B | 150 | 150 × 168,000 = **25,200,000** ریال | | User C | 50 | 50 × 168,000 = **8,400,000** ریال | | **جمع** | **1,500** | **252,000,000** ریال ✅ | --- ### مثال 4: فلش (Overflow) **User X:** ``` هفته قبل: چپ = 250 باقیمانده راست = 0 این هفته: چپ = 400 عضو جدید راست = 500 عضو جدید محاسبات: LeftTotal = 250 + 400 = 650 RightTotal = 0 + 500 = 500 TotalBalances (initial) = MIN(650, 500) = 500 CappedBalances = MIN(500, 300) = 300 ⭐ FlushedPerSide = 500 - 300 = 200 TotalFlushed = 200 × 2 = 400 ❌ (از دست رفت) LeftRemainder = 650 - 500 = 150 ✅ RightRemainder = 500 - 500 = 0 Result: امتیاز این هفته: 300 فلش: 400 (از بین رفت) باقیمانده چپ: 150 (به هفته بعد) ``` **نکته**: حتی با 650 چپ و 500 راست، فقط **300 امتیاز** می‌گیرد (سقف). --- ## Configuration های سیستم ### جدول تنظیمات | Key | Value | توضیحات | |-----|-------|---------| | `Club.ActivationFee` | `25000000` | هزینه فعالسازی باشگاه (25M ریال) | | `Club.GiftValue` | `25200000` | مبلغ اضافه به Pool (25.2M ریال) | | `Club.InitialBalance` | `56000000` | شارژ اولیه کیف پول (56M ریال) | | `Commission.MaxWeeklyBalancesPerLeg` | `300` | سقف امتیاز هر پا | | `Commission.MaxNetworkLevel` | `15` | حداکثر عمق شبکه برای محاسبات | --- ## Migration های ایجاد شده ### 1. `AddFlushedFieldsToNetworkWeeklyBalance` ```csharp migrationBuilder.AddColumn( name: "FlushedPerSide", schema: "Commission", table: "NetworkWeeklyBalances", type: "int", nullable: false, defaultValue: 0); migrationBuilder.AddColumn( name: "TotalFlushed", schema: "Commission", table: "NetworkWeeklyBalances", type: "int", nullable: false, defaultValue: 0); ``` ### 2. `AddSubordinateBalancesToNetworkWeeklyBalance` ```csharp migrationBuilder.AddColumn( name: "SubordinateBalances", schema: "Commission", table: "NetworkWeeklyBalances", type: "int", nullable: false, defaultValue: 0); ``` --- ## نکات مهم و Best Practices ### ✅ Do's 1. **همیشه Transaction استفاده کنید** برای عملیات چند مرحله‌ای 2. **ForceRecalculate با احتیاط** استفاده شود (حذف داده) 3. **Week Number** را از سیستم محاسبه کنید (نه دستی) 4. **فیلتر اعضای باشگاه** را فراموش نکنید 5. **FK Constraint** را رعایت کنید (History قبل از Payout حذف شود) ### ❌ Don'ts 1. **Pool را دستی پُر نکنید** (باید از ActivateClubMembership بیاید) 2. **SubordinateBalances را در Pool استفاده نکنید** (تکراری است) 3. **شرط `NetworkParentId.HasValue` نگذارید** (ریشه شبکه حذف می‌شود) 4. **محاسبات را بدون Lock اجرا نکنید** (امکان Race Condition) --- ## خلاصه فرآیند نهایی ``` 1. کاربر شارژ می‌کند (56M) ├─ Balance += 56M └─ DiscountBalance += 56M 2. کاربر «عضو باشگاه» می‌شود ├─ Balance -= 25M ├─ Pool += 25.2M ⭐ ├─ قرار گرفتن در شبکه └─ دریافت 4 امکان پایه 3. هر هفته: CalculateWeeklyBalances ├─ فقط اعضای فعال باشگاه ├─ محاسبه تعادل (تا 15 لول) ├─ محاسبه زیرمجموعه (تا 15 لول) └─ ذخیره فلش 4. هر هفته: CalculateWeeklyCommissionPool ├─ Pool از قبل پُر شده ├─ ارزش هر امتیاز = Pool ÷ مجموع تعادل‌ها ├─ ایجاد UserCommissionPayout └─ ثبت تاریخچه 5. کاربر درخواست برداشت └─ NetworkBalance → حساب بانکی ``` --- ## تاریخچه تغییرات | تاریخ | نسخه | تغییرات | |-------|------|---------| | 2025-12-04 | 1.0 | نسخه اولیه سیستم | | 2025-12-10 | 1.5 | اصلاح Pool (از فعالسازی) | | 2025-12-12 | 2.0 | ساده‌سازی به 2 مرحله + فیلتر باشگاه | --- **پایان مستندات** 🎯 --- # ضمیمه: مشخصات اصلی سیستم (نسخه قبلی) # سیستم باشگاه مشتریان و محاسبه کمیسیون شبکه ## خلاصه اجرایی این سند تحلیل جامع و معماری پیشنهادی برای پیاده‌سازی سیستم باشگاه مشتریان (Club Membership) و محاسبه کمیسیون شبکه‌ای (MLM Binary Plan) را ارائه می‌دهد. این سیستم امکان مدیریت سه نوع کیف پول، فروشگاه اختصاصی با تخفیف، و توزیع عادلانه کمیسیون بر اساس تعادل شبکه را فراهم می‌کند. --- ## ۱. مفاهیم کلیدی ### ۱.۱ کیف پول‌های سه‌گانه هر کاربر سه نوع کیف پول دارد: 1. **کیف پول اصلی (Balance)**: برای خرید از فروشگاه عمومی بازار 2. **کیف پول تخفیف (DiscountBalance)**: فقط برای خرید از فروشگاه باشگاه مشتریان (محدود به درصد تخفیف محصولات) 3. **کیف پول طلایی/کارمزد (NetworkBalance)**: دریافتی از کمیسیون شبکه‌ای - قابل برداشت نقدی یا خرید الماس از دایا ### ۱.۲ فعال‌سازی عضویت - کاربر ۵۶ میلیون تومان پرداخت می‌کند (از طریق دایا یا درگاه) - سیستم به صورت خودکار: - `Balance += 56M` (کیف پول اصلی) - `DiscountBalance += 56M` (کیف پول تخفیف) - کاربر دکمه «عضویت در باشگاه» را می‌زند: - `25M` به استخر کمیسیون هفتگی اضافه می‌شود - کاربر در شبکه باینری (Binary Tree) قرار می‌گیرد ### ۱.۳ شبکه باینری (Binary MLM Plan) - هر کاربر حداکثر دو زیرمجموعه دارد: **دست راست** و **دست چپ** - تعادل (Balance): زمانی که هر دو شاخه دارای اعضای جدید شوند، یک تعادل ایجاد می‌شود - **فرمول تعادل**: `UserBalances = MIN(LeftLegBalances, RightLegBalances)` - تعادل‌ها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست می‌شوند ### ۱.۴ محاسبه کمیسیون هفتگی ```text مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادل‌های کل سیستم) کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز) ``` **مثال**: - کاربر A: خودش ۱ تعادل + زیرمجموعه‌هایش ۲ تعادل = **۳ امتیاز** - استخر هفتگی: `175M` - مجموع امتیازهای سیستم: `5` - ارزش هر امتیاز: `175M ÷ 5 = 35M` - کمیسیون کاربر A: `3 × 35M = 105M` --- ## ۲. موجودیت‌های جدید (Domain Entities) ### ۲.۱ `ClubMembership` (عضویت باشگاه مشتریان) ```csharp public class ClubMembership : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } public bool IsActive { get; set; } public DateTime? ActivatedAt { get; set; } // مبلغ اولیه پرداختی برای فعال‌سازی (معمولاً ۲۵ میلیون) public long InitialContribution { get; set; } // مجموع درآمد کارمزد تاکنون public long TotalEarned { get; set; } public virtual ICollection UserClubFeatures { get; set; } } ``` ### ۲.۲ `ClubFeature` (امکانات باشگاه) ```csharp public class ClubFeature : BaseAuditableEntity { public string Title { get; set; } public string? Description { get; set; } public bool IsActive { get; set; } public int? RequiredPoints { get; set; } public int SortOrder { get; set; } public virtual ICollection UserClubFeatures { get; set; } } ``` ### ۲.۳ `UserClubFeature` (امتیاز/فیچرهای فعال برای کاربر) ```csharp public class UserClubFeature : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } public long ClubFeatureId { get; set; } public virtual ClubFeature ClubFeature { get; set; } public DateTime GrantedAt { get; set; } public string? Notes { get; set; } } ``` ### ۲.۴ `NetworkWeeklyBalance` (تعادل هفتگی شبکه) ```csharp public class NetworkWeeklyBalance : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } // مثلاً "2025-W48" public string WeekNumber { get; set; } public int LeftLegBalances { get; set; } public int RightLegBalances { get; set; } public int TotalBalances { get; set; } // مبلغی که این کاربر همان هفته به استخر اضافه کرده (معمولاً InitialContribution) public long WeeklyPoolContribution { get; set; } public DateTime? CalculatedAt { get; set; } public bool IsExpired { get; set; } } ``` ### ۲.۵ `WeeklyCommissionPool` (استخر کمیسیون هفتگی) ```csharp public class WeeklyCommissionPool : BaseAuditableEntity { public string WeekNumber { get; set; } public long TotalPoolAmount { get; set; } public int TotalBalances { get; set; } public long ValuePerBalance { get; set; } public bool IsCalculated { get; set; } public DateTime? CalculatedAt { get; set; } public virtual ICollection UserCommissionPayouts { get; set; } } ``` ### ۲.۶ `UserCommissionPayout` (پرداخت کمیسیون به کاربر) ```csharp public class UserCommissionPayout : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } public string WeekNumber { get; set; } public long WeeklyPoolId { get; set; } public virtual WeeklyCommissionPool WeeklyPool { get; set; } public int BalancesEarned { get; set; } public long ValuePerBalance { get; set; } public long TotalAmount { get; set; } public CommissionPayoutStatus Status { get; set; } public DateTime? PaidAt { get; set; } public WithdrawalMethod? WithdrawalMethod { get; set; } public string? IbanNumber { get; set; } public DateTime? WithdrawnAt { get; set; } } ``` --- ### ۲.۷ موجودیت‌های History (جداول لاگ) #### ۲.۷.۱ `ClubMembershipHistory` لاگ تغییرات مهم روی عضویت باشگاه (فعال‌سازی، غیرفعال‌سازی، ویرایش): ```csharp public class ClubMembershipHistory : BaseAuditableEntity { public long ClubMembershipId { get; set; } public long UserId { get; set; } public bool OldIsActive { get; set; } public bool NewIsActive { get; set; } public long? OldInitialContribution { get; set; } public long? NewInitialContribution { get; set; } // Activated / Deactivated / Updated / ManualFix public string Action { get; set; } public string? Reason { get; set; } } ``` #### ۲.۷.۲ `NetworkMembershipHistory` برای اینکه همیشه بدانیم «چه کسی زیرمجموعه‌ی کی شده، چه زمانی، و اگر بعداً جابه‌جا شد چه اتفاقی افتاده»: ```csharp public class NetworkMembershipHistory : BaseAuditableEntity { public long UserId { get; set; } public long? OldParentId { get; set; } public long? NewParentId { get; set; } public NetworkLeg? OldLegPosition { get; set; } public NetworkLeg? NewLegPosition { get; set; } // Join / Move / Remove public string Action { get; set; } public string? Reason { get; set; } } ``` - هر بار `RecordNetworkJoin` یا `UpdateNetworkPosition` صدا زده می‌شود، باید یک رکورد در این جدول نوشته شود. - این جدول مرجع اصلی برای بازسازی درخت شبکه در زمان‌های گذشته است. #### ۲.۷.۳ `CommissionPayoutHistory` برای لاگ کامل همه‌ی تغییرات روی پرداخت کمیسیون‌ها (ایجاد، ویرایش دستی، تغییر وضعیت، برداشت و ...): ```csharp public class CommissionPayoutHistory : BaseAuditableEntity { public long UserCommissionPayoutId { get; set; } public long UserId { get; set; } public string WeekNumber { get; set; } public long AmountBefore { get; set; } public long AmountAfter { get; set; } public CommissionPayoutStatus OldStatus { get; set; } public CommissionPayoutStatus NewStatus { get; set; } // Created / Paid / WithdrawRequested / Withdrawn / Cancelled / ManualFix public string Action { get; set; } public string? PerformedBy { get; set; } // UserId یا System public string? Reason { get; set; } } ``` - اگر بعداً بفهمیم یک پرداخت اشتباه بوده و اصلاحش کنیم، اینجا قابل ردیابی است. - برای گزارش‌گیری Audit کامل پرداخت‌ها، این جدول استفاده می‌شود. #### ۲.۷.۴ `SystemConfigurationHistory` تاریخچه تغییرات تنظیمات (Config) برای این‌که بعداً بدانیم در هر زمان چه محدودیتی فعال بوده: ```csharp public class SystemConfigurationHistory : BaseAuditableEntity { public long ConfigurationId { get; set; } public ConfigurationScope Scope { get; set; } public string Key { get; set; } public string OldValue { get; set; } public string NewValue { get; set; } public string? Reason { get; set; } } ``` --- ### ۲.۸ موجودیت‌های Configuration (تنظیمات پویا) #### ۲.۸.۱ `ConfigurationScope` (Enum) ```csharp public enum ConfigurationScope { System = 0, Network = 1, Club = 2, Commission = 3 } ``` #### ۲.۸.۲ `SystemConfiguration` جدولی برای نگهداری تنظیمات پویا. هم تنظیمات عمومی سیستم، هم تنظیمات مخصوص شبکه، باشگاه و کمیسیون: ```csharp public class SystemConfiguration : BaseAuditableEntity { public ConfigurationScope Scope { get; set; } // System / Network / Club / Commission // مثل: "MaxWeeklyBalancesPerUser", "MinContributionAmount", ... public string Key { get; set; } // مقدار به‌صورت رشته - تفسیر در لایه Application public string Value { get; set; } // برای UI و Validation (Int / Decimal / Bool / String / Json) public string? DataType { get; set; } public string? Description { get; set; } public bool IsActive { get; set; } } ``` **مثال کانفیگ‌های مرتبط با شبکه:** - `Scope = Network`, `Key = "MaxWeeklyBalancesPerUser"`, `Value = "300"` - `Scope = Network`, `Key = "MaxChildrenPerLeg"`, `Value = "1"` - `Scope = Commission`, `Key = "DefaultInitialContribution"`, `Value = "25000000"` > نکته: هر بار که مقدار `SystemConfiguration` تغییر می‌کند، یک رکورد در `SystemConfigurationHistory` ثبت می‌شود تا تنظیمات گذشته قابل ردیابی باشد. --- ### ۲.۹ Enums جدید ```csharp public enum CommissionPayoutStatus { Pending = 0, Paid = 1, WithdrawRequested = 2, Withdrawn = 3, Cancelled = 4 } public enum WithdrawalMethod { Cash = 0, Diamond = 1 } public enum NetworkLeg { Left = 0, Right = 1 } ``` --- ## ۳. تغییرات در موجودیت‌های موجود ### ۳.۱ `User` افزودن فیلدهای مربوط به شبکه باینری و ناوبری: ```csharp public class User : BaseAuditableEntity { // ... public long? NetworkParentId { get; set; } public virtual User? NetworkParent { get; set; } public NetworkLeg? LegPosition { get; set; } public virtual ICollection NetworkChildren { get; set; } public virtual ClubMembership? ClubMembership { get; set; } public virtual ICollection NetworkWeeklyBalances { get; set; } public virtual ICollection CommissionPayouts { get; set; } public virtual ICollection UserClubFeatures { get; set; } } ``` ### ۳.۲ `UserWallet` ```csharp public class UserWallet : BaseAuditableEntity { // موجودی ریالی اصلی public long Balance { get; set; } // موجودی شبکه/کارمزد (کیف پول طلایی) public long NetworkBalance { get; set; } // موجودی تخفیف (فقط برای خرید از فروشگاه باشگاه) public long DiscountBalance { get; set; } // ... } ``` ### ۳.۳ `Products` ```csharp public class Product : BaseAuditableEntity { // ... // آیا این محصول فقط در فروشگاه باشگاه موجود است public bool IsClubExclusive { get; set; } // درصد تخفیف باشگاه (0 تا 100) public int ClubDiscountPercent { get; set; } // ... } ``` ### ۳.۴ `UserWalletChangeLog` افزودن نوع جدید تراکنش: ```csharp public enum TransactionType { // ... NetworkCommission = 10, // دریافت کمیسیون شبکه ClubActivation = 11, // فعال‌سازی عضویت باشگاه DiscountWalletCharge = 12, // شارژ کیف پول تخفیف } ``` --- ## ۴. معماری ماژول‌های جدید (Application / CQRS) ### ۴.۱ `ClubMembershipCQ/` #### Commands - **ActivateClubMembership**: فعال‌سازی عضویت باشگاه (کسر ۲۵ میلیون و اضافه به استخر) - **DeactivateClubMembership**: غیرفعال‌سازی عضویت - **UpdateClubMembership**: به‌روزرسانی اطلاعات عضویت #### Queries - **GetUserClubStatus**: دریافت وضعیت عضویت کاربر - **GetAllClubMembersByFilter**: لیست اعضای باشگاه با فیلتر ### ۴.۲ `ClubFeatureCQ/` #### Commands - **CreateClubFeature**: ایجاد فیچر جدید - **UpdateClubFeature**: ویرایش فیچر - **DeleteClubFeature**: حذف فیچر - **GrantFeatureToUser**: فعال‌سازی فیچر برای کاربر - **RevokeFeatureFromUser**: غیرفعال‌سازی فیچر از کاربر #### Queries - **GetAllClubFeatures**: لیست تمام فیچرها - **GetUserClubFeatures**: لیست فیچرهای فعال یک کاربر ### ۴.۳ `NetworkBalanceCQ/` #### Commands - **RecordNetworkJoin**: ثبت ورود کاربر به شبکه باینری (تعیین والد و شاخه) - حتماً باید یک رکورد در `NetworkMembershipHistory` ایجاد کند. - **UpdateNetworkPosition**: تغییر موقعیت در شبکه (مدیریتی) - هر تغییر، یک رکورد History. - **CalculateWeeklyBalances**: محاسبه تعادل‌های هفتگی (فراخوانی از Worker) #### Queries - **GetUserNetworkTree**: دریافت درخت زیرمجموعه‌های کاربر (چند سطح) - **GetUserWeeklyBalances**: دریافت تعادل‌های هفتگی یک کاربر - **GetNetworkStatistics**: آمار کلی شبکه (تعداد اعضا، عمق، تعادل) ### ۴.۴ `CommissionPoolCQ/` #### Commands - **InitializeWeeklyPool**: ایجاد استخر جدید برای هفته - **AddToWeeklyPool**: افزودن مبلغ به استخر هفتگی (هنگام فعال‌سازی عضویت) - **CalculatePoolValue**: محاسبه ارزش هر امتیاز - **DistributeCommissions**: توزیع کمیسیون‌ها به کاربران (Worker) - **CloseWeeklyPool**: بستن استخر پس از توزیع #### Queries - **GetCurrentWeekPool**: دریافت اطلاعات استخر هفته جاری - **GetPoolHistory**: تاریخچه استخرهای قبلی با فیلتر ### ۴.۵ `CommissionPayoutCQ/` #### Commands - **CreatePayoutRecord**: ثبت پرداخت کمیسیون (اتوماتیک از Worker) - همراه با ایجاد رکورد در `CommissionPayoutHistory` (Action = Created). - **RequestWithdrawal**: درخواست برداشت کمیسیون (نقدی یا الماس) - History با Action = WithdrawRequested. - **ProcessWithdrawal**: پردازش درخواست برداشت (تایید/رد ادمین) - تغییر Status + History. - **CancelPayout**: لغو پرداخت #### Queries - **GetUserCommissionHistory**: تاریخچه کمیسیون‌های دریافتی کاربر - **GetPendingWithdrawals**: لیست درخواست‌های برداشت در انتظار (برای ادمین) - **GetCommissionSummary**: خلاصه درآمد کمیسیون (مجموع، ماهانه، سالانه) ### ۴.۶ `ConfigurationCQ/` #### Commands - **SetConfigurationValue**: ثبت/ویرایش یک تنظیم (SystemConfiguration) - هر تغییر باید در `SystemConfigurationHistory` ثبت شود. - **DeactivateConfiguration**: غیرفعال‌سازی یک تنظیم #### Queries - **GetConfigurationValue**: دریافت مقدار یک Key - **GetConfigurationByScope**: لیست تنظیمات یک Scope (مثلاً Network) --- ## ۵. Background Worker/Job (محاسبات هفتگی) ### ۵.۱ `WeeklyNetworkCommissionWorker` **زمان‌بندی**: هر یکشنبه ساعت ۲۳:۵۹ (یا دوشنبه ۰۰:۰۱) **مراحل اجرایی (High-level):** #### گام ۱: بستن هفته قبل و ایجاد استخر جدید ```csharp var currentWeek = GetCurrentWeekNumber(); // مثلاً "2025-W48" var previousWeek = GetPreviousWeekNumber(); await CloseWeeklyPool(previousWeek); await InitializeWeeklyPool(currentWeek); ``` #### گام ۲: محاسبه تعادل‌های شبکه ```csharp var maxBalancesPerUser = GetConfig("MaxWeeklyBalancesPerUser", scope: ConfigurationScope.Network); var activeMembers = await GetActiveClubMembers(); foreach (var member in activeMembers) { var leftBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Left, previousWeek); var rightBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Right, previousWeek); var totalBalances = Math.Min(leftBalances, rightBalances); // اعمال محدودیت کانفیگ (مثلاً حداکثر 300 تعادل برای هر کاربر) if (totalBalances > maxBalancesPerUser) totalBalances = maxBalancesPerUser; await RecordWeeklyBalance(new NetworkWeeklyBalance { UserId = member.UserId, WeekNumber = previousWeek, LeftLegBalances = leftBalances, RightLegBalances = rightBalances, TotalBalances = totalBalances, WeeklyPoolContribution = member.InitialContribution, CalculatedAt = DateTime.UtcNow }); } ``` #### الگوریتم بازگشتی محاسبه تعادل شاخه ```csharp private async Task CalculateLegBalances(long userId, NetworkLeg leg, string weekNumber) { var children = await GetNetworkChildren(userId, leg); int totalBalances = 0; foreach (var child in children) { var childMembership = await GetClubMembership(child.Id); if (childMembership != null && IsInWeek(childMembership.ActivatedAt, weekNumber)) { totalBalances++; } var childLeftBalances = await CalculateLegBalances(child.Id, NetworkLeg.Left, weekNumber); var childRightBalances = await CalculateLegBalances(child.Id, NetworkLeg.Right, weekNumber); totalBalances += Math.Min(childLeftBalances, childRightBalances); } return totalBalances; } ``` #### گام ۳: محاسبه استخر و ارزش امتیاز ```csharp var totalPoolAmount = await SumPoolContributions(previousWeek); var totalBalances = await SumTotalBalances(previousWeek); var valuePerBalance = totalBalances > 0 ? totalPoolAmount / totalBalances : 0; await UpdatePoolValue(previousWeek, totalPoolAmount, totalBalances, valuePerBalance); ``` #### گام ۴: توزیع کمیسیون‌ها ```csharp var weeklyBalances = await GetWeeklyBalances(previousWeek); foreach (var balance in weeklyBalances.Where(b => b.TotalBalances > 0)) { var payoutAmount = balance.TotalBalances * valuePerBalance; var payout = new UserCommissionPayout { UserId = balance.UserId, WeekNumber = previousWeek, BalancesEarned = balance.TotalBalances, ValuePerBalance = valuePerBalance, TotalAmount = payoutAmount, Status = CommissionPayoutStatus.Pending }; await CreatePayoutRecord(payout); // داخلش CommissionPayoutHistory هم ثبت می‌شود await AddToNetworkBalance(balance.UserId, payoutAmount); await RecordWalletChange(new UserWalletChangeLog { WalletId = balance.UserId, // PreviousBalance / AfterBalance پر می‌شود Amount = payoutAmount, TransactionType = TransactionType.NetworkCommission, ReferenceId = payout.Id.ToString() }); payout.Status = CommissionPayoutStatus.Paid; payout.PaidAt = DateTime.UtcNow; await UpdatePayout(payout); await AddCommissionHistory(payout, "Paid"); } ``` #### گام ۵: ریست تعادل‌ها ```csharp await ExpireWeeklyBalances(previousWeek); ``` --- ## ۶. لاجیک فروشگاه و سبد خرید ### ۶.۱ نمایش محصولات ```csharp var query = _context.Products.Where(p => !p.IsDeleted); if (!user.ClubMembership?.IsActive ?? true) { query = query.Where(p => !p.IsClubExclusive); } // اگر کاربر عضو است، قیمت با تخفیف باشگاه محاسبه می‌شود ``` ### ۶.۲ استفاده از کیف پول تخفیف در Checkout (خلاصه‌سازی شده – در کد اصلی از DiscountBalance استفاده می‌شود و ChangeLog ثبت می‌گردد.) --- ## ۷. سناریوی کامل فعال‌سازی عضویت ### مرحله ۱: شارژ اولیه ```text کاربر → پرداخت ۵۶ میلیون (دایا/درگاه) ↓ UserWallet.Balance += 56,000,000 UserWallet.DiscountBalance += 56,000,000 ``` ### مرحله ۲: فعال‌سازی عضویت ```text کاربر → کلیک روی دکمه «عضویت در باشگاه» ↓ API: ActivateClubMembership ↓ 1. ایجاد رکورد ClubMembership: - IsActive = true - InitialContribution = 25,000,000 2. افزودن به استخر هفتگی: - WeeklyCommissionPool.TotalPoolAmount += 25,000,000 3. تعیین موقعیت در شبکه: - User.NetworkParentId = والد - User.LegPosition = Left یا Right 4. ثبت ChangeLog برای استخر: - TransactionType = ClubActivation 5. ثبت ClubMembershipHistory: - Action = "Activated" ``` ### مرحله ۳: محاسبه هفتگی (Worker) (مطابق بخش ۵) ### مرحله ۴: برداشت کمیسیون ```text کاربر → درخواست برداشت ↓ API: RequestWithdrawal (Cash یا Diamond) ↓ ادمین → تایید درخواست ↓ 1. اگر Cash: - واریز به حساب بانکی - NetworkBalance -= مبلغ 2. اگر Diamond: - خرید الماس از دایا - NetworkBalance -= مبلغ ``` همراه با ثبت رکورد در `CommissionPayoutHistory` (Action = WithdrawRequested / Withdrawn). --- ## ۸. پروتوباف و gRPC Services ### ۸.۱ `clubmembership.proto` ```protobuf syntax = "proto3"; import "google/protobuf/timestamp.proto"; package clubmembership; service ClubMembershipService { rpc ActivateMembership (ActivateMembershipRequest) returns (ActivateMembershipResponse); rpc GetClubStatus (GetClubStatusRequest) returns (GetClubStatusResponse); rpc GrantFeature (GrantFeatureRequest) returns (GrantFeatureResponse); rpc GetUserFeatures (GetUserFeaturesRequest) returns (GetUserFeaturesResponse); } message ActivateMembershipRequest { int64 user_id = 1; int64 contribution_amount = 2; int64 network_parent_id = 3; NetworkLeg leg_position = 4; } message ActivateMembershipResponse { bool success = 1; string message = 2; ClubMembershipDto membership = 3; } message GetClubStatusRequest { int64 user_id = 1; } message GetClubStatusResponse { bool is_member = 1; ClubMembershipDto membership = 2; } message ClubMembershipDto { int64 id = 1; int64 user_id = 2; bool is_active = 3; google.protobuf.Timestamp activated_at = 4; int64 initial_contribution = 5; int64 total_earned = 6; } enum NetworkLeg { LEFT = 0; RIGHT = 1; } ``` ### ۸.۲ `networkbalance.proto` ```protobuf syntax = "proto3"; package networkbalance; service NetworkBalanceService { rpc GetNetworkTree (GetNetworkTreeRequest) returns (GetNetworkTreeResponse); rpc GetWeeklyBalances (GetWeeklyBalancesRequest) returns (GetWeeklyBalancesResponse); rpc GetNetworkStats (GetNetworkStatsRequest) returns (GetNetworkStatsResponse); } message GetNetworkTreeRequest { int64 user_id = 1; int32 max_depth = 2; } message GetNetworkTreeResponse { NetworkNodeDto root = 1; } message NetworkNodeDto { int64 user_id = 1; string full_name = 2; NetworkLeg leg_position = 3; bool is_active = 4; repeated NetworkNodeDto children = 5; } message GetWeeklyBalancesRequest { int64 user_id = 1; string week_number = 2; } message GetWeeklyBalancesResponse { int32 left_leg_balances = 1; int32 right_leg_balances = 2; int32 total_balances = 3; int64 pool_contribution = 4; } ``` ### ۸.۳ `commissionpayout.proto` ```protobuf syntax = "proto3"; import "google/protobuf/timestamp.proto"; package commissionpayout; service CommissionPayoutService { rpc RequestWithdrawal (RequestWithdrawalRequest) returns (RequestWithdrawalResponse); rpc GetCommissionHistory (GetCommissionHistoryRequest) returns (GetCommissionHistoryResponse); rpc GetPendingWithdrawals (GetPendingWithdrawalsRequest) returns (GetPendingWithdrawalsResponse); rpc ProcessWithdrawal (ProcessWithdrawalRequest) returns (ProcessWithdrawalResponse); } message RequestWithdrawalRequest { int64 user_id = 1; int64 amount = 2; WithdrawalMethod method = 3; string iban_number = 4; } message RequestWithdrawalResponse { bool success = 1; string message = 2; int64 request_id = 3; } message GetCommissionHistoryRequest { int64 user_id = 1; int32 page_number = 2; int32 page_size = 3; } message GetCommissionHistoryResponse { repeated CommissionPayoutDto payouts = 1; int32 total_count = 2; } message CommissionPayoutDto { int64 id = 1; string week_number = 2; int32 balances_earned = 3; int64 value_per_balance = 4; int64 total_amount = 5; CommissionPayoutStatus status = 6; google.protobuf.Timestamp paid_at = 7; WithdrawalMethod withdrawal_method = 8; } enum WithdrawalMethod { CASH = 0; DIAMOND = 1; } enum CommissionPayoutStatus { PENDING = 0; PAID = 1; WITHDRAW_REQUESTED = 2; WITHDRAWN = 3; CANCELLED = 4; } ``` --- ## ۹. نکات حیاتی و بهترین رویه‌ها ### ۹.۱ یکپارچگی شبکه باینری - هر کاربر حداکثر دو فرزند (یکی Left، یکی Right) - هنگام اضافه کردن فرزند، کنترل Race Condition - حذف کاربر نباید ساختار شبکه را خراب کند ### ۹.۲ Transaction Management - Worker باید تمام مراحل را در یک TransactionScope انجام دهد - در صورت شکست، Rollback کامل ### ۹.۳ Idempotency - محاسبه هفتگی برای یک WeekNumber فقط یک‌بار - بررسی `WeeklyCommissionPool.IsCalculated` قبل از شروع ### ۹.۴ Performance - Caching درخت شبکه برای کاربران پرحجم - Index روی `WeekNumber`, `UserId`, `NetworkParentId` ### ۹.۵ Audit و Compliance - همه تغییرات کیف پول در `UserWalletChangeLog` - همه پرداخت‌های کمیسیون در `UserCommissionPayout` + `CommissionPayoutHistory` - تغییرات شبکه در `NetworkMembershipHistory` - تغییرات تنظیمات در `SystemConfigurationHistory` ### ۹.۶ Security - محدودیت تعداد درخواست برداشت - تایید دو مرحله‌ای برای برداشت‌های بالا - Audit Log برای عملیات حساس --- ## ۱۰. مراحل پیاده‌سازی (Roadmap) (مطابق نسخه قبلی – فاز ۱ تا ۶) --- ## ۱۱. متریک‌های کلیدی (KPIs) - تعداد اعضای فعال باشگاه - مجموع کمیسیون‌های پرداختی هر ماه - میانگین تعادل هر کاربر در هفته - نرخ تبدیل به عضویت باشگاه - زمان اجرای Worker، تعداد خطاها، عمق درخت، حجم داده History و … --- ## ۱۲. سوالات متداول (FAQ) (همان سوالات قبلی + می‌توان سوالات مربوط به سقف تعادل و تنظیمات را اضافه کرد.) --- ## ۱۳. ضمیمه: مثال عددی کامل (مثال دو هفته‌ای A, B, C, D, E, F, G مثل نسخه قبلی.) --- ## ۱۴. مسیرهای مرتبط - Domain: `CMS/src/CMSMicroservice.Domain/Entities/` - Application: `CMS/src/CMSMicroservice.Application/ClubMembershipCQ/`, `NetworkBalanceCQ/`, `CommissionPoolCQ/`, `CommissionPayoutCQ/`, `ConfigurationCQ/` - Protobuf: `CMS/src/CMSMicroservice.Protobuf/Protos/` - Worker: `CMS/src/CMSMicroservice.Infrastructure/BackgroundJobs/` - مستند حاضر: `CMS/docs/network-club-commission-system.md` **نسخه**: 1.1 **تاریخ**: 2025-11-29 **نویسنده**: تیم توسعه CMS **وضعیت**: آماده پیاده‌سازی (با History و Config)