# 📦 سیستم مبتنی بر پکیج (Package-Based System) > **وضعیت:** پیاده‌سازی کد تکمیل — **فاز ۰-۱۰ ✅** | تست نهایی باقی‌مانده > **تاریخ بروزرسانی:** ۸ اسفند ۱۴۰۴ > **نسخه:** v7 (DataMigration + EF Staging + PackagePurchaseDialog + UI Fixes) > **تاثیرگذاری:** زیاد — **۱۰۰+** تغییر در ۶ لایه (۵۱ اصلی + ۴۴ سایدافکت + ۵ استقرار) > **کامیت‌ها:** `8b9c317` (Phase 0) → `ae92ab8` (Phase 1) → `a9cd2fd` (Phase 1.5) → `8e5c7c5` (Phase 2) → `ccb938e` (Phase 3) → `0002a5a` (Phase 4) → `a1024a3` (Q24+Q26) → `fdbb91d` (Q27) → `10d2ca2` (Rename+Interceptor+Migration) → `a3681a8` (PackagePurchaseDialog) → `3c1a8ff` (UI Fixes) --- ## ۱. خلاصه فیچر **وضعیت فعلی:** سیستم فقط یک پکیج پایه (۵۶ میلیون تومان) دارد و همه چیز حول آن hardcode شده. **وضعیت هدف:** سیستم چندین پکیج با قیمت‌ها و ویژگی‌های متفاوت پشتیبانی می‌کند. هر پکیج مقادیر مالی، فیچرها و Commission Pool مستقل خود را دارد. کاربر می‌تواند **N بار** پکیج بخرد (بعد از تکمیل چرخه Magic Wallet). --- ## ۲. تصمیمات تایید‌شده | # | سوال | تصمیم | |---|------|-------| | Q1 | باگ DiscountBalance در VerifyGoldenPackagePurchase | ✅ **باگه — باید فیکس بشه** | | Q2 | سه مسیر پرداخت موازی | ✅ **ادغام به سرویس Generic** — نه به نام پکیج خاص | | Q3 | پکیج‌های اولیه | ✅ **نقره‌ای (۵.۶M) + پایه (۵۶M)** — سیستم داینامیک | | Q4 | ActivationFee و GiftValue | ✅ **یک فیلد (ActivationFee)** — GiftValue حذف (تکراری بود) | | Q5 | DiscountMultiplier | ✅ **×2 برای همه** — ولی داینامیک در entity | | Q6 | Migration کاربران فعلی | ✅ **Pipeline/Script** — کاربران فعلی → PackageId = پکیج پایه | | Q7 | خرید چند پکیج | ✅ **N بار** — بعد تکمیل Magic Wallet و صفر شدن Balance | | Q8 | Commission Pool | ✅ **جدا برای هر پکیج** — تمام جداول Commission با PackageId | | Q9 | MagicWallet Multiplier | ✅ **داینامیک** به‌ازای هر پکیج (فعلاً همه ×2.5) | | Q10 | کاربران دایا | ✅ **پکیج پایه** گرفتن — "طلایی" اشتباه نام‌گذاری بوده | | Q11 | فیچرها | ✅ **داینامیک** — ادمین مدیریت می‌کند | | Q12 | MaxWeeklyBalancesPerLeg (300) | ✅ **per-package** — پکیج نقره‌ای ۳۰، پکیج پایه ۳۰۰ | | Q13 | MaxNetworkLevel (15) | ✅ **per-package** — ادمین تنظیم کند | | Q14 | MagicWalletMaxDeposit (1B) | ✅ **per-package** — سقف شارژ بر اساس پکیج | | Q15 | MagicWalletMaxCredit (2.5B) | ✅ **per-package** — سقف اعتبار بر اساس پکیج | | Q16 | NetworkWeeklyBalance + PackageId | ✅ **هر رکورد تعادل = per-package** — carryover هم جداگانه | | Q17 | گزارش پورسانت FO | ✅ **breakdown per-package** — مشتری ببیند از هر پکیج چقدر | | Q18 | گزارش پورسانت BO | ✅ **فیلتر بر اساس پکیج** — ادمین بر اساس پکیج فیلتر کند | | Q19 | قرارداد باشگاه — چند بار؟ | ✅ **فقط یک بار در طول عمر** — قرارداد فقط اولین خرید امضا می‌شود. خرید مجدد بدون قرارداد | | Q20 | فیچرها در خرید مجدد — چطور؟ | ✅ **DIFF/تفاضل** — مقایسه فیچرهای فعلی با پکیج جدید. فقط اختلاف اعمال می‌شود | | Q21 | ردیابی فعال‌سازی ClubMembership | ✅ **FirstActivation + LastActivation** — ۴ فیلد: `FirstActivationDate` + `FirstPackageId` + `LastActivationDate` + `LastPackageId` | | Q22 | تشخیص فعال‌شدگان هفته | ✅ **از `LastActivationDate`** — هر کسی که `LastActivationDate` در بازه هفته باشد | | Q23 | Carryover تعادل هفتگی | ✅ **per-downline-package** — تعادل بر اساس پکیج **زیرمجموعه‌ها** (نه پکیج خود کاربر). هر کاربر N رکورد تعادل دارد. تغییر پکیج خود کاربر تاثیری بر carryover ندارد | | Q24 | آستانه موجودی برای ورود Magic و خرید مجدد | ✅ **کمتر از ۱۰۰,۰۰۰ تومان** — چون قیمت محصولات متفاوته، `Balance == 0` عملاً غیرممکنه. آستانه ثابت ۱,۰۰۰,۰۰۰ ریال | | Q25 | DayaLoans محدودیت پکیج | ✅ **فقط پکیج پایه** — `SupportsDayaPurchase` فقط روی پکیج پایه `true` هست. تغییر نمی‌کنه | | Q26 | مدیریت Stored Procedures | ✅ **SP Worker (IHostedService)** — در startup، فایل‌های `.sql` از embedded resource خوانده و با checksum مقایسه و اعمال می‌شوند | | Q27 | History Tables یکسان‌سازی | ✅ **نام‌گذاری مشابه master** + ثبت خودکار تغییرات در EF interceptor/domain events | | Q28 | UI Guidance (آموزش/هشدار) | ✅ **مودال + متن inline** — در سراسر FO/BO توضیحات آموزشی و هشداری برای سیستم پکیج‌بیس | | Q29 | شرط EXIT Magic | ✅ **آخرین پکیج فعال** — از `ClubMembershipCycle.PackageId` (چرخه فعلی) → `Package.MagicWalletMaxDeposit` | | Q30 | Carryover توضیح | ✅ **باقی‌مانده تعادل هفتگی** — بر اساس پکیج **زیرمجموعه‌ها**. تغییر پکیج خود کاربر carryover را ریست **نمی‌کند**. هر Pool مجزا | --- ## ۳. تحلیل عمیق وضعیت فعلی (AS-IS) ### ۳.۱ باگ‌های کشف‌شده | # | باگ | شدت | فایل | |---|-----|------|------| | **B1** | `VerifyGoldenPackagePurchase` → **DiscountBalance شارژ نمی‌شود** | 🔴 بحرانی | VerifyGoldenPackagePurchaseCommandHandler.cs | | **B2** | `VerifyGoldenPackagePurchase` → **UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyGoldenPackagePurchaseCommandHandler.cs | | **B3** | `VerifyPackagePurchase` → **UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyPackagePurchaseCommandHandler.cs | | **B4** | `VerifyBasePackagePayment` → **UserPackagePurchase ساخته نمی‌شود** | 🔴 بحرانی | VerifyBasePackagePaymentCommandHandler.cs | | **B5** | `PackageService.CustomerPurchasePackage` → **guard برای خرید تکراری ندارد** | 🟡 متوسط | PackageService.cs | | **B6** | EXIT Magic Mode → **PackagePurchaseMethod ریست نمی‌شود** (خرید مجدد مسدود) | 🔴 بحرانی | UserOrderService.cs | ### ۳.۲ ناسازگاری مسیرهای پرداخت | مسیر | Balance | Discount | UserPackagePurchase | WalletChangeLog | |------|---------|----------|---------------------|-----------------| | **BFF/PYMS** (InitiateBase→VerifyBase) | ✅ | ✅ | ❌ | ✅ | | **ZarinPal Golden** (PurchaseGolden→VerifyGolden) | ✅ | ❌ | ❌ | ✅ (فقط Balance) | | **ZarinPal Generic** (Purchase→VerifyPurchase) | ✅ | ✅ | ❌ | ✅ | | **Daya Loan** (CheckAndProcess) | ✅ | ✅ | ✅ | ✅ | | **Manual** (CreateManualPayment) | ✅ | ✅ | ❌ | ✅ | > **فقط Daya Loan** همه مراحل را کامل انجام می‌دهد. بقیه مسیرها ناقص هستند. ### ۳.۳ مقادیر Hardcoded (۱۵ مورد) | # | مکان | مقدار | باید بشه | |---|------|-------|----------| | H1 | `InitiateBasePackagePaymentCommandHandler` | `BasePackageId = 4` | خوانش از پکیج فعال | | H2 | `CheckAndProcessDayaLoansCommandHandler` | `p.Id == 4` | خوانش از پکیج پایه | | H3 | `SystemConstants.BasePackageAmount` | `56_000_000` | `Package.Price` | | H4 | `SystemConstants.DayaLoanAmount` | `56_000_000` | `Package.Price` | | H5 | `SystemConstants.ClubActivationFee` | `25_200_000` | `Package.ActivationFee` | | H6 | `SystemConstants.ClubMembershipGiftValue` | `25_200_000` | حذف (= ActivationFee) | | H7 | `VerifyPackagePurchaseCommandHandler` | `Amount * 2` (×3 جا) | `Package.DiscountMultiplier` | | H8 | `VerifyBasePackagePaymentCommandHandler` | `BasePackageAmount * 2` (×2) | `Package.DiscountMultiplier` | | H9 | `CreateManualPaymentCommandHandler` | `BasePackageAmount * 2` | `Package.DiscountMultiplier` | | H10 | `CheckAndProcessDayaLoansCommandHandler` | `DayaLoanAmount * 2` | `Package.DiscountMultiplier` | | H11 | `ActivateClubMembershipCommandHandler` | `PackageAmount = BasePackageAmount` | `Package.Price` | | H12 | `PurchaseGoldenPackageCommandHandler` | `Title.Contains("طلایی")` | حذف — Generic | | H13 | `ActivateClubMembershipCommandHandler` | `GetAllFeatureIds()` | `Package.PackageFeatures` | | H14 | `ActivationSection.razor` | `56_000_000 × months` | از پکیج خوانده شود | | H15 | `ClubMembershipContractDialog.razor` | متن قرارداد ۵۶M | داینامیک از پکیج | ### ۳.۴ Guardهای مسدودکننده خرید مجدد | # | فایل | Guard | وضعیت | تغییر | |---|------|-------|-------|-------| | G1 | `PurchaseGoldenPackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه | | G2 | `InitiateBasePackagePaymentCommandHandler` | `PackagePurchaseMethod != None` → fail | مسدود | ✅ اجازه بعد تکمیل چرخه | | G3 | `PurchasePackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه | | G4 | `CheckAndProcessDayaLoansCommandHandler` | `hasPreviousCycle` → skip | عمدی ✅ | ❌ حفظ (دایا فقط دور اول) | | G5 | `AcceptClubMembershipContractCommandHandler` | `IsActive == true` → fail | مسدود | ✅ حفظ — قرارداد فقط یک بار (Q19). خرید مجدد → Skip قرارداد | | G6 | `ActivateClubMembershipCommandHandler` | `IsActive == true` → return true | short-circuit | ✅ باید چرخه جدید بسازه + فیچر DIFF (Q20) | | G7 | JWT Claim `HasPurchasedPackage` | permanent true | UI مسدود | ✅ اضافه `CanRepurchase` | ### ۳.۵ Root Cause — خرید مجدد کار نمی‌کند ``` EXIT Magic Mode (UserOrderService.cs): ✅ wallet.WalletMode = Normal ✅ wallet.MagicCompletedAt = now ✅ cycle.MagicCompletedAt = now ❌ MISSING: user.PackagePurchaseMethod = None ← Guards G1-G3 مسدود می‌مانند ❌ MISSING: cycle.IsCurrentCycle = false ← آماده چرخه جدید ❌ MISSING: JWT CanRepurchase = true ← UI دکمه خرید نشان نمی‌دهد ℹ️ membership.IsActive حفظ می‌شود (قرارداد یک‌بار — Q19) ℹ️ فیچرها حفظ می‌شوند — در خرید مجدد DIFF اعمال می‌شود (Q20) ``` **راه‌حل:** در EXIT Magic Mode، وضعیت کاربر ریست شود تا بتواند پکیج جدید بخرد. --- ## ۴. طراحی نهایی (TO-BE) ### ۴.۱ تغییرات Entity — Package ```csharp public class Package : BaseAuditableEntity { // === فیلدهای فعلی (حفظ) === public string Title { get; set; } public string Description { get; set; } public string ImagePath { get; set; } public long Price { get; set; } // قیمت پکیج (ریال) // === فیلدهای جدید === public int SortOrder { get; set; } // ترتیب نمایش public bool IsActive { get; set; } = true; // فعال/غیرفعال public bool IsBasePackage { get; set; } // پکیج پایه؟ (فقط یکی true) public bool SupportsDayaPurchase { get; set; } // پشتیبانی از خرید دایا public bool SupportsDirectPurchase { get; set; } = true; // پشتیبانی از پرداخت مستقیم // === محاسبات مالی === public long ActivationFee { get; set; } // سهم Commission Pool public decimal DiscountMultiplier { get; set; } = 2.0m; // ضریب شارژ DiscountBalance public decimal MagicWalletMultiplier { get; set; } = 2.5m; // ضریب کیف‌پول جادویی // === تنظیمات پورسانت (v3 — انتقال از SystemConstants) === public int MaxBalancesPerLeg { get; set; } = 300; // سقف تعادل هر پا (نقره‌ای=۳۰) public int MaxNetworkLevel { get; set; } = 15; // عمق شبکه برای محاسبه // === تنظیمات کیف‌پول جادویی (v3) === public long MagicWalletMaxDeposit { get; set; } = 1_000_000_000; // سقف شارژ public long MagicWalletMaxCredit { get; set; } = 2_500_000_000; // سقف اعتبار // === Navigation === public virtual ICollection PackageFeatures { get; set; } public virtual ICollection Purchases { get; set; } public virtual ICollection UserOrders { get; set; } } ``` **نسبت به v1:** - ❌ `GiftValue` حذف (= ActivationFee — تکراری) - ✅ `MagicWalletMultiplier` اضافه (داینامیک) - ❌ `PackageType` enum نیاز نیست (`IsBasePackage` کافیست) ### ۴.۲ Entity جدید — PackageFeature ```csharp public class PackageFeature : BaseAuditableEntity { public long PackageId { get; set; } public virtual Package Package { get; set; } public long ClubFeatureId { get; set; } public virtual ClubFeature ClubFeature { get; set; } public bool IsIncluded { get; set; } = true; } ``` ### ۴.۳ تغییرات Entity — سایر | Entity | فیلد جدید | توضیح | |--------|-----------|-------| | `ClubMembership` | `DateTime FirstActivationDate` | اولین فعال‌سازی — فقط یک بار ست می‌شود (v5 — Q21) | | `ClubMembership` | `long FirstPackageId` + FK | اولین پکیج خریداری‌شده (v5 — Q21) | | `ClubMembership` | `DateTime LastActivationDate` | آخرین/جاری فعال‌سازی — هر خرید بروزرسانی می‌شود (v5 — Q21) | | `ClubMembership` | `long LastPackageId` + FK | آخرین/جاری پکیج (v5 — Q21) | | `ClubMembership` | حذف `ActivatedAt` | ← جایگزین با First/LastActivationDate | | `ClubMembershipCycle` | `long PackageId` + FK | پکیج این چرخه | | `WeeklyCommissionPool` | `long PackageId` + FK | Pool جداگانه هر پکیج | | `UserCommissionPayout` | `long PackageId` + FK | از کدام Pool | | **`NetworkWeeklyBalance`** | **`long PackageId` + FK** ← v3 | **هر رکورد تعادل = per-package** | > **تغییر v5:** `ClubMembership.ActivatedAt` حذف شد و به دو فیلد `FirstActivationDate` و `LastActivationDate` تبدیل شد. > `FirstActivationDate` فقط در اولین فعال‌سازی ست می‌شود و هیچ‌وقت تغییر نمی‌کند. > `LastActivationDate` در هر خرید مجدد بروزرسانی می‌شود و مبنای تشخیص "فعال‌شدگان این هفته" است (Q22). **ClubMembership entity (v5):** ```csharp public class ClubMembership : BaseAuditableEntity { public long UserId { get; set; } public virtual User User { get; set; } public bool IsActive { get; set; } // === v5: First/Last Activation Tracking (Q21) === /// /// اولین فعال‌سازی — فقط یک بار ست می‌شود، هیچ‌وقت overwrite نمی‌شود /// public DateTime FirstActivationDate { get; set; } /// /// اولین پکیج خریداری‌شده — فقط یک بار ست می‌شود /// public long FirstPackageId { get; set; } public virtual Package FirstPackage { get; set; } /// /// آخرین/جاری فعال‌سازی — هر خرید مجدد بروزرسانی می‌شود /// مبنای تشخیص "فعال‌شدگان این هفته" (Q22) /// public DateTime LastActivationDate { get; set; } /// /// آخرین/جاری پکیج — هر خرید مجدد بروزرسانی می‌شود /// مبنای carryover per-package (Q23) /// public long LastPackageId { get; set; } public virtual Package LastPackage { get; set; } // === فیلدهای فعلی (حفظ) === public long InitialContribution { get; set; } public long GiftValue { get; set; } public long TotalEarned { get; set; } public PackagePurchaseMethod PurchaseMethod { get; set; } // === Navigation === public virtual ICollection? UserClubFeatures { get; set; } public virtual ICollection? ClubMembershipHistories { get; set; } public virtual ICollection? Cycles { get; set; } } ``` **Constraintهای جدید:** - `WeeklyCommissionPool` → Unique(`WeekDefinitionId`, `PackageId`) - `NetworkWeeklyBalance` → Unique(`UserId`, `WeekDefinitionId`, `PackageId`) ← v3 - `UserCommissionPayout` → Unique(`UserId`, `WeekDefinitionId`, `PackageId`) ← v3 > ⚠️ **تاثیر حجم داده:** رکوردهای `NetworkWeeklyBalance` ضربدر تعداد پکیج‌های فعال می‌شوند. مثلاً ۱۰۰۰ کاربر × ۲ پکیج = ۲۰۰۰ رکورد تعادل هفتگی (بجای ۱۰۰۰). ### ۴.۴ حذف/تغییر SystemConstants | ثابت | تغییر | جایگزین | |------|-------|---------| | `ClubMembershipGiftValue` | ❌ حذف | تکراری بود | | `ClubActivationFee` | ❌ حذف | `Package.ActivationFee` | | `BasePackageAmount` | ❌ حذف | `Package.Price` | | `DayaLoanAmount` | ❌ حذف | `Package.Price` (base) | | `MagicWalletMultiplier` | ❌ حذف | `Package.MagicWalletMultiplier` | | `CommissionMaxWeeklyBalancesPerLeg` | ❌ **حذف** ← تغییر از v2 | `Package.MaxBalancesPerLeg` (نقره‌ای=۳۰، پایه=۳۰۰) | | `CommissionMaxNetworkLevel` | ❌ **حذف** ← تغییر از v2 | `Package.MaxNetworkLevel` (قابل تنظیم ادمین) | | `MagicWalletMaxDeposit` | ❌ **حذف** ← جدید v3 | `Package.MagicWalletMaxDeposit` (سقف شارژ) | | `MagicWalletMaxCredit` | ❌ **حذف** ← جدید v3 | `Package.MagicWalletMaxCredit` (سقف اعتبار) | | `ShopVAT` | ✅ حفظ | عمومی | | `CommissionCalculationStrategy` | ✅ حفظ | عمومی (ORM or SP) | | `AllowDeletingParentsWithChildren` | ✅ حفظ | عمومی | | `MaxDirectChildrenPerLeg` | ✅ حفظ | عمومی | | `IsCommissionWithdrawalEnabled` | ✅ حفظ | عمومی | | `CommissionMinWithdrawalAmount` | ✅ حفظ | عمومی | | `IsMaintenanceMode` | ✅ حفظ | عمومی | | `IsAuditLogEnabled` | ✅ حفظ | عمومی | | `IsVATEnabled` | ✅ حفظ | عمومی | ### ۴.۵ فرمول مالی ``` ActivationFee = Price × 0.45 پکیج نقره‌ای (۵,۶۰۰,۰۰۰ ریال): ├── Balance += ۵,۶۰۰,۰۰۰ (Price) ├── DiscountBalance += ۱۱,۲۰۰,۰۰۰ (Price × DiscountMultiplier) └── CommissionPool += ۲,۵۲۰,۰۰۰ (ActivationFee) پکیج پایه (۵۶,۰۰۰,۰۰۰ ریال): ├── Balance += ۵۶,۰۰۰,۰۰۰ (Price) ├── DiscountBalance += ۱۱۲,۰۰۰,۰۰۰ (Price × DiscountMultiplier) └── CommissionPool += ۲۵,۲۰۰,۰۰۰ (ActivationFee) ``` --- ## ۵. فلوی خرید مجدد (Re-Purchase) ### ۵.۱ چرخه حیات کامل ```mermaid stateDiagram-v2 [*] --> NoPurchase: کاربر ثبت‌نام کرده NoPurchase --> PackagePurchased: خرید پکیج\n(هر پکیجی) PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد — فقط بار اول) ClubActivated --> Shopping: خرج Balance\nدر فروشگاه Shopping --> MagicMode: Balance == 0 MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف per-package) MagicCharging --> MagicMode: ادامه MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ MaxDeposit CycleComplete --> RePurchase: ریست PackagePurchaseMethod\n(membership فعال باقی — Q19) RePurchase --> ClubActivated: خرید مجدد\n(بدون قرارداد + فیچر DIFF) ``` ### ۵.۲ ریست وضعیت بعد تکمیل چرخه (EXIT Magic Mode) ```csharp // UserOrderService.cs — EXIT Magic Mode — تغییرات لازم: wallet.WalletMode = WalletMode.Normal; wallet.MagicCompletedAt = DateTime.UtcNow; cycle.MagicCompletedAt = DateTime.UtcNow; // ✅ اضافه شود: user.PackagePurchaseMethod = PackagePurchaseMethod.None; // اجازه خرید مجدد (G1-G3) cycle.IsCurrentCycle = false; // آماده چرخه جدید // ❌ membership.IsActive حفظ می‌شود! (قرارداد یک‌بار — Q19) // ❌ فیچرها حفظ می‌شوند! (DIFF در خرید بعدی اعمال می‌شود — Q20) ``` > **تفاوت v5 با v4:** `membership.IsActive = false` حذف شد. قرارداد فقط یک بار امضا می‌شود و عضویت فعال باقی می‌ماند. در خرید مجدد، فقط فیچر DIFF + شارژ wallet + Commission انجام می‌شود. ### ۵.۳ نکات مهم 1. **دایا فقط دور اول** — بعد از دور اول، فقط IPG مجاز 2. **هر خرید = Commission contribution** — ActivationFee به Pool آن پکیج 3. **PackagePurchaseMethod ریست** بعد تکمیل چرخه 4. **فیچرها بر اساس پکیج جدید — DIFF/تفاضل اعمال می‌شود (Q20)** ### ۵.۴ قرارداد باشگاه — فقط یک بار (v5 — Q19) > ⚠️ **تغییر بنیادی v5:** قرارداد باشگاه مشتریان **فقط یک بار** در طول عمر کاربر امضا می‌شود. ``` سناریو: خرید اول (پکیج پایه): ✅ OTP + امضای قرارداد + فعال‌سازی باشگاه ✅ فیچرهای پکیج پایه فعال ✅ قرارداد ثبت شد → UserContract record خرید دوم (پکیج پایه مجدد): ✅ پرداخت + شارژ wallet + Commission ❌ قرارداد مجدد نمی‌شود (Q19) ✅ فیچرها بدون تغییر (همان پکیج) خرید سوم (پکیج نقره‌ای): ✅ پرداخت + شارژ wallet + Commission ❌ قرارداد مجدد نمی‌شود (Q19) ✅ فیچر DIFF: حذف فیچرهای پایه‌ای که نقره‌ای ندارد + اضافه فیچرهای جدید نقره‌ای ``` **فلوی خرید مجدد (بدون قرارداد):** ```mermaid flowchart TD A["EXIT Magic Mode\n(چرخه قبلی تکمیل شد)"] --> B["ریست PackagePurchaseMethod=None\n+ JWT: CanRepurchase=true"] B --> C["کاربر پکیج جدید انتخاب می‌کند"] C --> D["پرداخت\n(IPG / دایا دور اول)"] D --> E["Verify Payment"] E --> F["شارژ Balance + DiscountBalance"] F --> G{"قرارداد دارد؟\n(UserContract exists?)"} G -->|بله — Skip| H["فیچر DIFF (Q20)"] G -->|خیر — اولین خرید| I["OTP + امضای قرارداد"] I --> H H --> J["بروزرسانی ClubMembership\nLastActivationDate + LastPackageId"] J --> K["ساخت ClubMembershipCycle جدید"] K --> L["ActivationFee → Pool پکیج"] ``` ### ۵.۵ الگوریتم DIFF فیچرها (v5 — Q20) > ⚠️ **فیچرها بر اساس تفاضل (DIFF) اعمال می‌شوند — نه تخصیص کامل مجدد.** ``` ورودی: currentFeatures = UserClubFeatures WHERE UserId = X AND IsActive = true newFeatures = PackageFeatures WHERE PackageId = newPackage.Id AND IsIncluded = true الگوریتم: toAdd = newFeatures - currentFeatures (فیچرهای جدید که قبلاً نداشت) toRemove = currentFeatures - newFeatures (فیچرهای قبلی که پکیج جدید ندارد) toKeep = currentFeatures ∩ newFeatures (مشترک — بدون تغییر) اقدامات: foreach feature in toAdd: INSERT UserClubFeature (UserId, ClubFeatureId, IsActive=true) Notes = "اضافه شده بابت خرید پکیج [نام پکیج]" foreach feature in toRemove: UPDATE UserClubFeature SET IsActive = false Notes = "حذف شده بابت تغییر از پکیج [قبلی] به [جدید]" foreach feature in toKeep: بدون تغییر — فیچر فعال باقی می‌ماند ``` **مثال عملی:** ``` پکیج پایه فیچرها: [چاتیکا, بیمه, سفر, آموزش] پکیج نقره‌ای فیچرها: [چاتیکا, بیمه] کاربر "علی" (فعلاً پکیج پایه) → خرید پکیج نقره‌ای: currentFeatures = [چاتیکا, بیمه, سفر, آموزش] newFeatures = [چاتیکا, بیمه] toKeep = [چاتیکا, بیمه] → بدون تغییر toRemove = [سفر, آموزش] → IsActive = false toAdd = [] → هیچ فیچر جدیدی نیست کاربر "سارا" (فعلاً پکیج نقره‌ای) → خرید پکیج پایه: currentFeatures = [چاتیکا, بیمه] newFeatures = [چاتیکا, بیمه, سفر, آموزش] toKeep = [چاتیکا, بیمه] → بدون تغییر toRemove = [] → هیچی حذف نمی‌شه toAdd = [سفر, آموزش] → UserClubFeature جدید ساخته می‌شه ``` **کد پیشنهادی:** ```csharp // FeatureDiffService.cs — لاجیک DIFF فیچرها public async Task ApplyFeatureDiffAsync( long userId, long clubMembershipId, long oldPackageId, long newPackageId, CancellationToken ct) { // 1. فیچرهای فعال فعلی کاربر var currentFeatureIds = await _context.UserClubFeatures .Where(ucf => ucf.UserId == userId && ucf.IsActive) .Select(ucf => ucf.ClubFeatureId) .ToListAsync(ct); // 2. فیچرهای پکیج جدید var newFeatureIds = await _context.PackageFeatures .Where(pf => pf.PackageId == newPackageId && pf.IsIncluded) .Select(pf => pf.ClubFeatureId) .ToListAsync(ct); // 3. DIFF var toAdd = newFeatureIds.Except(currentFeatureIds).ToList(); var toRemove = currentFeatureIds.Except(newFeatureIds).ToList(); // 4. حذف فیچرهای قدیمی if (toRemove.Any()) { var featuresToDeactivate = await _context.UserClubFeatures .Where(ucf => ucf.UserId == userId && ucf.IsActive && toRemove.Contains(ucf.ClubFeatureId)) .ToListAsync(ct); foreach (var f in featuresToDeactivate) { f.IsActive = false; f.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {oldPackageId} → {newPackageId})"; } } // 5. اضافه فیچرهای جدید if (toAdd.Any()) { var newFeatures = toAdd.Select(featureId => new UserClubFeature { UserId = userId, ClubMembershipId = clubMembershipId, ClubFeatureId = featureId, GrantedAt = DateTime.Now, IsActive = true, Notes = $"اضافه شده بابت خرید پکیج (PackageId: {newPackageId})" }).ToList(); _context.UserClubFeatures.AddRange(newFeatures); } await _context.SaveChangesAsync(ct); _logger.LogInformation( "Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}", userId, toAdd.Count, toRemove.Count, currentFeatureIds.Count - toRemove.Count); } ``` > **نکته مهم:** در اولین خرید (`currentFeatures` خالی)، تمام فیچرهای پکیج اضافه می‌شوند. > در خرید مجدد **همان پکیج**، DIFF خالی است و هیچ تغییری در فیچرها نمی‌شود. ### ۵.۶ تشخیص فعال‌شدگان هفته (v5 — Q22) ```sql -- کاربرانی که در هفته جاری فعال/خرید مجدد کرده‌اند: SELECT cm."UserId", cm."LastPackageId", cm."LastActivationDate", p."Title" AS PackageTitle FROM "CMS"."ClubMemberships" cm JOIN "CMS"."Packages" p ON p."Id" = cm."LastPackageId" WHERE cm."IsActive" = true AND cm."LastActivationDate" >= @WeekStartDate AND cm."LastActivationDate" < @WeekEndDate; -- اولین فعال‌سازی (عمر کاربر): SELECT cm."FirstActivationDate", cm."FirstPackageId" FROM "CMS"."ClubMemberships" cm WHERE cm."UserId" = @UserId; -- تعداد خرید (از تاریخچه): SELECT COUNT(*) AS TotalPurchases FROM "CMS"."ClubMembershipCycles" c WHERE c."UserId" = @UserId; ``` --- ## ۶. Commission Pool — تغییرات ### ۶.۱ ساختار جدید ``` هفته ۱: Pool_نقره‌ای (PackageId=X): TotalAmount = Σ ActivationFee نقره‌ای Pool_پایه (PackageId=Y): TotalAmount = Σ ActivationFee پایه هر Pool مستقل: ValuePerBalance = TotalPoolAmount ÷ TotalBalances (فقط کاربران همان پکیج) ``` ### ۶.۲ ساختار شبکه یکی‌ست ``` [Ali - پایه] / \ [Sara - نقره‌ای] [Reza - پایه] Commission (بالاسری Ali): Ali → از Pool_پایه (چون Reza پکیج پایه داره) Ali → از Pool_نقره‌ای (چون Sara پکیج نقره‌ای داره) → مجموع هر دو Pool → کیف پول شبکه Ali → به تفکیک: X از پایه، Y از نقره‌ای ⚠️ پکیج خود Ali مهم نیست! مهم پکیج زیرمجموعه‌هاست ``` ### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v5) ``` چرخه ۱ — کاربر "علی" پکیج پایه می‌خرد (۵۶M) — اولین خرید: ──────────────────────────────────────────────────────────────── ✅ Balance += 56M ✅ DiscountBalance += 112M (×2) ✅ ActivationFee → Pool_پایه هفته جاری ✅ OTP + امضای قرارداد (فقط این بار — Q19) ✅ فیچرهای پکیج پایه فعال (DIFF = همه اضافه — لیست خالی بود) ✅ ClubMembership: FirstActivationDate = now, FirstPackageId = پایه LastActivationDate = now, LastPackageId = پایه ✅ ClubMembershipCycle #1 ساخته می‌شود ✅ NetworkWeeklyBalance (PackageId=پایه, WeekId=هفته جاری) ✅ بالاسری‌ها: تعادل‌ها بر اساس MaxBalancesPerLeg=300 + MaxNetworkLevel=15 ✅ پورسانت بالاسری‌ها از Pool_پایه ... خرید از فروشگاه → Balance صفر شد → Magic Mode فعال ... ... خرید جادویی → MagicBalance صفر + Deposit ≥ MaxDeposit → EXIT Magic ... چرخه ۲ — کاربر "علی" دوباره پکیج پایه می‌خرد: ────────────────────────────────────────────── ✅ ریست وضعیت: PackagePurchaseMethod=None (membership فعال باقی — Q19) ✅ Balance += 56M (مجدد شارژ) ✅ DiscountBalance += 112M (مجدد شارژ) ✅ ActivationFee → Pool_پایه هفته جاری ❌ بدون قرارداد مجدد (Q19 — قبلاً امضا شده) ✅ فیچر DIFF = خالی (همان پکیج → بدون تغییر فیچر — Q20) ✅ ClubMembership: FirstActivationDate = حفظ, FirstPackageId = حفظ LastActivationDate = now (بروزرسانی), LastPackageId = پایه ✅ ClubMembershipCycle #2 ساخته می‌شود ✅ بالاسری علی: carryover همه Poolها (بر اساس پکیج زیرمجموعه‌ها) حفظ می‌شود ✅ پورسانت بالاسری: از هر Pool که زیرمجموعه‌ای دارد ... همان چرخه Magic Wallet تکرار ... چرخه ۵ — کاربر "علی" پکیج نقره‌ای می‌خرد (۵.۶M): ──────────────────────────────────────────────────── ✅ Balance += 5.6M ✅ DiscountBalance += 11.2M (×2) ✅ ActivationFee → Pool_نقره‌ای هفته جاری (۲,۵۲۰,۰۰۰) ❌ بدون قرارداد مجدد (Q19) ✅ فیچر DIFF (Q20): فعلی: [چاتیکا, بیمه, سفر, آموزش] (از پکیج پایه) جدید: [چاتیکا, بیمه] (از پکیج نقره‌ای) → حذف: [سفر, آموزش] → IsActive=false → اضافه: [] → هیچی → حفظ: [چاتیکا, بیمه] ✅ ClubMembership: FirstActivationDate = حفظ, FirstPackageId = حفظ (پایه) LastActivationDate = now (بروزرسانی), LastPackageId = نقره‌ای ✅ ClubMembershipCycle #5 ساخته می‌شود (PackageId=نقره‌ای) ✅ بالاسری‌های علی: carryover همه Poolها حفظ — تغییر پکیج علی تاثیری ندارد! (carryover بر اساس پکیج زیرمجموعه‌هاست نه خود علی) ✅ پورسانت بالاسری از هر Pool جداگانه: Pool_پایه: بر اساس زیرمجموعه‌هایی که پکیج پایه دارند Pool_نقره‌ای: بر اساس زیرمجموعه‌هایی که پکیج نقره‌ای دارند ✅ ActivationFee علی → Pool_نقره‌ای (۲,۵۲۰,۰۰۰) ``` ### ۶.۴ تعادل‌ها (NetworkWeeklyBalance) — per-downline-package (v6) > ⚠️ **تغییر اساسی v6:** تعادل هر کاربر بر اساس پکیج **زیرمجموعه‌ها** گروه‌بندی می‌شود — **نه** پکیج خود کاربر. > ✅ **قانون carryover v6 (Q23 اصلاح‌شده):** تغییر پکیج خود کاربر **هیچ تاثیری** بر carryover ندارد. carryover مال زیرمجموعه‌هاست. ``` قبل (تک‌پکیج): NetworkWeeklyBalance: [UserId, WeekId] → یک رکورد بعد (چند‌پکیج): NetworkWeeklyBalance: [UserId, WeekId, PackageId] → N رکورد (N = تعداد پکیج‌های مختلف زیرمجموعه‌ها) ``` **الگوریتم محاسبه تعادل per-downline-package:** ``` برای هر بالاسری (کاربر): برای هر پکیج فعال (X): 1. واکشی زیرمجموعه‌هایی که پکیج X دارند: زیرمجموعه‌ها WHERE ClubMembership.LastPackageId = X 2. شمارش: چند نفر تیم چپ + چند نفر تیم راست 3. carryover از هفته قبل: خواندن رکورد [UserId, PrevWeekId, PackageId=X] ✅ همیشه خوانده می‌شود — مستقل از پکیج خود کاربر! 4. LeftLegTotal = NewLeft + CarryoverLeft 5. RightLegTotal = NewRight + CarryoverRight 6. TotalBalances = MIN(Left, Right) → cap at Package.MaxBalancesPerLeg 7. Remainder → carryover هفته بعد (برای PackageId = X) 8. پورسانت: TotalBalances × ValuePerBalance_X → کیف پول شبکه مجموع پورسانت از همه Poolها → واریز به کیف پول شبکه کاربر (به تفکیک مشخص: هر مبلغ از کدام Pool) ``` **مثال جامع — بالاسری "علی":** ``` علی (خودش پکیج پایه داره) ├── تیم چپ: │ ├── سارا (پکیج پایه) │ ├── رضا (پکیج پایه) │ └── مریم (پکیج نقره‌ای) └── تیم راست: ├── حسین (پکیج پایه) └── زهرا (پکیج نقره‌ای) ``` ``` هفته ۱۰ — محاسبه تعادل علی: ═══════════════════════════════════════════════════════ ═══ Pool پکیج پایه (زیرمجموعه‌هایی که پایه دارن) ═══ چپ: سارا + رضا = 2 راست: حسین = 1 carryover هفته ۹: {چپ: 3, راست: 0} جمع: چپ = 2+3 = 5, راست = 1+0 = 1 تعادل = MIN(5, 1) = 1 (cap 300 → OK) carryover → هفته ۱۱: {چپ: 4, راست: 0} پورسانت: 1 × ValuePerBalance_پایه = A ریال ═══ Pool پکیج نقره‌ای (زیرمجموعه‌هایی که نقره‌ای دارن) ═══ چپ: مریم = 1 راست: زهرا = 1 carryover هفته ۹: {چپ: 0, راست: 0} جمع: چپ = 1+0 = 1, راست = 1+0 = 1 تعادل = MIN(1, 1) = 1 (cap 30 → OK) carryover → هفته ۱۱: {چپ: 0, راست: 0} پورسانت: 1 × ValuePerBalance_نقره‌ای = B ریال ═══ مجموع پورسانت علی هفته ۱۰: ═══ کیف پول شبکه += (A + B) ردیابی: A از Pool پایه، B از Pool نقره‌ای ``` **هفته ۱۱ — علی پکیج خودش رو عوض می‌کنه (پایه → نقره‌ای):** ``` ═══ Pool پکیج پایه ═══ carryover از هفته ۱۰: {چپ: 4, راست: 0} ← هنوز هست! ✅ تغییر پکیج خود علی تاثیری نداره! اعضای جدید: چپ = 0, راست = 1 جمع: چپ = 0+4 = 4, راست = 1+0 = 1 تعادل = MIN(4, 1) = 1 ✅ علی هنوز از Pool پایه سود می‌بره (چون زیرمجموعه‌هایی با پکیج پایه داره) ═══ Pool پکیج نقره‌ای ═══ carryover از هفته ۱۰: {چپ: 0, راست: 0} ... محاسبه عادی ... ``` **نکته کلیدی:** پکیج **خود کاربر** فقط تعیین می‌کنه ActivationFee‌اش به کدوم Pool بره. **تعادل و carryover** بر اساس پکیج **زیرمجموعه‌ها** محاسبه می‌شه. **Cap (سقف):** ``` اگه تعادل بیشتر از MaxBalancesPerLeg بشه → بریده می‌شه (flush): تعادل = 500، سقف = 300 → CappedBalance = 300، Flushed = 200 ⚠️ Flushed از بین می‌ره — carry نمی‌شه! ``` ### ۶.۵ تغییرات SP (v3 — بروزرسانی) | SP | تغییرات v2 | تغییرات اضافی v3 | |----|------------|------------------| | `sp_CalculateWeeklyBalances` | پارامتر `@PackageId` | `@MaxBalancesPerLeg` و `@MaxNetworkLevel` هم **پارامتر** شوند (نه hardcoded) | | `sp_CalculateWeeklyCommissionPool` | پارامتر `@PackageId` | Pool فقط از تعادل‌های همان PackageId | **sp_CalculateWeeklyBalances — تغییرات ساختاری:** ```sql -- قبل (v2 — فقط PackageId فیلتر): CREATE PROCEDURE sp_CalculateWeeklyBalances @WeekDefinitionId BIGINT, @PackageId BIGINT, @MaxBalancesPerLeg INT = 300, -- ← hardcoded! @MaxNetworkLevel INT = 15 -- ← hardcoded! -- بعد (v3 — همه داینامیک): CREATE PROCEDURE sp_CalculateWeeklyBalances @WeekDefinitionId BIGINT, @PackageId BIGINT, @MaxBalancesPerLeg INT, -- ← از Package entity خوانده می‌شود @MaxNetworkLevel INT, -- ← از Package entity خوانده می‌شود @ForceRecalculate BIT = 0, @RowCount INT OUTPUT ``` **StoredProcedureCommissionCalculationStrategy — تغییرات:** ```csharp // قبل: فقط WeekDefinitionId پاس می‌داد await connection.ExecuteAsync("CMS.sp_CalculateWeeklyBalances", new { WeekDefinitionId = weekId, ForceRecalculate = true }); // بعد (v3): پکیج + تنظیمات داینامیک await connection.ExecuteAsync("CMS.sp_CalculateWeeklyBalances", new { WeekDefinitionId = weekId, PackageId = package.Id, MaxBalancesPerLeg = package.MaxBalancesPerLeg, MaxNetworkLevel = package.MaxNetworkLevel, ForceRecalculate = true }); ``` ### ۶.۶ تغییرات Service (v3) ```csharp // WeeklyCommissionCalculationService — Loop روی پکیج‌ها: var activePackages = await _context.Packages .Where(p => p.IsActive && !p.IsDeleted) .ToListAsync(); foreach (var package in activePackages) { _logger.LogInformation( "Calculating commission for package {Id}: {Title} " + "(MaxBalances={Max}, MaxLevel={Level})", package.Id, package.Title, package.MaxBalancesPerLeg, package.MaxNetworkLevel); // محاسبه تعادل‌ها — هر پکیج با تنظیمات خودش await strategy.CalculateWeeklyBalancesAsync( weekId, package.Id, package.MaxBalancesPerLeg, package.MaxNetworkLevel); // محاسبه Pool — هر پکیج جداگانه await strategy.CalculateWeeklyPoolAsync(weekId, package.Id); } ``` ### ۶.۷ گزارش‌دهی پورسانت per-package (v3 — جدید) > ⚠️ **فعلاً `package_id` در هیچ‌کدام از پیام‌های commission.proto وجود ندارد!** #### تغییرات Proto (commission.proto): ```diff message UserWeeklyBalanceModel { // ... فیلدهای فعلی ... + int64 package_id = 17; + string package_title = 18; } message UserCommissionPayoutModel { // ... فیلدهای فعلی ... + int64 package_id = 16; + string package_title = 17; } message CustomerCommissionPayoutModel { // ... فیلدهای فعلی ... + int64 package_id = 10; + string package_title = 11; } message CustomerWeeklyBalanceModel { // ... فیلدهای فعلی ... + int64 package_id = 15; + string package_title = 16; } + // مدل خلاصه پورسانت per-package برای مشتری + message CustomerCommissionPackageSummary { + int64 package_id = 1; + string package_title = 2; + int32 downline_count = 3; // تعداد زیرمجموعه با این پکیج + int32 left_leg_members = 4; + int32 right_leg_members = 5; + int32 total_balances = 6; + int64 commission_earned = 7; // پورسانت کسب‌شده از این پکیج + string commission_formatted = 8; + } // Request فیلتر بر اساس پکیج message GetAllWeeklyBalancesByFilterRequest { // ... فیلدهای فعلی ... + Int64Value package_id = 7; // فیلتر اختیاری } message GetUserCommissionPayoutsRequest { // ... فیلدهای فعلی ... + Int64Value package_id = 7; // فیلتر اختیاری } ``` #### FrontOffice — گزارش پورسانت per-package (صفحه جدید/بهبود): ``` ┌─── پاداش‌های من — هفته ۱۰ ────────────────────────────────────────────┐ │ │ │ 📊 خلاصه بر اساس پکیج: │ │ │ │ ┌── پکیج پایه ─────────────────┐ ┌── پکیج نقره‌ای ───────────────┐ │ │ │ زیرمجموعه: ۱۲ نفر │ │ زیرمجموعه: ۵ نفر │ │ │ │ تیم اول: ۷ │ تیم دوم: ۵ │ │ تیم اول: ۳ │ تیم دوم: ۲ │ │ │ │ تعادل: ۵ │ │ تعادل: ۲ │ │ │ │ 💰 پاداش: ۱,۲۵۰,۰۰۰ تومان │ │ 💰 پاداش: ۱۸۰,۰۰۰ تومان │ │ │ └───────────────────────────────┘ └──────────────────────────────┘ │ │ │ │ 📦 مجموع پاداش هفته: ۱,۴۳۰,۰۰۰ تومان │ │ ├── از پکیج پایه: ۱,۲۵۰,۰۰۰ │ │ └── از پکیج نقره‌ای: ۱۸۰,۰۰۰ │ └─────────────────────────────────────────────────────────────────────────┘ ``` #### BackOffice — گزارش ادمین با فیلتر پکیج: ``` ┌─── گزارش تعادل‌ها — هفته ۱۰ ───────────────────────────────────────┐ │ │ │ فیلتر: [▼ پکیج: همه ▼] [کاربر: ___] [هفته: ▼ هفته ۱۰ ▼] [جستجو]│ │ ├── همه │ │ ├── پکیج پایه │ │ └── پکیج نقره‌ای │ │ │ │ # │ کاربر │ پکیج │ تیم اول │ تیم دوم │ تعادل │ سهم استخر │ │ ──┼──────────┼─────────┼─────────┼─────────┼───────┼────────────────│ │ 1 │ علی │ پایه │ ۴۵ │ ۵۲ │ ۴۵ │ ۲,۲۵۰,۰۰۰ │ │ 2 │ سارا │ نقره‌ای │ ۱۲ │ ۸ │ ۸ │ ۱۴۴,۰۰۰ │ │ 3 │ رضا │ پایه │ ۳۰ │ ۲۵ │ ۲۵ │ ۱,۲۵۰,۰۰۰ │ │ 4 │ رضا │ نقره‌ای │ ۲ │ ۰ │ ۰ │ ۰ │ │ │ │ خلاصه: پکیج پایه: ۷۰ تعادل | پکیج نقره‌ای: ۸ تعادل │ └──────────────────────────────────────────────────────────────────────┘ ``` --- ## ۷. Event-Driven Side Effects ### ۷.۱ ساخت پکیج جدید ```mermaid flowchart LR A["ساخت پکیج جدید
(از BackOffice)"] --> B["PackageCreatedEvent"] B --> C["ایجاد Pool خالی
برای هفته جاری"] B --> D["لاگ ادمین"] ``` ### ۷.۲ جدول رویدادها | رویداد | Side Effect | |--------|------------| | `PackageCreated` | ساخت WeeklyCommissionPool خالی هفته جاری | | `PackageDeactivated` | هشدار ادمین — Pool موجود تکمیل شود | | `PackagePurchased` | ActivationFee → Pool پکیج + شارژ wallets | | `MagicCycleCompleted` | ریست PackagePurchaseMethod + حفظ membership فعال (Q19) | --- ## ۷٫۵ تحلیل جامع Side Effectها (v4 — کشف جدید) > ⚠️ **این بخش ساید‌افکت‌هایی را مستند می‌کند که در تحلیل‌های v1–v3 کشف نشده بودند.** > هر آیتم با بررسی عمیق کدبیس CMS، FrontOffice و BackOffice شناسایی شده. ### ۷٫۵٫۱ Entity — WalletChangeLog بدون PackageId > 🔴 **بحرانی — ۲۱ محل ساخت WalletChangeLog** Entity فعلی `UserWalletChangeLog` **هیچ فیلد PackageId ندارد**. در سیستم چندپکیجی نمی‌توان ردیابی کرد کدام پکیج باعث تغییر کیف‌پول شده. **محل‌های ساخت WalletChangeLog (۲۱ مورد):** | # | فایل | کانتکست | |---|------|---------| | 1-2 | `UserOrderService.cs` | خرید فروشگاه + لغو مشتری | | 3-4 | `VerifyPackagePurchaseCommandHandler` | Balance + DiscountBalance | | 5 | `VerifyBasePackagePaymentCommandHandler` | Balance | | 6-7 | `VerifyGoldenPackagePurchaseCommandHandler` | Balance + Discount | | 8 | `VerifyMagicWalletChargeCommandHandler` | شارژ جادویی | | 9-10 | `CheckAndProcessDayaLoansCommandHandler` | Balance + Discount | | 11-12 | `CreateManualPaymentCommandHandler` | Balance + Discount | | 13-18 | `ChargeUserWalletsCommandHandler` | ۶ شاخه switch | | 19 | `CancelOrderByAdminCommandHandler` | ریفاند ادمین | | 20 | `SpCommissionCalculationStrategy` | پرداخت پورسانت | | 21 | `OrmCommissionCalculationStrategy` | پرداخت پورسانت | **اقدام لازم:** ```csharp // اضافه به UserWalletChangeLog entity: public long? PackageId { get; set; } public virtual Package Package { get; set; } ``` + Migration + بروزرسانی ۲۱ محل --- ### ۷٫۵٫۲ Validator — سقف hardcoded 1B > 🟡 **متوسط — ۳ Validator** | Validator | فایل | قانون | مشکل | |-----------|------|-------|------| | `ChargeMagicWalletCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global — پکیج نقره‌ای ممکنه ۱۰۰M باشه | | `ChargeDiscountWalletCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global | | `CreateManualPaymentCommandValidator` | FluentValidation | `.LessThanOrEqualTo(1_000_000_000)` | سقف global | **اقدام:** Validator باید PackageId بگیره و از `Package.MagicWalletMaxDeposit` بخونه، یا حداکثر بین همه پکیج‌ها. --- ### ۷٫۵٫۳ Magic Wallet — ۶ Side Effect > 🔴 **بحرانی — چرخه حیات Magic Wallet کامل global است** | # | فایل | مشکل | شدت | |---|------|------|------| | **MW1** | `ChargeMagicWalletCommandHandler` | `remainingDeposit = SystemConstants.MagicWalletMaxDeposit - wallet.MagicTotalDeposited` ← global 1B | 🔴 | | **MW2** | `VerifyMagicWalletChargeCommandHandler` | `creditAmount = depositAmount * SystemConstants.MagicWalletMultiplier` ← global ×2.5 | 🔴 | | **MW3** | `UserOrderService.cs` L347 | EXIT trigger: `wallet.MagicTotalDeposited >= SystemConstants.MagicWalletMaxDeposit` ← global 1B | 🔴 | | **MW4** | `UserOrderService.cs` L363 | ENTRY trigger: فقط `PackagePurchaseMethod != None` — چک نمی‌کنه **کدام** پکیج | 🟡 | | **MW5** | `WalletGrpcService` | `GetMagicWalletStatus` → سقف و باقیمانده global به FrontOffice ارسال | 🟡 | | **MW6** | `ActivateClubMembershipCommandHandler` | Guard: `WalletMode == Magic → throw` — مدت Magic وابسته به سقف per-package | 🟡 | **فلوی مشکل‌دار:** ``` پکیج نقره‌ای (MaxDeposit=100M): کاربر ۱۰۰M شارژ کرد → باید EXIT شه ❌ EXIT نمی‌شه! چون سیستم ۱B (global) چک می‌کنه ❌ کاربر تا ابد در Magic Mode گیر می‌افته! پکیج طلایی (MaxDeposit=2B): کاربر ۱B شارژ کرد → EXIT اشتباه! ❌ سیستم فکر می‌کنه سقف رسیده ❌ کاربر زودتر از موعد از Magic خارج می‌شه! ``` --- ### ۷٫۵٫۴ JWT Claims — بدون PackageId > 🟡 **بالا — اطلاعات ناکافی JWT** | Claim فعلی | نوع | مشکل | |------------|-----|------| | `HasPurchasedGoldenPackage` | `bool` | فقط boolean — نمی‌گه **کدام** پکیج | | `PackagePurchaseMethod` | missing | در JWT نیست — فقط در DB | | `CanRepurchase` | **وجود ندارد** | هیچ‌جا تعریف نشده — UI نمی‌تونه دکمه خرید مجدد نشون بده | | `PackageId` | **وجود ندارد** | فرانت نمی‌دونه کاربر کدام پکیج رو داره | | `PackageTitle` | **وجود ندارد** | نام پکیج در JWT نیست | **اقدام — Claims جدید:** ```csharp claims.Add("PackageId", membership.PackageId?.ToString() ?? ""); claims.Add("PackageTitle", package?.Title ?? ""); claims.Add("CanRepurchase", HasCompletedMagicCycle(membership).ToString()); // حذف HasPurchasedGoldenPackage → جایگزین با PackageId > 0 ``` --- ### ۷٫۵٫۵ Club Features — تخصیص global → DIFF (v5) > 🔴 **بحرانی — فیچرها باید DIFF/تفاضل باشند (Q20)** ```csharp // فعلی (اشتباه) — ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs: var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds(); // ↑ همیشه همه فیچرها (Chatika, Bime, Trip, Learn) — صرف‌نظر از پکیج! ``` **مشکل ۱:** پکیج نقره‌ای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال می‌کنه. **مشکل ۲:** در خرید مجدد، باید فقط **تفاضل** اعمال بشه (Q20) — نه تخصیص مجدد همه. **اقدام (v5):** ```csharp // خرید اول (currentFeatures خالی → همه فیچرها اضافه): await _featureDiffService.ApplyFeatureDiffAsync( userId, membershipId, oldPackageId: 0, // بدون پکیج قبلی newPackageId: package.Id); // خرید مجدد (مقایسه + تفاضل): await _featureDiffService.ApplyFeatureDiffAsync( userId, membershipId, oldPackageId: membership.LastPackageId, newPackageId: newPackage.Id); ``` --- ### ۷٫۵٫۶ Notification — بدون PackageId > 🟡 **متوسط — ۴ Notification** | # | Notification | مشکل | |---|-------------|------| | N1 | `CommissionDepositedNotification` | پیام: "پورسانت X ریال واریز شد" — نمی‌گه از **کدام پکیج** | | N2 | `ClubActivatedNotification` | پیام: "عضویت فعال شد" — نمی‌گه **کدام پکیج** فعال شد | | N3 | `CommissionPayoutFailedNotification` | بدون PackageId | | N4 | Daya SMS | "وام دایا تایید شد" — نمی‌گه برای کدام پکیج | **اقدام:** Interface `INotification` باید `PackageId` + `PackageTitle` بگیره. --- ### ۷٫۵٫۷ Background Services — ۳ سرویس > 🟡 **متوسط** | # | سرویس | مشکل | |---|--------|------| | BG1 | `ClubMembershipCycleSeedService` | چرخه‌ها را با `PurchaseAmount=0` seed می‌کنه — PackageId ندارد | | BG2 | `DayaLoanStatusCheckWorker` | `DayaLoanAmount` global — بدون PackageId | | BG3 | `ChatikaAccountActivationWorker` | همه اعضا — بدون فیلتر پکیج (Chatika ممکنه فقط پکیج پایه) | --- ### ۷٫۵٫۸ Commission Reports — بدون PackageId > 🟡 **متوسط** | # | فایل | مشکل | |---|------|------| | CR1 | `CommissionWithdrawalReportService` | گزارش برداشت global — بدون فیلتر پکیج | | CR2 | `WorkerExecutionLog` | لاگ اجرای Hangfire — نمی‌گه کدام پکیج محاسبه شد | | CR3 | Admin manual trigger endpoint | `EnqueueCommissionCalculation` بدون پارامتر PackageId | --- ### ۷٫۵٫۹ FrontOffice — متن‌های hardcoded > 🔴 **بحرانی — مسئولیت حقوقی قرارداد** | # | فایل | مشکل | شدت | |---|------|------|------| | FO1 | `ClubMembershipContractDialog.razor` | **"۵۶ میلیون تومان (پکیج پایه)"** در متن قرارداد حقوقی — اگه نقره‌ای بخره اشتباهه! | 🔴 حقوقی | | FO2 | `PackagePurchaseBottomSheet.razor` | **"خرید پکیج پایه ۵۶ میلیون تومان"** hardcoded | 🔴 | | FO3 | `ActivationSection.razor` | `56_000_000` hardcoded در محاسبه هزینه | 🔴 | | FO4 | `MagicWalletChargePage.razor` | "×۲.۵" و "سقف واریز: ۱۰۰ میلیون" hardcoded در ۶ جا | 🟡 | | FO5 | `MagicWalletChargePage.razor` L112 | `_chargeAmount * 2.5m` hardcoded در **C# code** | 🔴 | | FO6 | صفحات متعدد | **"پکیج طلایی"** hardcoded (حداقل ۵ جا) — نام اشتباه! | 🟡 | | FO7 | Activation flow | `PackageId = 1` hardcoded | 🔴 | | FO8 | `HasPurchasedPackage` claim | `bool` — UI نمی‌تونه multi-package نشون بده | 🟡 | | FO9 | Network tree | بدون نمایش پکیج هر نود | 🟢 | --- ### ۷٫۵٫۱۰ BackOffice — ۸ Side Effect > 🟡 **بالا** | # | فایل | مشکل | شدت | |---|------|------|------| | BO-S1 | `ManualActivationDialog.razor` | مبلغ `56_000_000` hardcoded + `Disabled` + **بدون package selector** | 🔴 | | BO-S2 | `SystemConfigurationPage.razor` | تنظیمات global — MaxWithdrawal, MaxTreeDepth, CommissionPercent باید per-package بشن | 🔴 | | BO-S3 | Commission Dashboard | یک Pool واحد نشون می‌ده — بدون per-package | 🟡 | | BO-S4 | User detail page | `HasPurchasedGoldenPackage` boolean — نمی‌گه کدام پکیج | 🟡 | | BO-S5 | Package CRUD grid | فقط Title + Price — فیلدهای v3 ندارد | 🟡 | | BO-S6 | Club members grid | ستون PackageTitle ✅ اما فیلتر ❌ | 🟢 | | BO-S7 | CSV exports (۳ جا) | بدون ستون پکیج | 🟡 | | BO-S8 | Network tree viewer | بدون پکیج هر نود + `MaxNetworkDepth` global | 🟡 | --- ### ۷٫۵٫۱۱ خلاصه آماری Side Effectها ``` ═══════════════════════════════════════════════════════════════ لایه 🔴 بحرانی 🟡 بالا/متوسط 🟢 کم جمع ═══════════════════════════════════════════════════════════════ CMS Domain ۲ ۱ ۰ ۳ CMS Application ۶ ۴ ۰ ۱۰ CMS Infrastructure ۱ ۳ ۰ ۴ CMS Validators ۰ ۳ ۰ ۳ FrontOffice ۵ ۳ ۱ ۹ BackOffice ۲ ۵ ۱ ۸ Notifications ۰ ۴ ۰ ۴ Background Jobs ۰ ۳ ۰ ۳ ───────────────────────────────────────────────────────────── جمع کل ۱۶ ۲۶ ۲ ۴۴ ═══════════════════════════════════════════════════════════════ ``` > **مجموع ساید‌افکت‌های کشف‌شده v4: ۴۴ مورد** (نسبت به ۴۸ تغییر اصلی v3) > **بیشتری تغییرات لازم از ساید‌افکت‌ها ناشی می‌شوند!** --- ## ۸. Seed Data ```sql -- پکیج پایه (۵۶ میلیون تومان) INSERT INTO "CMS"."Packages" ( "Title", "Description", "Price", "IsActive", "IsBasePackage", "SupportsDayaPurchase", "SupportsDirectPurchase", "ActivationFee", "DiscountMultiplier", "MagicWalletMultiplier", "MaxBalancesPerLeg", "MaxNetworkLevel", "MagicWalletMaxDeposit", "MagicWalletMaxCredit", "SortOrder", "ImagePath" ) VALUES ( 'پکیج پایه', 'پکیج اصلی باشگاه مشتریان کارا بازار سلامت', 56000000, true, true, true, true, 25200000, 2.0, 2.5, 300, 15, 1000000000, 2500000000, 2, '' ); -- پکیج نقره‌ای (۵.۶ میلیون تومان) INSERT INTO "CMS"."Packages" ( "Title", "Description", "Price", "IsActive", "IsBasePackage", "SupportsDayaPurchase", "SupportsDirectPurchase", "ActivationFee", "DiscountMultiplier", "MagicWalletMultiplier", "MaxBalancesPerLeg", "MaxNetworkLevel", "MagicWalletMaxDeposit", "MagicWalletMaxCredit", "SortOrder", "ImagePath" ) VALUES ( 'پکیج نقره‌ای', 'پکیج سطح نقره‌ای باشگاه مشتریان', 5600000, true, false, false, true, 2520000, 2.0, 2.5, 30, 15, 100000000, 250000000, 1, '' ); -- فیچرهای پکیج پایه: همه فیچرها INSERT INTO "CMS"."PackageFeatures" ("PackageId", "ClubFeatureId", "IsIncluded") SELECT base."Id", cf."Id", true FROM "CMS"."Packages" base CROSS JOIN "CMS"."ClubFeatures" cf WHERE base."IsBasePackage" = true AND cf."IsDeleted" = false; -- فیچرهای پکیج نقره‌ای: تعیین می‌شود از BackOffice ``` --- ## ۹. Migration داده‌های فعلی ```sql -- ======================================== -- STEP 1: مشخص کردن ID پکیج پایه -- ======================================== DO $$ DECLARE base_pkg_id BIGINT; BEGIN SELECT "Id" INTO base_pkg_id FROM "CMS"."Packages" WHERE "IsBasePackage" = true LIMIT 1; -- STEP 2: ClubMembership — v5 migration (Q21) -- ActivatedAt → FirstActivationDate + LastActivationDate UPDATE "CMS"."ClubMemberships" SET "FirstActivationDate" = "ActivatedAt", "LastActivationDate" = "ActivatedAt", "FirstPackageId" = base_pkg_id, "LastPackageId" = base_pkg_id WHERE "FirstActivationDate" IS NULL; -- STEP 3: ClubMembershipCycle UPDATE "CMS"."ClubMembershipCycles" SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL; -- STEP 4: WeeklyCommissionPool UPDATE "CMS"."WeeklyCommissionPools" SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL; -- STEP 5: UserCommissionPayout UPDATE "CMS"."UserCommissionPayouts" SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL; -- STEP 6: NetworkWeeklyBalance (v3 — جدید) UPDATE "CMS"."NetworkWeeklyBalances" SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL; RAISE NOTICE 'Migration completed for PackageId=%', base_pkg_id; END $$; -- STEP 7: Verify — همه باید 0 باشند SELECT 'ClubMemberships' AS tbl, COUNT(*) FROM "CMS"."ClubMemberships" WHERE "LastPackageId" IS NULL UNION ALL SELECT 'Cycles', COUNT(*) FROM "CMS"."ClubMembershipCycles" WHERE "PackageId" IS NULL UNION ALL SELECT 'Pools', COUNT(*) FROM "CMS"."WeeklyCommissionPools" WHERE "PackageId" IS NULL UNION ALL SELECT 'Payouts', COUNT(*) FROM "CMS"."UserCommissionPayouts" WHERE "PackageId" IS NULL UNION ALL SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" IS NULL; ``` --- ## ۱۱. Impact Analysis — ۹۵+ تغییر در ۶ لایه (v5) > ۵۱ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۵ تغییر کل** ### ۱۰.۱ لایه Domain (۱۰ تغییر) | # | فایل | نوع | شدت | v3? | |---|------|-----|------|-----| | D1 | `Package.cs` | اضافه **۱۱ فیلد** جدید (v2: ۷ + v3: ۴) | 🟡 | 🔄 | | D2 | `PackageFeature.cs` | Entity جدید + EF Config | 🔴 | | | D3 | `ClubMembership.cs` | حذف `ActivatedAt` → اضافه **۴ فیلد**: `FirstActivationDate`, `FirstPackageId`, `LastActivationDate`, `LastPackageId` (v5 — Q21) | 🔴 | 🔄 | | D4 | `ClubMembershipCycle.cs` | اضافه `PackageId` | 🟡 | | | D5 | `WeeklyCommissionPool.cs` | اضافه `PackageId` + Unique | 🔴 | | | D6 | `UserCommissionPayout.cs` | اضافه `PackageId` + Unique(UserId,WeekId,PackageId) | 🟡 | 🔄 | | D7 | `SystemConstants.cs` | حذف **۷ ثابت** (v2: ۵ + v3: ۲)، حفظ ۱۱ | 🟡 | 🔄 | | D8 | EF Migration + Seed | schema + data migration | 🔴 | | | **D9** | **`NetworkWeeklyBalance.cs`** | **اضافه `PackageId` + Unique(UserId,WeekId,PackageId)** | **🔴** | **🆕** | | **D10** | **Data volume impact** | **رکوردهای تعادل ×N (تعداد پکیج)** | **🟡** | **🆕** | | **D11** | **`UserWalletChangeLog.cs`** | **اضافه `PackageId` — ۲۱ محل ساخت باید بروزرسانی شوند** | **🔴** | **🆕 v4** | ### ۱۰.۲ لایه Application (۱۲ تغییر) | # | فایل | نوع | شدت | |---|------|-----|------| | A1 | `ActivateClubMembershipCommandHandler` | فیچر از PackageFeature + ActivationFee + re-activate | 🔴 | | A2 | `VerifyPackagePurchaseCommandHandler` | DiscountMultiplier + UserPackagePurchase + generic | 🔴 | | A3 | `VerifyBasePackagePaymentCommandHandler` | DiscountMultiplier + UserPackagePurchase + generic | 🔴 | | A4 | `VerifyGoldenPackagePurchaseCommandHandler` | فیکس DiscountBalance + UserPackagePurchase + generic | 🔴 | | A5 | `InitiateBasePackagePaymentCommandHandler` | حذف ID=4 + generic | 🟡 | | A6 | `PurchaseGoldenPackageCommandHandler` | حذف فیلتر "طلایی" + generic | 🟡 | | A7 | `PurchasePackageCommandHandler` | اجازه re-purchase | 🟡 | | A8 | `CreateManualPaymentCommandHandler` | DiscountMultiplier از Package | 🟡 | | A9 | `CheckAndProcessDayaLoansCommandHandler` | حذف ID=4 + DiscountMultiplier | 🟡 | | A10 | `AcceptClubMembershipContractCommandHandler` | حفظ guard (قرارداد یک‌بار — Q19). خرید مجدد Skip قرارداد | 🟡 | | A11 | `UserOrderService` (EXIT Magic) | ریست PackagePurchaseMethod + Deactivate | 🔴 | | A12 | Package CRUD handlers | فیلدهای جدید + PackageFeature CRUD | 🟡 | | **A13** | **`ChargeMagicWalletCommandHandler`** | **`MagicWalletMaxDeposit` از Package بخوند (نه global)** | **🔴 v4** | | **A14** | **`VerifyMagicWalletChargeCommandHandler`** | **`MagicWalletMultiplier` از Package بخوند (نه ×2.5 global)** | **🔴 v4** | | **A15** | **`UserOrderService.cs` EXIT/ENTRY** | **EXIT: سقف از Package + ENTRY: چک کدام پکیج** | **🔴 v4** | | **A16** | **Validators (۳ فایل)** | **`1_000_000_000` hardcoded → داینامیک per-package** | **🟡 v4** | | **A17** | **`GetAllFeatureIds()` (۲ handler)** | **فیچر DIFF/تفاضل (Q20): مقایسه فعلی vs جدید، فقط اختلاف اعمال** | **🔴 v5** | | **A18** | **JWT Token Generation** | **اضافه PackageId + PackageTitle + CanRepurchase** | **🟡 v4** | | **A19** | **`WalletGrpcService.GetMagicWalletStatus`** | **سقف و باقیمانده per-package (نه global)** | **🟡 v4** | | **A20** | **Notifications (۴ مورد)** | **اضافه PackageId + PackageTitle به interface** | **🟡 v4** | | **A21** | **`FeatureDiffService` — جدید** | **سرویس DIFF فیچرها (Q20): مقایسه + اعمال تفاضل** | **🔴 v5** | | **A22** | **`ActivateClubMembership` — FirstLast** | **بروزرسانی First/LastActivationDate + First/LastPackageId (Q21)** | **🟡 v5** | ### ۱۰.۳ لایه Infrastructure (۶ تغییر) | # | فایل | نوع | شدت | v3? | |---|------|-----|------|-----| | I1 | `sp_CalculateWeeklyBalances` | پارامتر `@PackageId` + **`@MaxBalancesPerLeg`** + **`@MaxNetworkLevel`** (حذف hardcode) | 🔴 | 🔄 | | I2 | `sp_CalculateWeeklyCommissionPool` | پارامتر `@PackageId` + فیلتر تعادل‌های همان پکیج | 🔴 | 🔄 | | I3 | `WeeklyCommissionCalculationService` | Loop روی پکیج‌ها + ارسال تنظیمات هر پکیج | 🟡 | 🔄 | | I4 | `OrmCommissionCalculationStrategy` | فیلتر PackageId + خواندن MaxBalancesPerLeg/MaxNetworkLevel از Package | 🔴 | 🔄 | | I5 | `SpCommissionCalculationStrategy` | پاس دادن PackageId + MaxBalancesPerLeg + MaxNetworkLevel | 🟡 | 🔄 | | **I6** | **Carryover logic** | **Week-shifting per-package: carryover فقط رکوردهای همان PackageId** | **🔴** | **🆕** | ### ۱۰.۴ لایه Proto/gRPC (۶ تغییر) | # | فایل | نوع | شدت | v3? | |---|------|-----|------|-----| | P1 | `package.proto` | فیلدهای جدید Package (۱۱ فیلد) | 🟡 | 🔄 | | P2 | `clubmembership.proto` | `package_id` در request/response | 🟡 | | | P3 | `commission.proto` | **`package_id` + `package_title`** در ۴ message: UserWeeklyBalance, UserCommissionPayout, CustomerCommissionPayout, CustomerWeeklyBalance | **🔴** | **🔄** | | P4 | `PackageGrpcService.cs` | Generic purchase + CRUD | 🟡 | | | **P5** | **`commission.proto`** | **Message جدید: `CustomerCommissionPackageSummary`** (خلاصه per-package) | **🟡** | **🆕** | | **P6** | **`commission.proto`** | **فیلتر اختیاری `package_id` در Request‌های** GetWeeklyBalances + GetPayouts | **🟡** | **🆕** | ### ۱۰.۵ لایه FrontOffice (۸ تغییر) | # | فایل | نوع | شدت | v3? | |---|------|-----|------|-----| | F1 | `Packages.razor` | کاشی‌های پکیج از API | 🔴 | | | F2 | `PackageDetail.razor` | فیچرها از PackageFeature | 🟡 | | | F3 | `ActivationSection.razor` | حذف hardcoded 56M | 🟡 | | | F4 | `ClubMembershipContractDialog.razor` | متن قرارداد داینامیک | 🟡 | | | F5 | `MyPackages.razor` | نمایش نوع پکیج + re-purchase | 🟡 | | | F6 | `PackageService.cs` | فیکس stub GetPurchaseHistory | 🟡 | | | **F7** | **`CommissionDashboardPage.razor`** | **خلاصه پاداش per-package (کارت‌های جداگانه هر پکیج)** | **🔴** | **🆕** | | **F8** | **`WeeklyBalancePage.razor`** | **تعادل‌ها per-package (تیم اول/دوم بر اساس پکیج)** | **🟡** | **🆕** | | **F9** | **`ClubMembershipContractDialog.razor`** | **متن حقوقی "۵۶ میلیون تومان" — مسئولیت حقوقی!** | **🔴** | **🆕 v4** | | **F10** | **`MagicWalletChargePage.razor`** | **×۲.۵ و سقف واریز hardcoded در ۶ جا (متن + کد C#)** | **🔴** | **🆕 v4** | | **F11** | **صفحات متعدد** | **"پکیج طلایی" hardcoded (۵+ جا) — نام اشتباه** | **🟡** | **🆕 v4** | | **F12** | **Activation flow** | **`PackageId = 1` hardcoded** | **🔴** | **🆕 v4** | ### ۱۰.۶ لایه BackOffice (۸ تغییر) | # | فایل | نوع | شدت | v3? | |---|------|-----|------|-----| | BO1 | `PackageCreateDialog.razor` | فیلدهای جدید (**۱۱ فیلد** شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWallet limits) | 🟡 | 🔄 | | BO2 | `PackageEditDialog.razor` | فیلدهای جدید + **Quick Access فیچرها** (checkbox فیچرها در همان فرم) | 🟡 | 🔄 | | BO3 | `PackageFeatureMatrixPage` — **جدید** | ماتریس پکیج×فیچر | 🔴 | | | BO4 | `ActivateClubDialog.razor` | dropdown انتخاب پکیج | 🟡 | | | **BO5** | **`Dashboard.razor` (Commission)** | **فیلتر dropdown پکیج + خلاصه per-package** | **🟡** | **🆕** | | **BO6** | **`WeeklyReportsPage.razor`** | **فیلتر پکیج + CSV export per-package** | **🟡** | **🆕** | | **BO7** | **`BalancesReportPage.razor`** | **فیلتر پکیج + ستون پکیج در جدول تعادل‌ها** | **🟡** | **🆕** | | **BO8** | **`UserPayoutsPage.razor`** | **ستون پکیج در لیست پرداخت‌ها + فیلتر** | **🟡** | **🆕** | | **BO9** | **`ManualActivationDialog.razor`** | **مبلغ ۵۶M hardcoded + Disabled + بدون package selector** | **🔴** | **🆕 v4** | | **BO10** | **`SystemConfigurationPage.razor`** | **تنظیمات global — باید per-package بشن** | **🔴** | **🆕 v4** | | **BO11** | **CSV exports (۳ جا)** | **بدون ستون پکیج** | **🟡** | **🆕 v4** | --- ## ۱۱. فازبندی پیاده‌سازی ### فاز ۰ — فیکس باگ‌های فوری ≈ ۱ روز | تسک | شرح | |-----|------| | **T0.1** | فیکس `VerifyGoldenPackagePurchase` — اضافه DiscountBalance (`Amount × 2`) | | **T0.2** | فیکس `VerifyGoldenPackagePurchase` — ساخت `UserPackagePurchase` | | **T0.3** | فیکس `VerifyPackagePurchase` — ساخت `UserPackagePurchase` | | **T0.4** | فیکس `VerifyBasePackagePayment` — ساخت `UserPackagePurchase` | ### فاز ۱ — زیرساخت (Domain + DB) ≈ ۳ روز | تسک | شرح | v3? | |-----|----- |-----| | **T1.1** | بروزرسانی `Package` entity (**۱۱ فیلد** جدید: v2 ۷ + v3 ۴ شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit, MagicWalletMaxCredit) | 🔄 | | **T1.2** | ایجاد `PackageFeature` entity + EF Config | | | **T1.3** | حذف `ActivatedAt` → اضافه **۴ فیلد**: `FirstActivationDate`, `FirstPackageId`, `LastActivationDate`, `LastPackageId` (Q21) | 🔄 v5 | | **T1.4** | اضافه `PackageId` به `ClubMembershipCycle` | | | **T1.5** | اضافه `PackageId` به `WeeklyCommissionPool` + Unique(WeekId,PackageId) | | | **T1.6** | اضافه `PackageId` به `UserCommissionPayout` + Unique(UserId,WeekId,PackageId) | 🔄 | | **T1.7** | حذف **۷ ثابت** از `SystemConstants` (v2: ۵ + v3: MaxWeeklyBalancesPerLeg, MaxNetworkLevel) | 🔄 | | **T1.8** | Database Migration + Seed Data (۲ پکیج + فیچرها) | | | **T1.9** | Data Migration: کاربران فعلی → PackageId = پکیج پایه | | | **T1.10** | بروزرسانی Proto‌ها (package + clubmembership + commission) | 🔄 | | **T1.11** | **اضافه `PackageId` به `NetworkWeeklyBalance` + Unique(UserId,WeekId,PackageId)** | **🆕** | | **T1.12** | **Data Migration: `NetworkWeeklyBalance` موجود → PackageId = پکیج پایه** | **🆕** | | **T1.13** | **اضافه `PackageId` به `UserWalletChangeLog` + Migration داده** | **🆕 v4** | ### فاز ۲ — منطق کسب‌وکار ≈ ۵ روز (v4: +۱) | تسک | شرح | v4? | |-----|----- |-----| | **T2.1** | ادغام Verify handlers → Generic (DiscountMultiplier + UserPackagePurchase) | | | **T2.2** | ادغام Purchase handlers → Generic (حذف "طلایی"، حذف ID=4) | | | **T2.3** | بروزرسانی `ActivateClubMembership` — فیچر DIFF از PackageFeature (Q20) + First/LastActivation (Q21) | 🔄 v5 | | **T2.4** | بروزرسانی `ActivateClubMembership` — ActivationFee از Package | | | **T2.5** | اجازه re-purchase در Guards (G1–G3) | | | **T2.6** | ریست وضعیت در EXIT Magic Mode (بدون `IsActive=false` — Q19) | | | **T2.7** | حفظ guard قرارداد (Q19) + ایجاد `FeatureDiffService` (Q20) + بروزرسانی JWT (G7) | 🔄 v5 | | **T2.8** | بروزرسانی `CreateManualPayment` + `DayaLoan` | | | **T2.9** | PackageFeature CRUD | | | **T2.10** | Event: PackageCreated → ساخت Pool خالی | | | **T2.11** | **Magic Wallet: `ChargeMagicWallet` + `VerifyMagicWalletCharge` + `UserOrderService` EXIT/ENTRY → خواندن سقف/ضریب از Package** | **🆕 v4** | | **T2.12** | **Validators (۳ فایل): حذف hardcoded 1B → داینامیک** | **🆕 v4** | | **T2.13** | **JWT Claims: اضافه PackageId + PackageTitle + CanRepurchase، حذف HasPurchasedGoldenPackage** | **🆕 v4** | | **T2.14** | **`WalletGrpcService.GetMagicWalletStatus`: سقف per-package** | **🆕 v4** | | **T2.15** | **Notifications (۴ مورد): اضافه PackageId به interface + پیام** | **🆕 v4** | | **T2.16** | **بروزرسانی ۲۱ محل ساخت WalletChangeLog با PackageId** | **🆕 v4** | | **T2.17** | **ایجاد `FeatureDiffService` — سرویس DIFF فیچرها (Q20) + استفاده در Activate + AcceptContract** | **🆕 v5** | | **T2.18** | **بروزرسانی فلوی خرید مجدد — Skip قرارداد (Q19) + فقط FeatureDiff + شارژ wallet** | **🆕 v5** | ### فاز ۳ — محاسبه پورسانت ≈ ۴ روز (موازی با فاز ۲) | تسک | شرح | v3? | |-----|----- |-----| | **T3.1** | بروزرسانی `sp_CalculateWeeklyBalances` — `@PackageId` + **`@MaxBalancesPerLeg`** + **`@MaxNetworkLevel`** (حذف hardcode ۳۰۰/۱۵) | 🔄 | | **T3.2** | بروزرسانی `sp_CalculateWeeklyCommissionPool` — `@PackageId` + فیلتر تعادل‌های همان پکیج | 🔄 | | **T3.3** | بروزرسانی `WeeklyCommissionCalculationService` — Loop روی پکیج‌ها + ارسال تنظیمات | 🔄 | | **T3.4** | بروزرسانی `OrmCommissionCalculationStrategy` — فیلتر PackageId + خواندن Max از Package | 🔄 | | **T3.5** | **بروزرسانی `SpCommissionCalculationStrategy` — پاس دادن PackageId + MaxBalancesPerLeg + MaxNetworkLevel** | **🆕** | | **T3.6** | **Carryover per-package: week-shifting فقط رکوردهای همان PackageId** | **🆕** | | **T3.7** | تست محاسبات با داده واقعی (۲ پکیج موازی، carryover مجزا) | 🔄 | ### فاز ۴ — UI ≈ ۵ روز | تسک | شرح | v3? | |-----|----- |-----| | **T4.1** | FrontOffice: کاشی‌های پکیج (داینامیک) — ✅ `PackagePurchaseDialog.razor` | | | **T4.2** | FrontOffice: مدال پرداخت (دایا+مستقیم / فقط مستقیم) — ✅ Step 2 in dialog | | | **T4.3** | FrontOffice: MyPackages — re-purchase | | | **T4.4** | FrontOffice: ActivationSection + Contract — داینامیک | | | **T4.5** | BackOffice: CRUD پکیج — فیلدهای جدید (۱۱ فیلد) | 🔄 | | **T4.6** | BackOffice: ماتریس PackageFeature | | | **T4.7** | BackOffice: ActivateClubDialog — dropdown | | | **T4.8** | **FrontOffice: CommissionDashboard — کارت‌های خلاصه per-package + مجموع پاداش** | **🆕** | | **T4.9** | **FrontOffice: WeeklyBalance — تعادل تیم اول/دوم per-package** | **🆕** | | **T4.10** | **BackOffice: Commission Dashboard — فیلتر dropdown پکیج** | **🆕** | | **T4.11** | **BackOffice: Weekly Reports + CSV export — ستون پکیج + فیلتر** | **🆕** | | **T4.12** | **BackOffice: BalancesReport + UserPayouts — ستون پکیج + فیلتر** | **🆕** | | **T4.13** | **BackOffice: Package Create/Edit — Quick Access فیچرها (checkbox inline)** | **🆕** | | **T4.14** | **FrontOffice: MagicWalletChargePage — حذف ×۲.۵ و سقف hardcoded (۶ جا) → خواندن از API** | **🆕 v4** | | **T4.15** | **FrontOffice: حذف "پکیج طلایی" hardcoded (۵+ جا) + حذف PackageId=1** | **🆕 v4** | | **T4.16** | **FrontOffice: قرارداد حقوقی — مبلغ + نام پکیج داینامیک (مسئولیت حقوقی!)** | **🆕 v4** | | **T4.17** | **BackOffice: ManualActivationDialog — حذف 56M hardcoded + اضافه package selector** | **🆕 v4** | | **T4.18** | **BackOffice: SystemConfiguration — تفکیک تنظیمات global/per-package** | **🆕 v4** | ### فاز ۵ — تست و استقرار ≈ ۳ روز | تسک | شرح | v3? | |-----|----- |-----| | **T5.1** | تست خرید هر پکیج | | | **T5.2** | تست re-purchase بعد تکمیل چرخه | | | **T5.3** | تست Commission Pool جداگانه | | | **T5.4** | تست Migration | | | **T5.5** | **تست تعادل per-package: carryover مجزا، MaxBalancesPerLeg متفاوت** | **🆕** | | **T5.6** | **تست گزارش FO per-package: مشتری breakdown صحیح می‌بیند** | **🆕** | | **T5.7** | **تست گزارش BO per-package: فیلتر پکیج + CSV** | **🆕** | | **T5.8** | **تست Magic Wallet: سقف متفاوت per-package (پایه=1B, نقره‌ای=100M) — EXIT صحیح** | **🆕 v4** | | **T5.9** | **تست ضریب جادویی: پکیج A ×2.5 vs پکیج B ×2.0 — اعتبار صحیح** | **🆕 v4** | | **T5.10** | **تست قرارداد حقوقی: مبلغ و نام پکیج صحیح در متن** | **🆕 v4** | | **T5.11** | **تست WalletChangeLog: رکوردها PackageId دارند** | **🆕 v4** | | **T5.12** | **تست قرارداد یک‌بار: خرید مجدد بدون OTP/قرارداد (Q19)** | **🆕 v5** | | **T5.13** | **تست فیچر DIFF: تغییر پکیج → فیچرهای صحیح فعال/غیرفعال (Q20)** | **🆕 v5** | | **T5.14** | **تست First/Last: FirstActivationDate حفظ + LastActivationDate بروزرسانی (Q21)** | **🆕 v5** | | **T5.15** | **تست carryover تغییر پکیج: carryover قبلی شمرده نمی‌شود (Q23)** | **🆕 v5** | | **T5.16** | Deploy staging → production | --- ## ۱۲. ریسک‌ها (v5) | ریسک | احتمال | شدت | راه‌حل | |------|--------|-----|--------| | Migration داده‌ها — PackageId اشتباه | کم | بحرانی | Verify query + بکاپ | | SP تغییر → محاسبات اشتباه | متوسط | بحرانی | تست staging قبل production | | ادغام handlers → رگرسیون | متوسط | زیاد | E2E test | | خرید مجدد بدون تکمیل چرخه | کم | زیاد | Validation: چرخه قبلی MagicCompletedAt | | Proto breaking change | قطعی | کم | backward compatible fields | | **Magic Wallet EXIT اشتباه — کاربر گیر می‌افته** | **زیاد** | **بحرانی** | **اولویت P0 — سقف از Package خوانده شود** | | **قرارداد حقوقی با مبلغ اشتباه** | **زیاد** | **بحرانی** | **متن قرارداد داینامیک از Package** | | **۲۱ WalletChangeLog بدون ردیابی** | **قطعی** | **متوسط** | **اضافه PackageId به entity** | | **فیچر DIFF — حذف فیچر فعال (v5)** | **متوسط** | **بالا** | **کاربر اگر فیچر فعالی حذف بشه، باید اطلاع‌رسانی بشه** | | **Migration ActivatedAt → First/Last (v5)** | **کم** | **بحرانی** | **هر دو فیلد = ActivatedAt فعلی، بعداً Last بروزرسانی** | | **ناسازگاری AcceptContract vs Activate (v5)** | **زیاد** | **بالا** | **AcceptContract: overwrite ActivatedAt / Activate: حفظ — باید یکسان شوند** | --- ## ۱۳. تخمین زمانی (v5) | فاز | مدت | وابستگی | v5 تغییر | |-----|------|---------|----------| | فاز ۰ — فیکس باگ‌ها | ۱ روز | — | | | فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | ClubMembership: ۴ فیلد First/Last (Q21) + Migration ActivatedAt | | فاز ۲ — منطق | **۶ روز** | فاز ۱ | +۱ (FeatureDiffService + Skip قرارداد + First/Last Activation) | | فاز ۳ — پورسانت | ۴ روز | فاز ۱ | LastActivationDate برای تشخیص هفتگی (Q22) | | فاز ۴ — UI | **۷ روز** | فاز ۲ | | | فاز ۵ — تست | **۵ روز** | فاز ۳, ۴ | +۱ (تست قرارداد یک‌بار + فیچر DIFF + First/Last + carryover تغییر پکیج) | | **مجموع** | **~۲۸ روز** | | | > فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۴ روز** > نسبت به v4 (**۲۲ روز**): **+۲ روز** بخاطر FeatureDiffService + قرارداد یک‌بار + First/Last Activation > نسبت به v3 (**۱۷ روز**): **+۷ روز** — بزرگ‌ترین سهم: Magic Wallet + WalletChangeLog + فیچر DIFF + First/Last --- ## ۱۴. تصمیمات جدید v6 (Q24–Q30) > **تاریخ:** ۸ اسفند ۱۴۰۴ > **زمینه:** مرور نهایی قبل از تست و استقرار — ۷ نکته جدید شناسایی شد ### Q24 — آستانه موجودی برای ورود به Magic و خرید مجدد 🔴 > **مشکل:** شرط فعلی `wallet.Balance == 0` عملاً غیرممکنه — چون قیمت محصولات متفاوته (مثلاً ۳۵۰,۰۰۰ تومان، ۱,۲۰۰,۰۰۰ تومان، ...) کاربر هیچ‌وقت نمی‌تونه دقیقاً به صفر برسه. **تصمیم:** آستانه ثابت **۱,۰۰۰,۰۰۰ ریال (۱۰۰,۰۰۰ تومان)** — هم برای ورود به Magic، هم برای تشخیص تکمیل چرخه. **مکان‌های تاثیر:** | # | فایل | شرط فعلی | شرط جدید | |---|------|----------|----------| | 1 | `UserOrderService.cs` L342 | `if (wallet.Balance == 0)` — ورود به Magic | `if (wallet.Balance <= MagicWalletEntryThreshold)` | | 2 | `UserOrderService.cs` L342 | `if (wallet.Balance == 0)` — EXIT Magic check | `if (wallet.Balance <= MagicWalletEntryThreshold)` | | 3 | خرید مجدد guard | `PackagePurchaseMethod == None` | + `Balance <= Threshold` | **تعریف ثابت:** ```csharp // SystemConstants.cs یا Package entity public const long MagicWalletEntryThreshold = 1_000_000; // 100,000 تومان = 1,000,000 ریال ``` **فلوی جدید:** ``` کاربر خرید می‌کند → Balance = 850,000 ریال (کمتر از 1M) → سیستم: Balance <= 1,000,000 ✅ → ورود به Magic Mode → قبلاً: Balance == 0 ❌ → کاربر گیر می‌افتاد! Magic تکمیل → Balance = 200,000 ریال → سیستم: Balance <= 1,000,000 ✅ → EXIT Magic → مجاز به خرید مجدد → قبلاً: Balance == 0 ❌ → کاربر تا ابد در Magic! ``` > ⚠️ **نکته:** این آستانه **ثابت (global)** تعریف می‌شه — نه per-package. چون مربوط به قیمت محصولات فروشگاهه، نه پکیج. --- ### Q25 — DayaLoans فقط پکیج پایه > **تایید:** وام دایا فعلاً فقط برای پکیج پایه فعاله و تغییر نمی‌کنه. **وضعیت فعلی:** - فیلد `Package.SupportsDayaPurchase` روی پکیج پایه = `true`، نقره‌ای = `false` - `CheckAndProcessDayaLoansCommandHandler` فقط پکیج‌هایی که `SupportsDayaPurchase = true` دارن رو بررسی می‌کنه - این یک تصمیم بیزینسی ثابته — اگر در آینده تغییر کرد، ادمین از BackOffice فلگ رو تغییر می‌ده --- ### Q26 — SP Worker (مدیریت خودکار Stored Procedures) 🔴 > **مشکل:** الان SPها **کاملاً دستی** deploy می‌شن. اگه SP تغییر کنه، باید یادمون باشه دستی اجرا کنیم. **تصمیم:** یک `IHostedService` به نام `StoredProcedureDeploymentService` ایجاد بشه. **الگوریتم:** ``` 1. Startup → خواندن فایل‌های .sql از Embedded Resources 2. برای هر فایل: a. محاسبه SHA256 checksum محتوا b. خواندن checksum قبلی از جدول "CMS"."StoredProcedureVersions" c. اگر وجود نداشت یا checksum فرق داشت: → اجرای CREATE OR ALTER PROCEDURE → ذخیره checksum جدید → لاگ: "SP {name} deployed/updated" d. اگر checksum برابر بود: → لاگ: "SP {name} is up-to-date, skipping" 3. اجرا بعد از EF Migration (ترتیب startup) ``` **جدول جدید:** ```sql CREATE TABLE "CMS"."StoredProcedureVersions" ( "Id" BIGSERIAL PRIMARY KEY, "Name" VARCHAR(256) NOT NULL UNIQUE, -- نام SP "Checksum" VARCHAR(64) NOT NULL, -- SHA256 hash "DeployedAt" TIMESTAMP NOT NULL, -- آخرین deploy "Version" INT NOT NULL DEFAULT 1 -- شمارنده نسخه ); ``` **فایل‌های SP فعلی (embed شوند):** | # | فایل | خطوط | |---|------|------| | 1 | `sp_CalculateWeeklyBalances.sql` | ۳۷۲ | | 2 | `sp_CalculateWeeklyCommissionPool.sql` | ۲۶۷ | **csproj تغییرات:** ```xml ``` --- ### Q27 — History Tables — یکسان‌سازی و ثبت خودکار 🟡 > **مشکل:** نام‌گذاری history tableها ناسازگاره و ثبت تاریخچه دستیه. **وضعیت فعلی:** | Master Entity | History Entity | نام‌گذاری | |---------------|---------------|----------| | `UserCommissionPayout` | `CommissionPayoutStatusHistory` | ❌ نام متفاوت | | `CommissionCashWithdrawal` | `CommissionCashWithdrawalStatusHistory` | ✅ مشابه | | `DiscountOrder` | `DiscountOrderStatusHistory` | ✅ مشابه | | `UserWallet` | `UserWalletChangeLog` | ❌ نام متفاوت | | `Package` | ❌ ندارد | 🔴 | | `ClubMembership` | ❌ ندارد | 🔴 | | `ClubMembershipCycle` | ❌ ندارد | 🔴 | **تصمیم — ۲ بخش:** **بخش ۱: نام‌گذاری یکسان** ``` الگو: {MasterEntityName}History مثال: UserCommissionPayout → UserCommissionPayoutHistory (rename از CommissionPayoutStatusHistory) UserWallet → UserWalletHistory (rename از UserWalletChangeLog) Package → PackageHistory (جدید) ClubMembership → ClubMembershipHistory (جدید) ClubMembershipCycle → ClubMembershipCycleHistory (جدید) ``` **بخش ۲: ثبت خودکار تغییرات** ``` دو رویکرد: A) EF Interceptor (پیشنهادی): → در SaveChangesInterceptor، entity‌های تغییریافته شناسایی → برای هر entity که IHasHistory پیاده کرده: snapshot فعلی → رکورد History جدید → مزیت: یکجا و خودکار B) Domain Events: → هر entity یک EntityChangedEvent publish کنه → Handler مربوطه History ثبت کنه → مزیت: async + decoupled ``` **Interface پیشنهادی:** ```csharp public interface IHasHistory where THistory : BaseHistoryEntity { THistory CreateHistorySnapshot(string action, long? changedBy); } public abstract class BaseHistoryEntity : BaseEntity { public long MasterEntityId { get; set; } // FK به entity اصلی public string Action { get; set; } // Created, Updated, StatusChanged, ... public long? ChangedBy { get; set; } // UserId تغییردهنده public DateTime ChangedAt { get; set; } // زمان تغییر public string? Snapshot { get; set; } // JSON snapshot (اختیاری) } ``` --- ### Q28 — UI Guidance (آموزش و هشدار در FO/BO) 🟡 > **تصمیم:** در سراسر FrontOffice و BackOffice، متن‌های آموزشی و هشداری اضافه بشه. **انواع Guidance:** | نوع | جایگاه | مثال | |-----|--------|------| | **مودال آموزشی** | اولین بار باز کردن صفحه | «سیستم پکیج‌بیس: شما می‌توانید بعد از تکمیل چرخه، پکیج جدید بخرید» | | **متن inline** | کنار فیلد/آیتم | «💡 هزینه فعال‌سازی از قیمت پکیج محاسبه می‌شود» | | **هشدار** | قبل از اقدام بحرانی | «⚠️ با تغییر پکیج، فیچرهای قبلی ممکنه غیرفعال بشن» | | **Tooltip** | روی آیکون اطلاعات | «این مبلغ بر اساس پکیج فعال شما محاسبه شده» | **صفحات هدف FrontOffice:** | # | صفحه | نوع | محتوا | |---|-------|-----|-------| | G1 | صفحه پکیج‌ها | مودال (اولین بار) | توضیح سیستم پکیج‌بیس، تفاوت پکیج‌ها، نحوه خرید | | G2 | Checkout | متن inline | «این پکیج {features} را شامل می‌شود» | | G3 | MyPackages | متن inline | «بعد تکمیل چرخه جادویی، می‌توانید پکیج جدید بخرید» | | G4 | Magic Wallet | هشدار مودال | «سقف واریز و ضریب بر اساس پکیج شماست» | | G5 | Commission Dashboard | tooltip | «پاداش بر اساس هر پکیج جداگانه محاسبه می‌شود» | | G6 | قرارداد باشگاه | متن inline | «این قرارداد فقط یک‌بار امضا می‌شود» | | G7 | ActivationSection | متن inline | «هزینه تخمینی بر اساس پکیج {name} محاسبه شده» | **صفحات هدف BackOffice:** | # | صفحه | نوع | محتوا | |---|-------|-----|-------| | G8 | Package CRUD | متن inline | «تغییر قیمت روی کاربران فعلی اثر ندارد — فقط خریدهای جدید» | | G9 | Feature Matrix | tooltip | «فیچرهای تیک‌خورده برای خریداران این پکیج فعال می‌شود» | | G10 | Manual Payment | هشدار | «مبلغ پرداخت باید مطابق قیمت پکیج انتخابی باشد» | | G11 | Commission Reports | متن inline | «هر پکیج Pool پورسانت مستقل دارد» | | G12 | User Detail | متن inline | «پکیج فعلی: {name} — چرخه: {n}» | | G13 | Club Members | tooltip | «فیلتر بر اساس آخرین پکیج خریداری‌شده» | **پیاده‌سازی فنی:** ```razor @* FrontOffice — کامپوننت Guidance قابل استفاده مجدد *@ @* BackOffice — متن inline *@ 💡 تغییر قیمت روی کاربران فعلی اثر ندارد — فقط خریدهای جدید ``` --- ### Q29 — شرط EXIT Magic — تایید: آخرین پکیج فعال > **تایید:** شرط EXIT از `ClubMembershipCycle` فعلی (`IsCurrentCycle=true`) → `PackageId` → `Package.MagicWalletMaxDeposit` خوانده می‌شه. **فلوی دقیق:** ``` 1. UserOrderService.ProcessOrderAsync → wallet.Balance کم می‌شه 2. if (Balance <= Threshold): ← Q24 آستانه جدید a. اگر Normal + خرید کرده + باشگاه فعال → ENTER Magic b. اگر Magic: → خواندن چرخه فعلی → PackageId → Package.MagicWalletMaxDeposit → if (MagicTotalDeposited >= MaxDeposit) → EXIT Magic → else: هنوز Magic (می‌تونه شارژ کنه) ``` > **Fallback:** اگه چرخه فعالی نبود → `IsBasePackage = true` (پکیج پایه) --- ### Q30 — Carryover — توضیح ساده (v6 اصلاح‌شده) > **Carryover = باقی‌مانده تعادل از هفته قبل — بر اساس پکیج زیرمجموعه‌ها** **مثال ساده:** ``` علی بالاسری — زیرمجموعه‌هاش پکیج پایه دارن: هفته ۱۰: تیم چپ (پکیج پایه) = ۵۰ نفر، تیم راست (پکیج پایه) = ۳۰ نفر تعادل_پایه = MIN(50, 30) = 30 باقی‌مانده_پایه: {چپ: 20, راست: 0} ← CARRYOVER هفته ۱۱: اعضای جدید (پکیج پایه): چپ = 5، راست = 10 جمع با carryover: چپ = 5+20 = 25، راست = 10+0 = 10 تعادل_پایه = MIN(25, 10) = 10 باقی‌مانده_پایه: {چپ: 15, راست: 0} ← CARRYOVER بعدی ``` **تغییر پکیج خود کاربر — تاثیری ندارد (Q23 اصلاحی):** ``` علی پکیج خودش رو عوض می‌کنه (پایه → نقره‌ای): carryover_پایه = {چپ: 20, راست: 0} ← هنوز هست! حذف نمی‌شه! carryover_نقره‌ای = {چپ: 0, راست: 0} ✅ هفته بعد، علی هنوز از Pool پایه سود می‌بره (چون زیرمجموعه‌هایی با پکیج پایه داره) ✅ همزمان از Pool نقره‌ای هم سود می‌بره (چون زیرمجموعه‌هایی با پکیج نقره‌ای هم داره) پکیج خود علی فقط تعیین می‌کنه: → ActivationFee‌اش به کدوم Pool بره → MagicWallet سقفش چقدره ```