From 977ef69e269024fd7fe1b7770e1b3698dc9d1de4 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Wed, 25 Feb 2026 21:50:02 +0330 Subject: [PATCH] =?UTF-8?q?docs(biz):=20v5=20=E2=80=94=20=D9=82=D8=B1?= =?UTF-8?q?=D8=A7=D8=B1=D8=AF=D8=A7=D8=AF=20=DB=8C=DA=A9=E2=80=8C=D8=A8?= =?UTF-8?q?=D8=A7=D8=B1=20(Q19)=20+=20=D9=81=DB=8C=DA=86=D8=B1=20DIFF=20(Q?= =?UTF-8?q?20)=20+=20First/Last=20Activation=20(Q21-Q22)=20+=20carryover?= =?UTF-8?q?=20=D8=AA=D8=BA=DB=8C=DB=8C=D8=B1=20=D9=BE=DA=A9=DB=8C=D8=AC=20?= =?UTF-8?q?(Q23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit تغییرات بنیادی v5: - Q19: قرارداد باشگاه فقط یک بار امضا — حذف re-contract از G5, A10, T2.7 - Q20: فیچرها DIFF/تفاضل — FeatureDiffService جدید (مقایسه + اعمال اختلاف) - Q21: ClubMembership: ActivatedAt → FirstActivationDate + LastActivationDate + FirstPackageId + LastPackageId - Q22: تشخیص فعال‌شدگان هفته از LastActivationDate - Q23: carryover strictly per-package — تغییر پکیج = carryover قبلی شمرده نمی‌شود بخش‌های جدید: - 5.4: قرارداد یک‌بار + فلوچارت خرید مجدد بدون قرارداد - 5.5: الگوریتم DIFF فیچرها + مثال عملی + کد پیشنهادی - 5.6: تشخیص فعال‌شدگان هفته (SQL) - ClubMembership entity v5 با ۴ فیلد جدید اصلاحات: - EXIT Magic Mode: حذف membership.IsActive=false - State diagram: re-purchase بدون قرارداد - Migration: ActivatedAt → First/LastActivationDate - Impact Analysis: 95+ تغییر (51 اصلی + 44 سایدافکت) - Timeline: ~28 روز مجموع، ~24 روز critical path --- business/BIZ-PACKAGE-BASED-SYSTEM.md | 445 +++++++++++++++++++++++---- 1 file changed, 382 insertions(+), 63 deletions(-) diff --git a/business/BIZ-PACKAGE-BASED-SYSTEM.md b/business/BIZ-PACKAGE-BASED-SYSTEM.md index 7b58014..b0f5494 100644 --- a/business/BIZ-PACKAGE-BASED-SYSTEM.md +++ b/business/BIZ-PACKAGE-BASED-SYSTEM.md @@ -1,9 +1,9 @@ # 📦 سیستم مبتنی بر پکیج (Package-Based System) > **وضعیت:** تایید‌شده — آماده پیاده‌سازی -> **تاریخ بروزرسانی:** ۶ اسفند ۱۴۰۴ -> **نسخه:** v4 (تحلیل جامع سایدافکت‌ها — ۴۴ مورد جدید) -> **تاثیرگذاری:** زیاد — **۹۰+** تغییر در ۶ لایه (۴۸ اصلی + ۴۴ سایدافکت) +> **تاریخ بروزرسانی:** ۷ اسفند ۱۴۰۴ +> **نسخه:** v5 (قرارداد یک‌بار + فیچر DIFF + تاریخچه فعال‌سازی) +> **تاثیرگذاری:** زیاد — **۹۵+** تغییر در ۶ لایه (۵۱ اصلی + ۴۴ سایدافکت) --- @@ -37,6 +37,11 @@ | 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 تعادل هفتگی | ✅ **strictly per-package** — اگر کاربر پکیج عوض کرد، carryover پکیج قبلی شمرده نمی‌شود | --- @@ -93,8 +98,8 @@ | G2 | `InitiateBasePackagePaymentCommandHandler` | `PackagePurchaseMethod != None` → fail | مسدود | ✅ اجازه بعد تکمیل چرخه | | G3 | `PurchasePackageCommandHandler` | `PackagePurchaseMethod != None` → throw | مسدود | ✅ اجازه بعد تکمیل چرخه | | G4 | `CheckAndProcessDayaLoansCommandHandler` | `hasPreviousCycle` → skip | عمدی ✅ | ❌ حفظ (دایا فقط دور اول) | -| G5 | `AcceptClubMembershipContractCommandHandler` | `IsActive == true` → fail | مسدود | ✅ اجازه re-contract | -| G6 | `ActivateClubMembershipCommandHandler` | `IsActive == true` → return true | short-circuit | ✅ باید چرخه جدید بسازه | +| 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 — خرید مجدد کار نمی‌کند @@ -106,8 +111,11 @@ EXIT Magic Mode (UserOrderService.cs): ✅ cycle.MagicCompletedAt = now ❌ MISSING: user.PackagePurchaseMethod = None ← Guards G1-G3 مسدود می‌مانند - ❌ MISSING: membership.IsActive = false ← Guards G5-G6 مسدود می‌مانند + ❌ MISSING: cycle.IsCurrentCycle = false ← آماده چرخه جدید ❌ MISSING: JWT CanRepurchase = true ← UI دکمه خرید نشان نمی‌دهد + + ℹ️ membership.IsActive حفظ می‌شود (قرارداد یک‌بار — Q19) + ℹ️ فیچرها حفظ می‌شوند — در خرید مجدد DIFF اعمال می‌شود (Q20) ``` **راه‌حل:** در EXIT Magic Mode، وضعیت کاربر ریست شود تا بتواند پکیج جدید بخرد. @@ -178,12 +186,68 @@ public class PackageFeature : BaseAuditableEntity | Entity | فیلد جدید | توضیح | |--------|-----------|-------| -| `ClubMembership` | `long? PackageId` + FK | آخرین پکیج خریداری‌شده | +| `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 @@ -242,19 +306,21 @@ stateDiagram-v2 NoPurchase --> PackagePurchased: خرید پکیج\n(هر پکیجی) - PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد) + PackagePurchased --> ClubActivated: فعالسازی باشگاه\n(OTP + قرارداد — فقط بار اول) ClubActivated --> Shopping: خرج Balance\nدر فروشگاه Shopping --> MagicMode: Balance == 0 - MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف 1B) + MagicMode --> MagicCharging: شارژ + خرج\n(تا سقف per-package) MagicCharging --> MagicMode: ادامه - MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ 1B + MagicMode --> CycleComplete: Balance == 0\nAND Deposit ≥ MaxDeposit - CycleComplete --> NoPurchase: ریست وضعیت\nآماده خرید مجدد + CycleComplete --> RePurchase: ریست PackagePurchaseMethod\n(membership فعال باقی — Q19) + + RePurchase --> ClubActivated: خرید مجدد\n(بدون قرارداد + فیچر DIFF) ``` ### ۵.۲ ریست وضعیت بعد تکمیل چرخه (EXIT Magic Mode) @@ -266,17 +332,203 @@ wallet.MagicCompletedAt = DateTime.UtcNow; cycle.MagicCompletedAt = DateTime.UtcNow; // ✅ اضافه شود: -user.PackagePurchaseMethod = PackagePurchaseMethod.None; // اجازه خرید مجدد -membership.IsActive = false; // اجازه re-contract +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. **فیچرها بر اساس پکیج جدید** — ممکنه متفاوت باشه +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; +``` --- @@ -307,16 +559,21 @@ Commission: Reza → پاداش از Pool_پایه ``` -### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v3) +### ۶.۳ فلوی کامل چرخه خرید مجدد و تاثیر بر پورسانت (v5) ``` -چرخه ۱ — کاربر "علی" پکیج پایه می‌خرد (۵۶M): -──────────────────────────────────────────────── +چرخه ۱ — کاربر "علی" پکیج پایه می‌خرد (۵۶M) — اولین خرید: +──────────────────────────────────────────────────────────────── ✅ Balance += 56M ✅ DiscountBalance += 112M (×2) ✅ ActivationFee → Pool_پایه هفته جاری - ✅ فیچرهای پکیج پایه فعال - ✅ NetworkWeeklyBalance ساخته می‌شود (PackageId=پایه) + ✅ OTP + امضای قرارداد (فقط این بار — Q19) + ✅ فیچرهای پکیج پایه فعال (DIFF = همه اضافه — لیست خالی بود) + ✅ ClubMembership: + FirstActivationDate = now, FirstPackageId = پایه + LastActivationDate = now, LastPackageId = پایه + ✅ ClubMembershipCycle #1 ساخته می‌شود + ✅ NetworkWeeklyBalance (PackageId=پایه, WeekId=هفته جاری) ✅ بالاسری‌ها: تعادل‌ها بر اساس MaxBalancesPerLeg=300 + MaxNetworkLevel=15 ✅ پورسانت بالاسری‌ها از Pool_پایه @@ -325,11 +582,16 @@ Commission: چرخه ۲ — کاربر "علی" دوباره پکیج پایه می‌خرد: ────────────────────────────────────────────── - ✅ ریست وضعیت: PackagePurchaseMethod=None, membership.IsActive=false + ✅ ریست وضعیت: PackagePurchaseMethod=None (membership فعال باقی — Q19) ✅ Balance += 56M (مجدد شارژ) ✅ DiscountBalance += 112M (مجدد شارژ) ✅ ActivationFee → Pool_پایه هفته جاری - ✅ فیچرهای پکیج پایه فعال (مجدد) + ❌ بدون قرارداد مجدد (Q19 — قبلاً امضا شده) + ✅ فیچر DIFF = خالی (همان پکیج → بدون تغییر فیچر — Q20) + ✅ ClubMembership: + FirstActivationDate = حفظ, FirstPackageId = حفظ + LastActivationDate = now (بروزرسانی), LastPackageId = پایه + ✅ ClubMembershipCycle #2 ساخته می‌شود ✅ NetworkWeeklyBalance جدید (PackageId=پایه, WeekId=هفته جاری) ✅ carryover از هفته قبل: فقط carryover پکیج پایه (نه نقره‌ای!) ✅ بالاسری‌ها: محاسبه مجدد از Pool_پایه @@ -341,18 +603,30 @@ Commission: ✅ Balance += 5.6M ✅ DiscountBalance += 11.2M (×2) ✅ ActivationFee → Pool_نقره‌ای هفته جاری (۲,۵۲۰,۰۰۰) - ✅ فیچرهای پکیج نقره‌ای فعال (ممکنه کمتر از پایه باشه!) + ❌ بدون قرارداد مجدد (Q19) + ✅ فیچر DIFF (Q20): + فعلی: [چاتیکا, بیمه, سفر, آموزش] (از پکیج پایه) + جدید: [چاتیکا, بیمه] (از پکیج نقره‌ای) + → حذف: [سفر, آموزش] → IsActive=false + → اضافه: [] → هیچی + → حفظ: [چاتیکا, بیمه] + ✅ ClubMembership: + FirstActivationDate = حفظ, FirstPackageId = حفظ (پایه) + LastActivationDate = now (بروزرسانی), LastPackageId = نقره‌ای + ✅ ClubMembershipCycle #5 ساخته می‌شود (PackageId=نقره‌ای) ✅ NetworkWeeklyBalance جدید (PackageId=نقره‌ای, WeekId=هفته جاری) ✅ تعادل‌ها: MaxBalancesPerLeg=30 (نه 300!) + MaxNetworkLevel=15 (از پکیج) - ✅ carryover: فقط carryover نقره‌ای (جداگانه از پایه) + ✅ carryover: فقط carryover نقره‌ای (Q23 — carryover پکیج پایه شمرده نمی‌شود!) ✅ بالاسری‌ها: محاسبه از Pool_نقره‌ای → ValuePerBalance کمتر ✅ پاداش بالاسری: ~۲,۵۲۰,۰۰۰ ÷ TotalBalances_نقره‌ای × BalancesEarned ``` -### ۶.۴ تعادل‌ها (NetworkWeeklyBalance) — per-package (v3) +### ۶.۴ تعادل‌ها (NetworkWeeklyBalance) — per-package (v5) > ⚠️ **تغییر اساسی:** هر کاربر **به‌ازای هر پکیج فعال** یک رکورد تعادل جداگانه دارد. +> 🔴 **قانون carryover v5 (Q23):** اگر کاربر **پکیج عوض کرد**، carryover پکیج قبلی **شمرده نمی‌شود!** + ``` قبل (تک‌پکیج): NetworkWeeklyBalance: [UserId, WeekId] → یک رکورد @@ -365,9 +639,15 @@ Commission: ``` برای هر پکیج فعال: - 1. واکشی کاربرانی که این پکیج را دارند (PackageId = X) - 2. carryover از هفته قبل: فقط رکوردهای PackageId = X - 3. اعضای جدید: فقط کسانی که PackageId = X خریدند + JoinedAt در بازه هفته + 1. واکشی کاربرانی که این پکیج را دارند: + ClubMembership.LastPackageId = X (v5 — Q21) + 2. carryover از هفته قبل: + فقط رکوردهای PackageId = X + ✅ مهم: فقط اگر پکیج فعلی کاربر = X (Q23) + ❌ اگر کاربر هفته قبل پکیج Y داشت و حالا X دارد → carryover Y شمرده نمی‌شود! + 3. اعضای جدید این هفته: + ClubMembership.LastActivationDate در بازه هفته + AND ClubMembership.LastPackageId = X (v5 — Q22) 4. LeftLegTotal = NewLeft + CarryoverLeft 5. RightLegTotal = NewRight + CarryoverRight 6. TotalBalances = MIN(Left, Right) → cap at Package.MaxBalancesPerLeg @@ -375,6 +655,22 @@ Commission: 8. SubordinateBalances: مجموع TotalBalances زیرمجموعه (تا Package.MaxNetworkLevel) ``` +**مثال carryover با تغییر پکیج (v5 — Q23):** + +``` +هفته ۹: + علی: پکیج پایه → تعادل_پایه: Left=45, Right=30 + carryover_پایه: {Left: 15, Right: 0} ← باقیمانده + +هفته ۱۰: + علی: پکیج پایه → EXIT Magic → خرید پکیج نقره‌ای + + ❌ carryover_پایه {Left:15, Right:0} شمرده نمی‌شود! + (پکیج فعلی = نقره‌ای ≠ پایه) + ✅ carryover_نقره‌ای: {Left:0, Right:0} (تازه شروع) + ✅ تعادل_نقره‌ای: Left=NewLeft+0, Right=NewRight+0 +``` + **مثال عملی:** ``` @@ -584,7 +880,7 @@ flowchart LR | `PackageCreated` | ساخت WeeklyCommissionPool خالی هفته جاری | | `PackageDeactivated` | هشدار ادمین — Pool موجود تکمیل شود | | `PackagePurchased` | ActivationFee → Pool پکیج + شارژ wallets | -| `MagicCycleCompleted` | ریست PackagePurchaseMethod + Deactivate membership | +| `MagicCycleCompleted` | ریست PackagePurchaseMethod + حفظ membership فعال (Q19) | --- @@ -689,25 +985,32 @@ claims.Add("CanRepurchase", HasCompletedMagicCycle(membership).ToString()); --- -### ۷٫۵٫۵ Club Features — تخصیص global +### ۷٫۵٫۵ Club Features — تخصیص global → DIFF (v5) -> 🔴 **بحرانی — همه فیچرها به همه کاربران** +> 🔴 **بحرانی — فیچرها باید DIFF/تفاضل باشند (Q20)** ```csharp -// ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs: +// فعلی (اشتباه) — ActivateClubMembershipCommandHandler.cs + AcceptClubMembershipContractCommandHandler.cs: var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds(); // ↑ همیشه همه فیچرها (Chatika, Bime, Trip, Learn) — صرف‌نظر از پکیج! ``` -**مشکل:** پکیج نقره‌ای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال می‌کنه. +**مشکل ۱:** پکیج نقره‌ای ممکنه فقط ۲ فیچر داشته باشه ولی سیستم فعلی **همه ۴ فیچر** رو فعال می‌کنه. +**مشکل ۲:** در خرید مجدد، باید فقط **تفاضل** اعمال بشه (Q20) — نه تخصیص مجدد همه. -**اقدام:** +**اقدام (v5):** ```csharp -// باید بشه: -var featureIds = await _context.PackageFeatures - .Where(pf => pf.PackageId == package.Id && pf.IsIncluded) - .Select(pf => pf.ClubFeatureId) - .ToListAsync(); +// خرید اول (currentFeatures خالی → همه فیچرها اضافه): +await _featureDiffService.ApplyFeatureDiffAsync( + userId, membershipId, + oldPackageId: 0, // بدون پکیج قبلی + newPackageId: package.Id); + +// خرید مجدد (مقایسه + تفاضل): +await _featureDiffService.ApplyFeatureDiffAsync( + userId, membershipId, + oldPackageId: membership.LastPackageId, + newPackageId: newPackage.Id); ``` --- @@ -873,9 +1176,14 @@ BEGIN SELECT "Id" INTO base_pkg_id FROM "CMS"."Packages" WHERE "IsBasePackage" = true LIMIT 1; - -- STEP 2: ClubMembership + -- STEP 2: ClubMembership — v5 migration (Q21) + -- ActivatedAt → FirstActivationDate + LastActivationDate UPDATE "CMS"."ClubMemberships" - SET "PackageId" = base_pkg_id WHERE "PackageId" IS NULL; + SET "FirstActivationDate" = "ActivatedAt", + "LastActivationDate" = "ActivatedAt", + "FirstPackageId" = base_pkg_id, + "LastPackageId" = base_pkg_id + WHERE "FirstActivationDate" IS NULL; -- STEP 3: ClubMembershipCycle UPDATE "CMS"."ClubMembershipCycles" @@ -897,7 +1205,7 @@ BEGIN END $$; -- STEP 7: Verify — همه باید 0 باشند -SELECT 'ClubMemberships' AS tbl, COUNT(*) FROM "CMS"."ClubMemberships" WHERE "PackageId" IS NULL +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 @@ -910,9 +1218,9 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" --- -## ۱۱. Impact Analysis — ۹۰+ تغییر در ۶ لایه (v4) +## ۱۱. Impact Analysis — ۹۵+ تغییر در ۶ لایه (v5) -> ۴۸ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۲ تغییر کل** +> ۵۱ تغییر اصلی (بخش ۱۰) + ۴۴ سایدافکت (بخش ۷.۵) = **۹۵ تغییر کل** ### ۱۰.۱ لایه Domain (۱۰ تغییر) @@ -920,7 +1228,7 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" |---|------|-----|------|-----| | D1 | `Package.cs` | اضافه **۱۱ فیلد** جدید (v2: ۷ + v3: ۴) | 🟡 | 🔄 | | D2 | `PackageFeature.cs` | Entity جدید + EF Config | 🔴 | | -| D3 | `ClubMembership.cs` | اضافه `PackageId` | 🟡 | | +| 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) | 🟡 | 🔄 | @@ -943,17 +1251,19 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" | A7 | `PurchasePackageCommandHandler` | اجازه re-purchase | 🟡 | | A8 | `CreateManualPaymentCommandHandler` | DiscountMultiplier از Package | 🟡 | | A9 | `CheckAndProcessDayaLoansCommandHandler` | حذف ID=4 + DiscountMultiplier | 🟡 | -| A10 | `AcceptClubMembershipContractCommandHandler` | اجازه re-contract بعد چرخه | 🟡 | +| 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)** | **همه فیچرها global → per-package از PackageFeature** | **🔴 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 (۶ تغییر) @@ -1029,7 +1339,7 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" |-----|----- |-----| | **T1.1** | بروزرسانی `Package` entity (**۱۱ فیلد** جدید: v2 ۷ + v3 ۴ شامل MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit, MagicWalletMaxCredit) | 🔄 | | **T1.2** | ایجاد `PackageFeature` entity + EF Config | | -| **T1.3** | اضافه `PackageId` به `ClubMembership` | | +| **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) | 🔄 | @@ -1047,11 +1357,11 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" |-----|----- |-----| | **T2.1** | ادغام Verify handlers → Generic (DiscountMultiplier + UserPackagePurchase) | | | **T2.2** | ادغام Purchase handlers → Generic (حذف "طلایی"، حذف ID=4) | | -| **T2.3** | بروزرسانی `ActivateClubMembership` — فیچر از PackageFeature | | +| **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 | | -| **T2.7** | اجازه re-contract (G5) + بروزرسانی JWT (G7) | | +| **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 خالی | | @@ -1061,6 +1371,8 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" | **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** | ### فاز ۳ — محاسبه پورسانت ≈ ۴ روز (موازی با فاز ۲) @@ -1112,11 +1424,15 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" | **T5.9** | **تست ضریب جادویی: پکیج A ×2.5 vs پکیج B ×2.0 — اعتبار صحیح** | **🆕 v4** | | **T5.10** | **تست قرارداد حقوقی: مبلغ و نام پکیج صحیح در متن** | **🆕 v4** | | **T5.11** | **تست WalletChangeLog: رکوردها PackageId دارند** | **🆕 v4** | -| **T5.12** | Deploy staging → production | +| **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 | --- -## ۱۲. ریسک‌ها (v4) +## ۱۲. ریسک‌ها (v5) | ریسک | احتمال | شدت | راه‌حل | |------|--------|-----|--------| @@ -1128,21 +1444,24 @@ SELECT 'Balances', COUNT(*) FROM "CMS"."NetworkWeeklyBalances" WHERE "PackageId" | **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: حفظ — باید یکسان شوند** | --- -## ۱۳. تخمین زمانی (v4) +## ۱۳. تخمین زمانی (v5) -| فاز | مدت | وابستگی | v4 تغییر | +| فاز | مدت | وابستگی | v5 تغییر | |-----|------|---------|----------| | فاز ۰ — فیکس باگ‌ها | ۱ روز | — | | -| فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | +۱ (WalletChangeLog PackageId + ۲۱ محل) | -| فاز ۲ — منطق | **۵ روز** | فاز ۱ | +۱ (Magic Wallet + Validators + JWT + Notifications) | -| فاز ۳ — پورسانت | ۴ روز | فاز ۱ | | -| فاز ۴ — UI | **۷ روز** | فاز ۲ | +۲ (Magic Wallet UI + قرارداد + ManualActivation + SystemConfig) | -| فاز ۵ — تست | **۴ روز** | فاز ۳, ۴ | +۱ (تست Magic per-package + قرارداد + WalletLog) | -| **مجموع** | **~۲۶ روز** | | | +| فاز ۱ — زیرساخت | **۵ روز** | فاز ۰ | ClubMembership: ۴ فیلد First/Last (Q21) + Migration ActivatedAt | +| فاز ۲ — منطق | **۶ روز** | فاز ۱ | +۱ (FeatureDiffService + Skip قرارداد + First/Last Activation) | +| فاز ۳ — پورسانت | ۴ روز | فاز ۱ | LastActivationDate برای تشخیص هفتگی (Q22) | +| فاز ۴ — UI | **۷ روز** | فاز ۲ | | +| فاز ۵ — تست | **۵ روز** | فاز ۳, ۴ | +۱ (تست قرارداد یک‌بار + فیچر DIFF + First/Last + carryover تغییر پکیج) | +| **مجموع** | **~۲۸ روز** | | | -> فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۲ روز** -> نسبت به v3 (**۱۷ روز**): **+۵ روز** بخاطر ۴۴ سایدافکت کشف‌شده -> نسبت به v2 (**۱۴ روز**): **+۸ روز** — بزرگ‌ترین سهم: Magic Wallet + WalletChangeLog + قرارداد حقوقی +> فاز ۲ و ۳ **موازی** → مسیر بحرانی: ۰→۱→۲→۴→۵ = **~۲۴ روز** +> نسبت به v4 (**۲۲ روز**): **+۲ روز** بخاطر FeatureDiffService + قرارداد یک‌بار + First/Last Activation +> نسبت به v3 (**۱۷ روز**): **+۷ روز** — بزرگ‌ترین سهم: Magic Wallet + WalletChangeLog + فیچر DIFF + First/Last