Files
docs/migration/customer-facing-capabilities-codex.md
T

197 KiB

تحلیل امکانات قابل ارائه به مشتری (Codex)

تحلیل مختصر بر اساس: CMS/cms-data-and-business.md, CMS/network-club-commission-system-v1.1.md, CMS/balance-calculation-carryover-logic.md, CMS/email-sms-configuration-guide.md, REMAINING-TASKS.md.


🏗️ راهنمای معماری: جریان توسعه از CMS تا FrontOffice

📐 ساختار کلی پروژه

┌─────────────────────────────────────────────────────────────┐
│                     USER (Customer)                         │
│                    مشتری / کاربر نهایی                      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│              FrontOffice (Blazor WebAssembly)               │
│                       فرانت سمت مشتری                       │
│  Location: FrontOffice/src/FrontOffice.Main/                │
│  Technology: Blazor WASM + MudBlazor                        │
│  Files: Pages/*.razor, Components/*.razor                   │
└──────────────────────────┬──────────────────────────────────┘
                           │ HTTP/REST
                           ▼
┌─────────────────────────────────────────────────────────────┐
│          FrontOffice.BFF (Backend For Frontend)             │
│                    گیت‌وی سمت مشتری                         │
│  Location: FrontOffice.BFF/src/                             │
│  Technology: ASP.NET Core REST API                          │
│  Structure:                                                 │
│    ├── Application/                                         │
│    │   ├── [ModuleName]CQ/                                  │
│    │   │   ├── Commands/                                    │
│    │   │   └── Queries/                                     │
│    │   └── DTOs/                                            │
│    └── WebApi/                                              │
│        └── Controllers/                                     │
└──────────────────────────┬──────────────────────────────────┘
                           │ gRPC (CMS Protobuf)
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    CMS (Microservice)                        │
│                  سرویس اصلی / دیتابیس                       │
│  Location: CMS/src/CMSMicroservice.*/                       │
│  Technology: ASP.NET Core + gRPC + SQL Server               │
│  Structure (Clean Architecture):                            │
│    ├── Domain/         (Entities, Enums, Events)            │
│    ├── Application/    (Commands, Queries, Handlers)        │
│    ├── Infrastructure/ (Database, Services)                 │
│    ├── Protobuf/       (gRPC Proto definitions)             │
│    └── WebApi/         (gRPC Services, Hangfire)            │
└─────────────────────────────────────────────────────────────┘

🔄 جریان توسعه یک قابلیت (Feature Flow)

مثال: پیاده‌سازی "نمایش کمیسیون هفتگی"

Step 1: CMS (Already Done ✅)
├── Domain/Entities/UserCommissionPayout.cs
├── Application/CommissionCQ/Queries/GetUserCommissionPayouts/
│   ├── GetUserCommissionPayoutsQuery.cs
│   ├── GetUserCommissionPayoutsQueryHandler.cs
│   └── CommissionPayoutDto.cs
└── Protobuf/Protos/Commission.proto (gRPC definition)

Step 2: FrontOffice.BFF (TODO ❌)
├── Application/CommissionCQ/Queries/GetMyCommissionPayouts/
│   ├── GetMyCommissionPayoutsQuery.cs
│   ├── GetMyCommissionPayoutsQueryHandler.cs
│   │   └── Calls CMS via gRPC: CommissionService.GetUserCommissionPayouts
│   └── CommissionPayoutResponseDto.cs (Customer-friendly DTO)
└── WebApi/Controllers/CommissionController.cs
    └── GET /api/commission/my-payouts

Step 3: FrontOffice UI (TODO ❌)
└── Pages/Commission/PayoutsPage.razor
    ├── @inject CommissionService _commissionService
    ├── await _commissionService.GetMyPayoutsAsync()
    └── Display: MudTable with Columns (Week, Amount, Status, Date)

🎨 تفاوت‌های کلیدی CMS vs BFF

جنبه CMS (Microservice) FrontOffice.BFF FrontOffice UI
مخاطب Admin + System Customer فقط Customer
داده همه کاربران کاربر جاری (UserId از JWT) کاربر جاری
Response DTO کامل + Metadata DTO ساده + فقط فیلدهای لازم UI-friendly JSON
Authorization Role-based (Admin/User) User-only (No Admin access) Login required
مثال Query GetAllCommissionPayouts GetMyCommissionPayouts نمایش جدول
Input UserId required UserId از Token هیچ ورودی (خودکار)

📁 ساختار استاندارد BFF Module

FrontOffice.BFF/src/FrontOffice.BFF.Application/
└── [ModuleName]CQ/
    ├── Commands/
       └── [ActionName]/
           ├── [ActionName]Command.cs          // Input
           ├── [ActionName]CommandHandler.cs   // Logic
           ├── [ActionName]CommandValidator.cs // Validation
           └── [ActionName]ResponseDto.cs      // Output
    └── Queries/
        └── [QueryName]/
            ├── [QueryName]Query.cs
            ├── [QueryName]QueryHandler.cs
            └── [QueryName]ResponseDto.cs

🔐 احراز هویت و دسترسی

JWT Token Structure:

{
  "sub": "123",                    // UserId
  "email": "user@example.com",
  "phone": "09123456789",
  "IsSignMainContract": "True",    // قرارداد امضا شده؟
  "exp": 1234567890
}

استخراج UserId در Handler:

public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<...>
{
    private readonly ICurrentUserService _currentUser;
    
    public async Task<Response> Handle(Query request, CancellationToken ct)
    {
        var userId = _currentUser.UserId; // از JWT
        
        // Call CMS with userId
        var result = await _cmsClient.GetUserCommissionPayoutsAsync(userId);
        return result;
    }
}

📦 الگوی DTO Mapping

CMS DTO (داده خام):

public class CommissionPayoutDto
{
    public long Id { get; set; }
    public long UserId { get; set; }
    public int WeekNumber { get; set; }
    public long TotalAmount { get; set; }
    public CommissionPayoutStatus Status { get; set; }
    public DateTime CalculatedDate { get; set; }
    // ... 10 فیلد دیگر
}

BFF Response DTO (مشتری‌محور):

public class MyCommissionPayoutDto
{
    public long Id { get; set; }
    public string WeekLabel { get; set; }        // "هفته 45 - آذر 1403"
    public string AmountFormatted { get; set; }  // "1,250,000 تومان"
    public string StatusText { get; set; }       // "پرداخت شده"
    public string StatusBadgeColor { get; set; } // "success" / "warning"
    public string DatePersian { get; set; }      // "25 آذر 1403"
}

🎯 چک‌لیست شروع توسعه

قبل از شروع کار روی هر ماژول، این موارد را چک کنید:

[ ] CMS Commands/Queries مربوطه را شناسایی کردم
[ ] Proto definitions مربوطه را یافتم (Protobuf/*.proto)
[ ] نمونه Handler موجود در BFF را بررسی کردم
[ ] JWT Token و CurrentUserService را فهمیدم
[ ] ساختار DTO مشتری‌محور را طراحی کردم
[ ] Mock data برای UI آماده کردم (قبل از اتصال به API)

ترمینولوژی

  • «گت‌وی سمت مشتری» = FrontOffice.BFF
  • «فرانت» = پروژه FrontOffice (UI مشتری)
  • الویت کار: تغییر روی CMS فقط پس از تأیید؛ تمرکز اصلی روی FrontOffice.BFF و FrontOffice. هر نیازمندی جدید سمت مشتری قبل از دست‌کاری CMS باید تأیید شود.

امکانات موجود در CMS که باید در FrontOffice دیده شود

  • عضویت باشگاه و کیف‌پول‌های سه‌گانه: جریان پرداخت/فعال‌سازی (۵۶M) → افزایش همزمان Balance و DiscountBalance و واریز ۲۵M به استخر؛ نیاز به UI «عضویت در باشگاه»، نمایش موجودی هر سه کیف‌پول و تراکنش‌های مرتبط.
  • فروشگاه باشگاه با تخفیف: خرید از فروشگاه ویژه با DiscountBalance؛ تفکیک لیست محصولات باشگاه و عمومی + نمایش سقف/درصد تخفیف و موجودی تخفیف در کارت محصول/Checkout.
  • شبکه باینری و تعادل هفتگی: نمایش درخت دوبخشی، اعضای جدید هر پا، تعادل هفته، Carryover و سقف هفتگی (MaxWeeklyBalances=۳۰۰)؛ UI گزارش هفتگی و نمودار رشد برای شفاف‌سازی محاسبه کمیسیون.
  • کمیسیون هفتگی و پرداخت‌ها: نمایش مقدار استخر هفته، ارزش هر Balance، امتیازهای کاربر، مبلغ قابل برداشت، تاریخچه UserCommissionPayout با وضعیت (Pending/Calculated/Paid/Withdrawn) و امکان انتخاب روش برداشت (IBAN).
  • ویژگی‌های باشگاه (ClubFeature): لیست فیچرهای فعال/قابل دریافت، امتیاز موردنیاز و تاریخ فعال‌سازی (UserClubFeature); ارائه به‌صورت Badge/Progress Bar در پروفایل.
  • تجربه خرید استاندارد: کاتالوگ دسته/تگ، سبد (UserCarts)، Checkout، پرداخت ترکیبی (کیف‌پول + درگاه)، فاکتور (FactorDetails)، وضعیت ارسال/کد رهگیری؛ تاریخچه سفارش در پروفایل.
  • کیف‌پول و لاگ مالی: تاریخچه UserWalletChangeLog (واریز، خرید، بازپرداخت، برداشت) با فیلتر نوع/بازه زمانی؛ واریز از درگاه، برداشت با صف تأیید دستی؛ نمایش NetworkBalance جداگانه.
  • آدرس‌ها و قراردادها: مدیریت آدرس پیش‌فرض برای سفارش؛ اجباری‌کردن قبول آخرین نسخه قرارداد/Terms و نگه‌داری PDF امضا شده؛ هدایت اجباری به صفحه امضا در اولین ورود بعد از تغییر نسخه.
  • اعلان‌ها: ایمیل/SMS برای فعال‌سازی باشگاه، پرداخت کمیسیون، خطاها و وضعیت ارسال؛ در پروفایل دکمه Opt-in/Opt-out اعلان‌ها (موبایل/ایمیل) نیاز است.

قابلیت‌های جدید/در حال تکمیل که باید در Gateway و UI برنامه‌ریزی شود

  • سیستم تراکنش درگاه (۰٪): جریان Create/Verify/Refund تراکنش؛ در FrontOffice صفحات وضعیت تراکنش، Retry/Verify، نمایش ReferenceId و همگام‌سازی وضعیت سفارش/کیف‌پول.
  • سبد خرید پیشرفته (۰٪): پشتیبانی Add/Update/Delete/Clear/Merge (مهمان→ورود) روی UserCarts; UI ادغام سبد مهمان و کاربر، و بازگردانی سبد در شکست پرداخت.
  • تکمیل Products & Orders (۷۰٪): اعمال Tag/Category فیلترها، نمایش موجودی/تخفیف/گالری، کنترل تغییر قیمت روی اقلام فاکتور، قابلیت لغو سفارش و Refund به کیف‌پول.
  • Withdrawal/Settlement (۴۰٪): فرم درخواست برداشت از NetworkBalance/Balance با IBAN، پیگیری وضعیت صف تأیید، تاریخچه برداشت و سقف‌های روزانه.
  • VAT روی سفارشات (جدید): نمایش VatPercentage و خط مجزا در فاکتور/Checkout («شامل ۱۰٪ مالیات بر ارزش افزوده»)؛ نگه‌داری مقدار در سفارش و UI.

پیشنهاد اقدام برای FrontOffice/BFF (ترتیب توصیه‌شده)

۱) صفحه «عضویت باشگاه» + داشبورد کیف‌پول/کمیسیون/فیچرها (یکپارچه با نمودار تعادل هفتگی و تاریخچه پرداخت کمیسیون).
۲) راه‌اندازی پرداخت تراکنش و خطایابی: مسیر پرداخت، صفحه نتیجه، Retry/Verify، بازپرداخت به کیف‌پول.
۳) تکمیل سبد/Checkout: Merge سبد مهمان، پرداخت ترکیبی، نمایش VAT و تفکیک فروشگاه باشگاه.
۴) تاریخچه مالی و برداشت: لیست ChangeLog، درخواست/پیگیری برداشت، قوانین سقف/صف تأیید.
۵) اعلان‌ها و قراردادها: تنظیمات Opt-in اعلان، اجبار امضای نسخه جدید قرارداد پیش از دسترسی به بخش‌های مالی.

وضعیت فعلی FrontOffice.BFF (گت‌وی سمت مشتری)

  • مستندات موجود: فقط FrontOffice.BFF/README.md (خالی) و docs/CMS.sql/model.ndm2 (ساختار دیتابیس CMS). هیچ API یا هندلر مستند نشده است.
  • نتیجه: پوشش قابلیت‌ها در BFF نامشخص؛ فرض پیش‌فرض «پیاده‌سازی نشده/نیاز به بررسی» برای موارد زیر: عضویت باشگاه، کیف‌پول سه‌گانه و لاگ مالی، کمیسیون هفتگی و پرداخت/Withdraw، فروشگاه باشگاه و تخفیف، تراکنش درگاه (Create/Verify/Refund)، Merge سبد مهمان→ورود، VAT در Checkout، اعلان‌های Email/SMS/Push.
  • استثنا (موجود و پیاده‌سازی‌شده): جریان قرارداد در گت‌وی سمت مشتری و فرانت پیاده شده است؛ ثبت‌نام بدون امضای قرارداد متوقف می‌شود و پس از امضا Claim/Roll مربوط در توکن ست می‌شود.
  • اقدام فوری: فهرست APIهای فعلی BFF را استخراج و مقابل نیازهای بالا چک کنیم؛ تا زمان تأیید، تغییری در CMS داده نمی‌شود و تمرکز بر طراحی/افزودن هندلرهای BFF و UI فرانت است.

جدول پیشرفت قابلیت‌های مشتری (FrontOffice.BFF ↔ FrontOffice)

درصدها براساس شواهد فعلی؛ در صورت کشف پیاده‌سازی بیشتر، مقدار به‌روزرسانی شود.

قابلیت وضعیت فعلی درصد پیشرفت اقدام بعدی (BFF) اقدام بعدی (FrontOffice)
قرارداد و امضا پیاده‌سازی شده (امضا اجباری، Claim در توکن) ۱۰۰٪ بررسی صحت Claim در JWT و روتینگ پس از امضا نمایش وضعیت امضا، ریدایرکت به امضا در اولین ورود بعد از تغییر نسخه
عضویت باشگاه پیاده‌سازی نشده ۰٪ API شروع عضویت و فعال‌سازی باشگاه صفحه عضویت و پرداخت ورود به باشگاه
خلاصه کیف‌پول‌ها (Balance/Discount/Network) BFF/فرانت سه موجودی را نمایش می‌دهند؛ DiscountBalance هنوز از CMS برنمی‌گردد (در UI پیام «در انتظار اتصال CMS» نشان داده می‌شود، fallback صفر شد). ۷۵٪ Blocked: اضافه‌شدن DiscountBalance به سرویس CMS و مپ در BFF نمایش مقدار واقعی پس از اتصال
جزئیات تراکنش کیف‌پول BFF: GetAllUserWalletChangeLog پارامتر ReferenceId/IsIncrease دارد؛ فرانت فیلتر ارجاع/نوع تراکنش دارد. ۷۵٪ افزودن فیلتر تاریخ/Channel (در صورت نیاز) بهبود نمایش برچسب نوع و Channel
فروشگاه باشگاه (خرید با DiscountBalance) نامشخص/احتمالاً صفر ۰٪ API فهرست محصولات باشگاه + اعتبارسنجی موجودی تخفیف تفکیک کاتالوگ باشگاه/عمومی، نمایش موجودی تخفیف در کارت و Checkout
شبکه باینری، تعادل و کمیسیون هفتگی نامشخص/احتمالاً صفر ۰٪ API گزارش تعادل هفته، استخر، پرداخت کمیسیون و Withdraw داشبورد شبکه/کمیسیون، نمودار تعادل، درخواست برداشت
تراکنش درگاه (Create/Verify/Refund) PaymentRequest/PaymentVerification در BFF و Checkout فرانت پیاده شده؛ Refund دیده نشد. ۶۰٪ افزودن Refund و همگام‌سازی وضعیت سفارش/کیف‌پول نمایش وضعیت پرداخت و مسیر Retry/Verify در UI
VAT در سفارش نامشخص/احتمالاً صفر ۰٪ افزودن فیلد VAT به DTO/پاسخ سفارش نمایش خط VAT در Checkout و فاکتور
برداشت/Settlement از کیف‌پول شبکه BFF: WithdrawBalance به RequestWithdrawal و GetWithdrawalSettings (MinWithdrawalAmount از CMS) متصل؛ GetUserWithdrawals فعال. فرانت: فرم برداشت با حداقل مبلغ دینامیک، مپ وضعیت/روش، فیلتر وضعیت، نمایش پیام خطای CMS و لیست درخواست‌ها. ۹۵٪ همگام‌سازی ترجمه وضعیت/روش در همه صفحات
اعلان‌ها (Email/SMS/Push) نامشخص/احتمالاً صفر ۰٪ API Opt-in/Opt-out و تریگر اعلان‌های کلیدی تنظیمات اعلان در پروفایل، نمایش وضعیت ارسال
آدرس‌ها CRUD آدرس در BFF و فرانت موجود است. ۸۰٪ بررسی ولیدیشن/کشورها و پیش‌فرض بهبود UX انتخاب آدرس پیش‌فرض و پیام خطا
ثبت‌نام/OTP/دعوت OTP و Verify در BFF و فرانت موجود؛ ReferralCode در پروفایل نمایش داده می‌شود. ۸۰٪ سناریوهای خطا و RateLimit OTP بهبود متن راهنما و تجربه اشتراک‌گذاری کد دعوت
سفارش و تاریخچه Create/Submit/Update/Delete و فیلتر در BFF موجود؛ فرانت سفارش و Checkout دارد، Refund دیده نشد. ۷۰٪ افزودن Refund/Cancellation و فیلد VAT نمایش تاریخچه سفارش با وضعیت ارسال و کد رهگیری
درخت شبکه (نمایش اعضا) کامپوننت OrganizationChart در فرانت با داده‌ی User/GetAllUserByFilter؛ بدون تعادل/امتیاز. ۳۰٪ API داده شبکه/تعادل از CMS (درخت باینری) نمایش درخت با امتیاز، تعداد تعادل و Carryover

نکات مربوط به کیف‌پول و برداشت

  • CMS: ماژول کیف‌پول و Withdrawal پیاده‌سازی شده (Commands: RequestWithdrawal, ProcessWithdrawal, History، MinWithdrawalAmount، حالت Cash/Diamond). می‌توانیم مستقیماً از gRPC/HTTP آن در BFF استفاده کنیم.
  • FrontOffice: کارت کیف‌پول و صفحه جزئیات لاگ موجود است؛ نیاز به نمایش کیف تخفیف، بهبود UI، فیلترها و اضافه کردن جریان برداشت از موجودی شبکه. برداشت فعلاً تنها اکشن عملی روی موجودی شبکه است.
  • اقدام ریز:
    1. BFF: تکمیل GetUserWallet با DiscountBalance، افزودن فیلتر به GetAllUserWalletChangeLog، پیاده‌سازی WithdrawBalance با CMS RequestWithdrawal + ولیدیشن MinWithdrawalAmount/IBAN.
    2. Front: به‌روزرسانی کارت کیف‌پول با سه کیف و توضیح کاربرد، لینک به برداشت برای NetworkBalance، فیلتر/مرتب‌سازی لاگ، نمایش مبلغ تغییر و Reference/Type.
    3. تجربه کاربری برداشت: پیام خطاهای Withdrawal (کمتر از حداقل مبلغ، درخواست در صف) و نمایش وضعیت‌های Pending/Approved/Rejected در UI.

کشفیات جدید (ویژگی‌های مشتری در CMS که باید به BFF/فرانت برسد)

  • پروفایل/OTP/ثبت‌نام: جریان OTP و ثبت‌نام، ذخیره کد ملی/نام/موبایل (User, OtpToken) و Claim IsSignMainContract در JWT پس از امضا.
  • آدرس‌ها: UserAddress با پیش‌فرض برای سفارش‌ها؛ در فرانت پیاده است، نیاز به بهبود UX.
  • سبد/سفارش/پرداخت: UserCarts, UserOrder, Transactions و PaymentRequest/Verification در BFF/فرانت موجود؛ Refund و VAT پوشش داده نشده.
  • شبکه و کمیسیون: Network/Commission/WeeklyPool در CMS (باینری، Carryover، سقف ۳۰۰)؛ فرانت فقط درخت ساده بدون تعادل/امتیاز دارد.
  • باشگاه و کیف تخفیف: ClubMembership, ClubFeature, DiscountBalance تعریف شده؛ هنوز Endpoint/UI ندارد.
  • برداشت کمیسیون/کیف شبکه: RequestWithdrawal/ProcessWithdrawal در CMS؛ در BFF وصل شد ولی UI و استعلام وضعیت هنوز نداریم.
  • اعلان‌ها (Email/SMS): پیکربندی و ارسال در CMS آماده؛ Opt-in/Opt-out و نمایش وضعیت ارسال در فرانت پیاده نشده.
  • قرارداد: AcceptContract در BFF/فرانت فعال و توکن جدید پس از امضا صادر می‌شود.

📊 تحلیل جامع: شکاف‌های پیاده‌سازی در FrontOffice/FrontOffice.BFF

تاریخ تحلیل: 2024-12-01
روش تحلیل: بررسی عمیق ساختار دایرکتوری‌های CMS/Application در مقابل FrontOffice.BFF/Application
یافته کلیدی: از 27 ماژول CMS، تنها 10 ماژول در BFF پیاده‌سازی شده. 4 ماژول کلیدی مشتری‌محور کاملاً غایب هستند.

📌 خلاصه اجرایی

  • CMS Modules: 27 ماژول (13 ماژول مرتبط با مشتری)
  • FrontOffice.BFF Modules: 10 ماژول (فقط 70% از نیازهای مشتری)
  • Missing Modules: 4 ماژول حیاتی (ClubMembership, NetworkMembership, Commission, DayaLoan)
  • Partial Modules: 3 ماژول با پیاده‌سازی ناقص (UserWallet, UserWalletChangeLog, Contract)

🔴 ماژول‌های کاملاً غایب (Critical Gap)

1️⃣ ClubMembershipCQ - باشگاه مشتریان

📍 مسیر: CMS/src/CMSMicroservice.Application/ClubMembershipCQ/
وضعیت: هیچ معادلی در BFF وجود ندارد

Commands در CMS:

  • ActivateClubMembershipCommand - فعال‌سازی عضویت (پرداخت 56M + شارژ کیف‌پول‌ها)
  • DeactivateClubMembershipCommand - غیرفعال کردن عضویت
  • UpdateClubMembershipCommand - به‌روزرسانی جزئیات

Queries در CMS:

  • GetClubMembershipStatusQuery - وضعیت و فیچرهای فعال
  • GetAllClubMembershipsQuery - لیست عضویت‌ها (Admin)
  • GetClubMembershipHistoryQuery - تاریخچه تغییرات

💥 تأثیر بر کاربر:

  • عدم امکان عضویت در باشگاه
  • عدم دسترسی به فروشگاه تخفیفی
  • عدم نمایش فیچرها و امتیازات باشگاه

📋 اقدام مورد نیاز:

BFF: ایجاد ClubMembershipCQ + 6 Handler + gRPC Client
UI:  ClubMembershipPage.razor + نمایش وضعیت در داشبورد

2️⃣ NetworkMembershipCQ - شبکه باینری

📍 مسیر: CMS/src/CMSMicroservice.Application/NetworkMembershipCQ/
وضعیت: هیچ معادلی در BFF وجود ندارد (UI درخت دارد اما بدون داده واقعی)

Commands در CMS:

  • JoinNetworkCommand - ثبت در شبکه باینری (SponsorId, ParentId, Position)
  • MoveInNetworkCommand - جابجایی در درخت (Admin)
  • RemoveFromNetworkCommand - حذف از شبکه (Admin)

Queries در CMS:

  • GetNetworkTreeQuery - درخت باینری با MaxDepth (1-10)
  • GetUserNetworkPositionQuery - موقعیت + آمار (Parent, Children, Total)
  • GetNetworkMembershipHistoryQuery - تاریخچه تغییرات

💥 تأثیر بر کاربر:

  • ⚠️ UI درخت موجود اما با Mock data
  • عدم نمایش امتیازات و تعادل پاها
  • عدم امکان دعوت افراد به شبکه

📋 اقدام مورد نیاز:

BFF: ایجاد NetworkMembershipCQ + 6 Handler + gRPC Client
UI:  به‌روزرسانی OrganizationChart.razor با داده واقعی + نمایش امتیاز

3️⃣ CommissionCQ - کمیسیون هفتگی و برداشت

📍 مسیر: CMS/src/CMSMicroservice.Application/CommissionCQ/
وضعیت: BFF دارای WithdrawBalance اما Handler خالی است

Commands در CMS:

  • RequestWithdrawalCommand - درخواست برداشت (Cash/Diamond)
  • ProcessWithdrawalCommand - تایید/رد توسط ادمین

Queries در CMS:

  • GetWeeklyCommissionPoolQuery - اطلاعات استخر (TotalPool, ValuePerPoint)
  • GetUserCommissionPayoutsQuery - تاریخچه پرداخت‌ها (Pending→Paid→Withdrawn)
  • GetUserWeeklyBalancesQuery - تعادل هفتگی (Left/Right Volume, Carryover, سقف 300)
  • GetAllWeeklyPoolsQuery - تاریخچه استخرها (Admin)
  • GetWithdrawalRequestsQuery - لیست درخواست‌های برداشت (Admin)

💥 تأثیر بر کاربر:

  • عدم نمایش کمیسیون هفتگی
  • عدم امکان درخواست برداشت (Handler خالی)
  • عدم پیگیری وضعیت برداشت‌ها

📋 اقدام مورد نیاز:

BFF: ایجاد CommissionCQ + تکمیل WithdrawBalance + 5 Query + gRPC Client
UI:  CommissionDashboardPage.razor + WithdrawalRequestPage.razor + WeeklyBalanceChart.razor

4️⃣ DayaLoanCQ - وام دایا (Phase 11 - جدید)

📍 مسیر: CMS/src/CMSMicroservice.Application/DayaLoanCQ/
وضعیت: هیچ معادلی در BFF وجود ندارد (تازه در CMS پیاده شده)

Commands در CMS:

  • ProcessDayaLoanApprovalCommand - شارژ 3 کیف‌پول (168M تومان)
  • CheckDayaLoanStatusCommand - استعلام از API دایا

💥 تأثیر بر کاربر:

  • عدم نمایش وضعیت وام
  • عدم امکان پیگیری اعتبار دریافتی

📋 اقدام مورد نیاز:

BFF: ایجاد DayaLoanCQ + 2 Handler + Mock API Client
UI:  DayaLoanStatusPage.razor + نمایش ContractNumber و تاریخ دریافت

⚠️ ماژول‌های پیاده‌سازی ناقص

5️⃣ UserWalletChangeLogCQ - تاریخچه مالی

وضعیت: BFF دارد GetAllUserWalletChangeLog اما بدون فیلتر

گپ:

  • فیلتر نوع تراکنش (Deposit, Withdraw, Purchase, Refund)
  • فیلتر بازه زمانی
  • جستجوی ReferenceId
  • Query برای جزئیات تراکنش خاص (GetUserWalletChangeLogQuery)

📋 اقدام:

BFF: افزودن پارامترهای فیلتر به Handler موجود
UI:  افزودن فیلتر/جستجو به WalletDetailsPage.razor

6️⃣ UserWalletCQ - کیف‌پول‌ها

وضعیت: BFF دارد GetUserWallet اما بدون DiscountBalance در DTO

گپ:

  • ⚠️ Response فقط Balance + NetworkBalance برمی‌گرداند
  • DiscountBalance نمایش داده نمی‌شود

📋 اقدام:

BFF: افزودن DiscountBalance به GetUserWallet Response DTO
UI:  نمایش کیف تخفیف در WalletCard.razor

📊 آمار نهایی شکاف

ماژول CMS Commands Queries BFF Status UI Status Gap %
ClubMembershipCQ 3 3 None None 100%
NetworkMembershipCQ 3 3 None ⚠️ Mock 90%
CommissionCQ 2 5 ⚠️ Empty Handler None 100%
DayaLoanCQ 2 0 None None 100%
UserWalletChangeLogCQ 0 2 ⚠️ No Filter ⚠️ No Filter 40%
UserWalletCQ 1 2 ⚠️ Missing Field ⚠️ Missing 30%
TOTAL 11 15 10/27 Modules - 63% Missing

نتیجه‌گیری: از 26 قابلیت (Commands/Queries) مورد نیاز مشتری، 16 قابلیت (62%) کاملاً غایب و 4 قابلیت (15%) ناقص هستند.


🎯 اولویت‌بندی توسعه (برای Developer بعدی)

🔴 فاز 1 (Critical - 2 هفته):

  1. CommissionCQ - کمیسیون و برداشت

    • BFF: 2 Commands + 5 Queries + gRPC Client
    • UI: CommissionDashboard + WithdrawalRequest + WeeklyBalanceChart
    • ⏱️ تخمین: 5 روز کاری
  2. ClubMembershipCQ - عضویت باشگاه

    • BFF: 3 Commands + 3 Queries + gRPC Client
    • UI: ClubMembershipPage + Profile widgets
    • ⏱️ تخمین: 4 روز کاری
  3. NetworkMembershipCQ - شبکه باینری

    • BFF: 3 Commands + 3 Queries + gRPC Client
    • UI: OrganizationChart update + Position page
    • ⏱️ تخمین: 5 روز کاری

🟡 فاز 2 (Important - 1 هفته):

  1. UserWalletCQ Enhancement - کیف تخفیف

    • BFF: Add DiscountBalance to DTO
    • UI: Display in WalletCard
    • ⏱️ تخمین: 1 روز کاری
  2. UserWalletChangeLogCQ Enhancement - فیلتر تراکنش‌ها

    • BFF: Add filter params (Type, DateRange, ReferenceId)
    • UI: Filter controls in WalletDetailsPage
    • ⏱️ تخمین: 2 روز کاری
  3. DayaLoanCQ - وام دایا

    • BFF: 2 Commands + Mock API Client
    • UI: DayaLoanStatusPage
    • ⏱️ تخمین: 2 روز کاری

🟢 فاز 3 (Nice to Have - 3 روز):

  1. OtpTokenCQ Enhancement - RateLimit

    • BFF: Add middleware (5 req/10min per IP)
    • ⏱️ تخمین: 1 روز کاری
  2. TransactionsCQ Enhancement - Refund & VAT

    • BFF: RefundTransaction Command + VAT fields
    • UI: Refund button + VAT display
    • ⏱️ تخمین: 2 روز کاری

📋 چک‌لیست کامل (Copy-Paste Ready)

FrontOffice.BFF:

// ماژول‌های جدید (از صفر)
[ ] Create /Application/ClubMembershipCQ/
    [ ] Commands/ActivateClubMembership.cs + Handler
    [ ] Queries/GetClubMembershipStatus.cs + Handler
    [ ] DTOs/ClubMembershipDto.cs

[ ] Create /Application/NetworkMembershipCQ/
    [ ] Commands/JoinNetwork.cs + Handler
    [ ] Queries/GetNetworkTree.cs + Handler (MaxDepth: 1-10)
    [ ] Queries/GetUserNetworkPosition.cs + Handler
    [ ] DTOs/NetworkTreeDto.cs, NetworkPositionDto.cs

[ ] Create /Application/CommissionCQ/
    [ ] Commands/RequestWithdrawal.cs (تکمیل Handler خالی موجود)
    [ ] Queries/GetUserCommissionPayouts.cs + Handler
    [ ] Queries/GetUserWeeklyBalances.cs + Handler
    [ ] Queries/GetWeeklyCommissionPool.cs + Handler
    [ ] DTOs/CommissionPayoutDto.cs, WeeklyBalanceDto.cs

[ ] Create /Application/DayaLoanCQ/
    [ ] Commands/CheckDayaLoanStatus.cs + Handler
    [ ] Services/MockDayaApiClient.cs
    [ ] DTOs/DayaLoanInfoDto.cs

// به‌روزرسانی ماژول‌های موجود
[ ] Update /Application/UserWalletCQ/
    [ ] DTOs/UserWalletDto.cs  Add: public decimal DiscountBalance { get; set; }
    [ ] Handlers/GetUserWalletQueryHandler.cs  Map DiscountBalance from CMS

[ ] Update /Application/UserWalletCQ/ (ChangeLog)
    [ ] Queries/GetAllUserWalletChangeLog.cs  Add params:
        - WalletChangeType? Type
        - DateTime? DateFrom, DateTime? DateTo
        - string? ReferenceId
    [ ] Handler  Apply filters in CMS gRPC call

// gRPC Registration
[ ] Update /Infrastructure/ConfigureGrpcServices.cs
    builder.Services.AddGrpcClient<ClubMembershipServiceClient>(...)
    builder.Services.AddGrpcClient<NetworkMembershipServiceClient>(...)
    builder.Services.AddGrpcClient<CommissionServiceClient>(...)

// Security
[ ] Create /Infrastructure/Middleware/RateLimitMiddleware.cs
    - Apply to: /api/user/otp endpoints
    - Limit: 5 requests per 10 minutes per IP

FrontOffice (UI):

// صفحات جدید
[ ] Create /Pages/ClubMembership/Index.razor
    - نمایش وضعیت عضویت (Active/Inactive)
    - دکمه فعال‌سازی (هدایت به درگاه پرداخت 56M)
    - لیست فیچرهای فعال (Badge system)

[ ] Create /Pages/Commission/Dashboard.razor
    - نمایش استخر هفته (TotalPool, ValuePerPoint)
    - نمودار تعادل (Left vs Right Volume)
    - تاریخچه پرداخت‌ها با Badge وضعیت

[ ] Create /Pages/Commission/Withdrawal.razor
    - فرم برداشت (IBAN, Amount, Method: Cash/Diamond)
    - ولیدیشن MinWithdrawalAmount (100,000 تومان)
    - نمایش پیام خطا (کمتر از حداقل، صف تایید)

[ ] Create /Pages/Network/Tree.razor
    - به‌روزرسانی OrganizationChart.razor
    - Slider MaxDepth (1-10)
    - نمایش امتیاز در هر Node
    - رنگ‌بندی بر اساس تعادل (سبز=متعادل، قرمز=نامتعادل)
    - Tooltip: Parent, Children count, Carryover

[ ] Create /Pages/DayaLoan/Status.razor
    - نمایش ContractNumber
    - تاریخ دریافت اعتبار
    - مبالغ شارژ شده (3×56M)

// کامپوننت‌های جدید
[ ] Update /Components/Wallet/WalletCard.razor
    <MudCard>
      <MudText>موجودی کیف پول: {Balance:N0} ریال</MudText>
      <MudText>موجودی شبکه: {NetworkBalance:N0} ریال</MudText>
      <MudText Color="Color.Success">موجودی تخفیف: {DiscountBalance:N0} ریال</MudText>
      <MudButton Href="/wallet/withdraw" Disabled="@(NetworkBalance < 100000)">
        درخواست برداشت
      </MudButton>
    </MudCard>

[ ] Create /Components/Commission/WeeklyBalanceChart.razor
    - نمودار میله‌ای Left/Right Volume
    - نمایش WeakerLeg (کمترین حجم)
    - نمایش Carryover و سقف 300

// به‌روزرسانی موجودی
[ ] Update /Pages/Wallet/DetailsPage.razor
    [ ] Add filter controls:
        - نوع تراکنش (Deposit, Withdraw, Purchase, Refund)
        - بازه زمانی (DatePicker: From/To)
        - جستجوی ReferenceId (TextBox)
    [ ] نمایش ChangeValue به جای CurrentBalance
    [ ] پیجینیشن

[ ] Update /Components/Layout/NavMenu.razor
    <MudNavLink Href="/clubmembership" Icon="@Icons.Material.Filled.CardMembership">
      باشگاه مشتریان
    </MudNavLink>
    <MudNavLink Href="/commission/dashboard" Icon="@Icons.Material.Filled.Payments">
      کمیسیون و برداشت
    </MudNavLink>
    <MudNavLink Href="/network/tree" Icon="@Icons.Material.Filled.AccountTree">
      شبکه من
    </MudNavLink>

🚨 نکات حیاتی (Critical Notes)

⚠️ امنیت:

1. WithdrawBalance:
   - MinAmount: 100,000 ریال (CMS config)
   - IBAN: IR + 24 digits validation
   - Daily limit per user: Check CMS setting

2. JoinNetwork:
   - IsDescendant recursive check (prevent circular ref)
   - Position validation (Left/Right must be empty)
   - SponsorId must be active club member

3. OTP RateLimit:
   - 5 requests / 10 min per IP
   - Redis/InMemory cache

💡 UI/UX:

1. WalletCard: 3 کیف‌پول با رنگ متفاوت
   - Balance: آبی (خرید عمومی)
   - NetworkBalance: سبز (برداشت Cash/Diamond)
   - DiscountBalance: زرد (فروشگاه باشگاه)

2. CommissionDashboard:
   - Badge colors: Pending=زرد, Calculated=آبی, Paid=سبز, Withdrawn=خاکستری
   - Carryover info tooltip
   - سقف 300 Balance در هفته

3. NetworkTree:
   - MaxDepth default: 3
   - Load on demand برای عمق بیشتر
   - Tooltip با Shift+Click

خطاهای رایج:

1. BFF: Handler خالی
   ❌ FrontOffice.BFF/Application/UserWalletCQ/Commands/WithdrawBalanceCommandHandler.cs
   ✅ Fix: Call CMS.RequestWithdrawal via gRPC

2. UI: Mock data
   ❌ FrontOffice/Pages/Network/OrganizationChart.razor (hardcoded nodes)
   ✅ Fix: @inject NetworkService → await GetTreeAsync()

3. DTO: Missing field
   ❌ UserWalletDto missing DiscountBalance
   ✅ Fix: Add property + map in Handler

📊 تحلیل کامل شکاف‌های پیاده‌سازی (Gap Analysis Report)

تاریخ تحلیل: 2024-12-01
روش: مقایسه ماژول به ماژول CMS vs FrontOffice.BFF vs FrontOffice

📈 آمار کلی

مجموع CMS Modules BFF Modules شکاف (Missing) نرخ پوشش
کل ماژول‌ها 26 ماژول 9 ماژول 17 ماژول 35%
ماژول‌های مشتری‌محور 15 ماژول 7 ماژول 8 ماژول 47%
ماژول‌های حیاتی غایب - - 4 ماژول 0%

🔴 CRITICAL: ماژول‌های کاملاً غایب (0% پیاده‌سازی)

1️⃣ ClubMembershipCQ - باشگاه مشتریان

Commands در CMS (موجود):

  • ActivateClubMembership - فعال‌سازی عضویت (پرداخت 56M)
  • DeactivateClubMembership - غیرفعال کردن
  • AssignClubFeature - اختصاص فیچر (Trial/VIP)

Queries در CMS (موجود):

  • GetClubMembership - دریافت وضعیت عضویت کاربر
  • GetAllClubMemberships - لیست کل اعضا (Admin)
  • GetClubMembershipHistory - تاریخچه تغییرات
  • GetClubStatistics - آمار کلی باشگاه

در FrontOffice.BFF: هیچ چیز موجود نیست
در FrontOffice UI: هیچ صفحه‌ای برای باشگاه وجود ندارد

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Create ClubMembershipCQ/Commands/ActivateClubMembership/
    [ ] Create ClubMembershipCQ/Queries/GetMyClubMembership/
    [ ] Create ClubMembershipCQ/Queries/GetClubFeatures/
    
[ ] FrontOffice UI:
    [ ] Create /Pages/Club/MembershipPage.razor
        - نمایش وضعیت عضویت (Active/Inactive/Trial)
        - دکمه فعال‌سازی (پرداخت 56M)
        - لیست فیچرهای باشگاه
    [ ] Create /Pages/Club/FeaturesPage.razor
        - لیست فیچرهای Trial vs VIP
        - Badge امتیاز برای هر فیچر
    [ ] Create /Components/Club/ActivationButton.razor
        - فرم پرداخت
        - اتصال به درگاه

💰 بیزینس اثر:

  • کاربر نمی‌تواند عضو باشگاه شود
  • 56M تومان در Balance/Discount شارژ نمی‌شود
  • دسترسی به فروشگاه تخفیفی ندارد

2️⃣ NetworkMembershipCQ - شبکه باینری

Commands در CMS (موجود):

  • JoinNetwork - عضویت در شبکه (Parent/Position)
  • MoveInNetwork - جابجایی موقعیت
  • RemoveFromNetwork - حذف از شبکه

Queries در CMS (موجود):

  • GetNetworkTree - دریافت درخت باینری (MaxDepth: 1-10)
  • GetUserNetworkPosition - موقعیت کاربر در درخت
  • GetNetworkMembershipHistory - تاریخچه جابجایی‌ها
  • GetNetworkStatistics - آمار شبکه (تعداد چپ/راست/کل)

در FrontOffice.BFF: هیچ چیز موجود نیست
⚠️ در FrontOffice UI: فقط OrganizationChart با داده Mock

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Create NetworkMembershipCQ/Commands/JoinNetwork/
    [ ] Create NetworkMembershipCQ/Queries/GetNetworkTree/
    [ ] Create NetworkMembershipCQ/Queries/GetMyNetworkPosition/
    [ ] Create NetworkMembershipCQ/Queries/GetNetworkStatistics/
    
[ ] FrontOffice UI:
    [ ] Update /Pages/Network/OrganizationChart.razor
        - حذف Mock data
        - فراخوانی GetNetworkTree از BFF
        - نمایش MaxDepth selector (1-10)
        - Lazy loading برای سطوح پایین‌تر
    [ ] Create /Pages/Network/JoinPage.razor
        - فرم انتخاب Parent
        - انتخاب Position (Left/Right)
        - نمایش پیش‌نمایش موقعیت
    [ ] Create /Pages/Network/StatsPage.razor
        - تعداد اعضای چپ/راست
        - عمق درخت
        - آخرین عضو جدید

💰 بیزینس اثر:

  • کاربر نمی‌تواند زیرمجموعه بگیرد
  • درخت شبکه واقعی نمایش داده نمی‌شود
  • محاسبه کمیسیون باینری کار نمی‌کند

3️⃣ CommissionCQ - کمیسیون هفتگی و برداشت

Commands در CMS (موجود):

  • RequestWithdrawal - درخواست برداشت (Cash/Diamond/IBAN)
  • ApproveWithdrawal - تایید برداشت (Admin)
  • RejectWithdrawal - رد برداشت (Admin)
  • ProcessWithdrawal - پردازش برداشت
  • CalculateWeeklyBalances - محاسبه تعادل هفتگی
  • CalculateWeeklyCommissionPool - محاسبه استخر
  • ProcessUserPayouts - توزیع کمیسیون
  • TriggerWeeklyCalculation - اجرای دستی Worker

Queries در CMS (موجود):

  • GetUserCommissionPayouts - لیست پرداخت‌های کمیسیون کاربر
  • GetCommissionPayoutHistory - تاریخچه تغییرات
  • GetUserWeeklyBalances - تعادل هفتگی (Left/Right/Weaker)
  • GetWeeklyCommissionPool - اطلاعات استخر هفته
  • GetAllWeeklyPools - تمام استخرها (Admin)
  • GetWithdrawalRequests - درخواست‌های برداشت
  • GetWorkerStatus - وضعیت Worker
  • GetWorkerExecutionLogs - لاگ اجرای Worker

⚠️ در FrontOffice.BFF: فقط یک Handler خالی WithdrawBalance
در FrontOffice UI: هیچ چیز موجود نیست

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Complete UserWalletCQ/Commands/WithdrawBalance/
        - فراخوانی CMS.RequestWithdrawal
        - ولیدیشن MinWithdrawalAmount
        - چک IBAN format
    [ ] Create CommissionCQ/Queries/GetMyCommissionPayouts/
    [ ] Create CommissionCQ/Queries/GetMyWeeklyBalances/
    [ ] Create CommissionCQ/Queries/GetWithdrawalHistory/
    
[ ] FrontOffice UI:
    [ ] Create /Pages/Commission/DashboardPage.razor
        - کارت استخر هفته (TotalPool, BalanceValue)
        - کارت امتیازات من (LesserLegPoints)
        - پیش‌بینی کمیسیون این هفته
    [ ] Create /Pages/Commission/HistoryPage.razor
        - جدول پرداخت‌های گذشته
        - فیلتر Status (Pending/Paid/Withdrawn)
        - نمودار روند کمیسیون
    [ ] Create /Pages/Commission/WithdrawPage.razor
        - فرم برداشت (Amount, Method, IBAN)
        - نمایش MinWithdrawalAmount
        - نمایش موجودی قابل برداشت
        - تاریخچه برداشت‌ها
    [ ] Create /Pages/Commission/WeeklyBalancePage.razor
        - تعادل چپ/راست
        - Carryover از هفته قبل
        - سقف 300 Balance
        - نمودار خطی رشد هفتگی

💰 بیزینس اثر:

  • کاربر نمی‌تواند کمیسیون خود را ببیند
  • برداشت از NetworkBalance کار نمی‌کند
  • تعادل هفتگی و Carryover نامشخص است

4️⃣ DayaLoanCQ - وام دایا

Commands در CMS (موجود - جدید):

  • CheckDayaLoanStatus - استعلام وضعیت وام
  • ProcessDayaLoanApproval - پردازش تایید وام (شارژ 168M)

در FrontOffice.BFF: هیچ چیز موجود نیست
در FrontOffice UI: هیچ چیز موجود نیست

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Create DayaLoanCQ/Queries/GetMyDayaLoanStatus/
    [ ] Create DayaLoanCQ/Commands/RequestDayaLoanCheck/ (optional)
    
[ ] FrontOffice UI:
    [ ] Create /Pages/DayaLoan/StatusPage.razor
        - نمایش وضعیت وام (PendingReceive/Received/Rejected)
        - شماره قرارداد (ContractNumber)
        - تاریخ آخرین بررسی
    [ ] Create /Components/DayaLoan/StatusBadge.razor
        - Badge رنگی برای Status

💰 بیزینس اثر:

  • کاربر نمی‌تواند وضعیت وام دایا خود را ببیند
  • 168M شارژ کیف‌پول (56M×3) نامشخص است
  • فقط Worker پس‌زمینه فعال است (بدون UI)

🟡 PARTIAL: ماژول‌های نیمه‌پیاده (50-80% تکمیل)

5️⃣ UserWalletCQ - کیف‌پول

در BFF موجود:

  • GetUserWallet - دریافت موجودی
  • GetAllUserWalletChangeLog - تاریخچه تراکنش‌ها

در BFF غایب:

  • WithdrawBalance Handler - خالی است و کار نمی‌کند

⚠️ مشکلات موجود:

  • GetUserWallet Response فقط Balance و NetworkBalance دارد
  • DiscountBalance موجود نیست (باید اضافه شود)
  • GetAllUserWalletChangeLog فیلتر ندارد (نوع/بازه زمانی)

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Update GetUserWallet Response DTO
        ✅ Balance (موجود)
        ✅ NetworkBalance (موجود)
        ❌ DiscountBalance (باید اضافه شود)
    [ ] Update GetAllUserWalletChangeLog
        - فیلتر نوع تراکنش (Deposit/Withdraw/Purchase)
        - فیلتر بازه زمانی (From/To)
        - فیلتر ReferenceId
    [ ] Fix WithdrawBalance Handler
        - Call CMS.RequestWithdrawal
        - Validation: MinAmount, IBAN
        
[ ] FrontOffice UI:
    [ ] Update /Pages/Wallet/WalletCard.razor
        ✅ Balance (موجود)
        ✅ NetworkBalance (موجود)
        ❌ DiscountBalance (باید اضافه شود - با رنگ زرد)
        - حذف داده Mock
    [ ] Update /Pages/Wallet/DetailsPage.razor
        - فیلترها (نوع/تاریخ/جستجو)
        - نمایش ChangeValue به جای CurrentBalance
        - Pagination

6️⃣ UserCartsCQ / ShoppingCartCQ - سبد خرید

در BFF موجود (نام: ShopingCartCQ):

  • AddNewUserCart - افزودن به سبد
  • UpdateUserCart - به‌روزرسانی تعداد

در BFF غایب:

  • ClearCart - پاک کردن کل سبد
  • DeleteUserCarts - حذف یک آیتم
  • MergeGuestCart - ادغام سبد مهمان→ورود

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Create ShopingCartCQ/Commands/ClearCart/
    [ ] Create ShopingCartCQ/Commands/DeleteCartItem/
    [ ] Create ShopingCartCQ/Commands/MergeGuestCart/
        - Input: SessionId مهمان + UserId ورود
        - Logic: Merge duplicate products (sum quantities)
        
[ ] FrontOffice UI:
    [ ] Update /Pages/Cart/CartPage.razor
        - دکمه "پاک کردن سبد"
        - دکمه حذف آیتم (هر سطر)
    [ ] Implement Guest→Login merge
        - ذخیره SessionId در LocalStorage
        - POST به MergeGuestCart بعد از Login
        - نمایش پیام "x محصول از سبد قبلی شما اضافه شد"

7️⃣ ContractCQ - قرارداد

در BFF موجود:

  • AcceptContract در UserCQ/Commands/

در BFF غایب:

  • GetContract - دریافت متن قرارداد
  • GetAllContracts - لیست نسخه‌های قرارداد

📋 Task های مورد نیاز:

[ ] FrontOffice.BFF:
    [ ] Create ContractCQ/Queries/GetLatestContract/
    [ ] Create ContractCQ/Queries/GetMyContractHistory/
    
[ ] FrontOffice UI:
    [ ] Update /Pages/Auth/ContractPage.razor
        - دریافت متن قرارداد از API (حذف hardcode)
        - نمایش تاریخ آخرین نسخه
    [ ] Create /Pages/Profile/ContractHistoryPage.razor
        - لیست قراردادهای امضا شده
        - دانلود PDF

COMPLETE: ماژول‌های کامل (80-100% تکمیل)

8️⃣ UserCQ - پروفایل و احراز هویت

پیاده‌سازی کامل:

  • Login, Register, UpdateProfile
  • ChangePassword, ForgotPassword
  • GetUserByFilter
  • AcceptContract (امضای قرارداد)

9️⃣ UserAddressCQ - آدرس‌ها

پیاده‌سازی کامل:

  • CRUD آدرس
  • SetDefault
  • UI: AddressPage و AddressCard

🔟 UserOrderCQ - سفارشات

پیاده‌سازی 70%:

  • Create, Update, Delete, GetById, GetByFilter
  • غایب: Cancel, Refund, TrackingCode

📊 جدول خلاصه اولویت‌بندی

اولویت ماژول درصد فعلی Tasks باقی‌مانده تخمین زمان
🔴 P0 ClubMembershipCQ 0% 8 Handlers + 4 Pages 2 هفته
🔴 P0 CommissionCQ 10% 12 Handlers + 6 Pages 3 هفته
🔴 P0 NetworkMembershipCQ 5% 7 Handlers + 4 Pages 2 هفته
🟡 P1 UserWalletCQ 60% 3 Handlers + 2 Pages 1 هفته
🟡 P1 ShoppingCartCQ 50% 3 Handlers + UI updates 1 هفته
🟢 P2 DayaLoanCQ 0% 2 Handlers + 1 Page 3 روز
🟢 P2 ContractCQ 80% 2 Handlers + 1 Page 2 روز

مجموع تخمین: 9 هفته = 2 ماه (1 نفر Full-time)


🎯 خلاصه اجرایی برای توسعه‌دهنده

وضعیت فعلی:

  • از 15 ماژول مشتری‌محور CMS، تنها 7 ماژول در BFF دارید
  • 4 ماژول حیاتی (باشگاه، شبکه، کمیسیون، وام) کاملاً غایب
  • 3 ماژول موجود (کیف‌پول، سبد، قرارداد) ناقص

کارهایی که توسعه‌دهنده قبلی انجام نداد:

  1. هیچ Handler برای باشگاه (ClubMembership)
  2. هیچ Handler برای شبکه (NetworkMembership)
  3. هیچ Handler برای کمیسیون (Commission) به جز یک Handler خالی
  4. هیچ Handler برای وام دایا (DayaLoan)
  5. ⚠️ Handler کیف‌پول (UserWallet) ناقص - DiscountBalance غایب
  6. ⚠️ Handler سبد (ShoppingCart) ناقص - ClearCart, Merge غایب
  7. ⚠️ UI درخت شبکه (OrganizationChart) با داده Mock

تسک‌های واقعی که باید از CMS به FrontOffice.BFF منتقل شوند:

  • 26 Command موجود در CMS که در BFF نیستند
  • 24 Query موجود در CMS که در BFF نیستند
  • 15+ صفحه UI که باید در FrontOffice ساخته شوند

اولویت‌بندی توصیه شده:

  1. Week 1-2: ClubMembership - چون بدون این، کاربر نمی‌تواند عضو شود
  2. Week 3-5: Commission + Withdrawal - چون کاربر نمی‌تواند پول خود را ببیند/برداشت کند
  3. Week 6-7: NetworkMembership - چون درخت شبکه Mock است
  4. Week 8: UserWallet completion - اضافه کردن DiscountBalance و فیلترها
  5. Week 9: DayaLoan + ShoppingCart completion

این تحلیل نشان می‌دهد که حداقل 50 روز کاری (2 ماه) برای تکمیل نیاز است.


🌳 مرحله 3: راهنمای گام‌به‌گام - NetworkMembership (شبکه باینری)

📊 خلاصه ماژول

هدف کسب‌وکار: مشتری باید بتواند درخت شبکه باینری خود را ببیند (پدر، فرزند چپ، فرزند راست)، موقعیت خود را بررسی کند، و تاریخچه جابجایی‌ها را مشاهده نماید.

اجزای موجود در CMS:

  • NetworkMembership Entity با BinaryTree structure (ParentId, LeftChildId, RightChildId)
  • 3 Commands: JoinNetwork, MoveInNetwork, RemoveFromNetwork
  • 4 Queries: GetNetworkTree, GetUserPosition, GetNetworkHistory, GetNetworkStatistics

چیزهای غایب:

  • هیچ Handler در FrontOffice.BFF
  • هیچ صفحه نمایش درخت در FrontOffice UI
  • Component نمایش درخت باینری (Tree Visualization)

📝 STEP 1: بررسی CMS NetworkMembership

Task 1.1: بررسی Entity و Logic

cd /home/masoud/Apps/project/FourSat/CMS/src/

# 1. بررسی Entity
cat CMSMicroservice.Domain/Entities/NetworkMembership.cs
# چیزهایی که باید بفهمی:
#   - UserId: کاربر اصلی
#   - ParentId: کاربر بالایی در شبکه
#   - LeftChildId: فرزند چپ (nullable)
#   - RightChildId: فرزند راست (nullable)
#   - Position: Left/Right (موقعیت در شبکه پدر)
#   - JoinDate: تاریخ پیوستن

# 2. بررسی Query GetNetworkTree
cat CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkTree/GetNetworkTreeQueryHandler.cs
# توجه کن به:
#   - Input: UserId (برای نمایش درخت از این کاربر به بعد)
#   - Depth: عمق درخت (چند لایه)
#   - Output: Recursive DTO (Parent + Left + Right با فیلدهای کامل)

# 3. بررسی DTO
cat CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkTree/NetworkTreeNodeDto.cs
# Structure:
#   - UserId, UserFullName, UserMobile
#   - Position (Left/Right)
#   - JoinDate
#   - LeftChild (recursive NetworkTreeNodeDto?)
#   - RightChild (recursive NetworkTreeNodeDto?)

Output Task 1.1:

[ ] Entity NetworkMembership را خواندم
[ ] ساختار Recursive Tree را فهمیدم
[ ] GetNetworkTreeQueryHandler را بررسی کردم

Task 1.2: بررسی GetUserPosition Query

cat CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetUserPosition/GetUserPositionQueryHandler.cs
# این Query چه می‌دهد:
#   - Parent info: نام و موبایل پدر
#   - User Position: Left یا Right
#   - Left Child info (if exists)
#   - Right Child info (if exists)
#   - Total Depth: عمق کل درخت از این کاربر

📝 STEP 2: ایجاد BFF Module - NetworkMembershipCQ

Task 2.1: ساخت فولدرها

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/FrontOffice.BFF.Application/

mkdir -p NetworkMembershipCQ/Queries/GetMyNetworkTree
mkdir -p NetworkMembershipCQ/Queries/GetMyNetworkPosition
mkdir -p NetworkMembershipCQ/Queries/GetMyNetworkHistory

tree NetworkMembershipCQ/

Expected Output:

NetworkMembershipCQ/
└── Queries/
    ├── GetMyNetworkTree/
    ├── GetMyNetworkPosition/
    └── GetMyNetworkHistory/

Task 2.2: Query #1 - GetMyNetworkTree (نمایش درخت)

فایل 1: GetMyNetworkTreeQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkTree;

/// <summary>
/// Query برای دریافت درخت شبکه کاربر جاری
/// </summary>
public record GetMyNetworkTreeQuery : IRequest<MyNetworkTreeResponseDto>
{
    /// <summary>
    /// عمق درخت (چند لایه زیرمجموعه نمایش داده شود)
    /// پیش‌فرض: 3 لایه
    /// </summary>
    public int Depth { get; init; } = 3;
}

فایل 2: MyNetworkTreeResponseDto.cs

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkTree;

/// <summary>
/// DTO مشتری‌محور برای نمایش درخت شبکه
/// </summary>
public class MyNetworkTreeResponseDto
{
    public NetworkNodeDto CurrentUser { get; set; }
    public int TotalNetworkSize { get; set; }      // تعداد کل افراد در شبکه
    public int DirectChildrenCount { get; set; }   // تعداد فرزندان مستقیم
    public string LastUpdatePersian { get; set; }  // آخرین به‌روزرسانی
}

/// <summary>
/// نود درخت (Recursive)
/// </summary>
public class NetworkNodeDto
{
    public long UserId { get; set; }
    public string FullName { get; set; }
    public string Mobile { get; set; }
    public string Position { get; set; }           // "Root" / "Left" / "Right"
    public string JoinDatePersian { get; set; }
    public bool HasLeftChild { get; set; }
    public bool HasRightChild { get; set; }
    
    // Recursive children
    public NetworkNodeDto LeftChild { get; set; }
    public NetworkNodeDto RightChild { get; set; }
    
    // UI Helper fields
    public string StatusBadge { get; set; }        // "فعال" / "غیرفعال"
    public string StatusColor { get; set; }        // "success" / "error"
}

فایل 3: GetMyNetworkTreeQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkTree;

public class GetMyNetworkTreeQueryHandler 
    : IRequestHandler<GetMyNetworkTreeQuery, MyNetworkTreeResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly NetworkMembershipServiceClient _cmsClient;
    
    public GetMyNetworkTreeQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyNetworkTreeResponseDto> Handle(
        GetMyNetworkTreeQuery request, 
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: فراخوانی CMS
        // var cmsResult = await _cmsClient.GetNetworkTreeAsync(
        //     new GetNetworkTreeRequest { UserId = userId, Depth = request.Depth });
        
        // Mock Data برای تست UI
        return new MyNetworkTreeResponseDto
        {
            CurrentUser = new NetworkNodeDto
            {
                UserId = userId,
                FullName = "علی احمدی",
                Mobile = "09121234567",
                Position = "Root",
                JoinDatePersian = "1 آذر 1403",
                HasLeftChild = true,
                HasRightChild = true,
                StatusBadge = "فعال",
                StatusColor = "success",
                LeftChild = new NetworkNodeDto
                {
                    UserId = 101,
                    FullName = "رضا محمدی",
                    Mobile = "09129876543",
                    Position = "Left",
                    JoinDatePersian = "5 آذر 1403",
                    HasLeftChild = false,
                    HasRightChild = false,
                    StatusBadge = "فعال",
                    StatusColor = "success"
                },
                RightChild = new NetworkNodeDto
                {
                    UserId = 102,
                    FullName = "سارا کریمی",
                    Mobile = "09131111111",
                    Position = "Right",
                    JoinDatePersian = "10 آذر 1403",
                    HasLeftChild = false,
                    HasRightChild = false,
                    StatusBadge = "فعال",
                    StatusColor = "success"
                }
            },
            TotalNetworkSize = 3,
            DirectChildrenCount = 2,
            LastUpdatePersian = "15 آذر 1403"
        };
    }
}

Checkpoint Task 2.2:

[ ] 3 فایل ایجاد شدند
[ ] Recursive DTO به درستی تعریف شد
[ ] Mock tree data با 2 فرزند برمی‌گردد

Task 2.3: Query #2 - GetMyNetworkPosition (موقعیت من)

فایل 1: GetMyNetworkPositionQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkPosition;

public record GetMyNetworkPositionQuery : IRequest<MyNetworkPositionResponseDto>
{
}

فایل 2: MyNetworkPositionResponseDto.cs

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkPosition;

public class MyNetworkPositionResponseDto
{
    public bool HasParent { get; set; }
    public string ParentFullName { get; set; }
    public string ParentMobile { get; set; }
    public string MyPosition { get; set; }          // "چپ" / "راست" / "ریشه"
    public string MyPositionIcon { get; set; }      // "arrow_back" / "arrow_forward"
    
    public int NetworkLevel { get; set; }           // سطح در شبکه (1=ریشه, 2=فرزند, ...)
    public int TotalDownlineCount { get; set; }     // تعداد کل زیرمجموعه‌ها
    public string JoinDatePersian { get; set; }
}

فایل 3: GetMyNetworkPositionQueryHandler.cs (Mock Data)

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkPosition;

public class GetMyNetworkPositionQueryHandler 
    : IRequestHandler<GetMyNetworkPositionQuery, MyNetworkPositionResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public GetMyNetworkPositionQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyNetworkPositionResponseDto> Handle(
        GetMyNetworkPositionQuery request, 
        CancellationToken cancellationToken)
    {
        // TODO: Call CMS
        return new MyNetworkPositionResponseDto
        {
            HasParent = true,
            ParentFullName = "حسن رضایی",
            ParentMobile = "09123456789",
            MyPosition = "چپ",
            MyPositionIcon = "arrow_back",
            NetworkLevel = 2,
            TotalDownlineCount = 5,
            JoinDatePersian = "1 آذر 1403"
        };
    }
}

📝 STEP 3: اضافه کردن Controller

فایل: NetworkMembershipController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkTree;
using FrontOffice.BFF.Application.NetworkMembershipCQ.Queries.GetMyNetworkPosition;

namespace FrontOffice.BFF.WebApi.Controllers;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class NetworkMembershipController : ControllerBase
{
    private readonly IMediator _mediator;
    
    public NetworkMembershipController(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    /// <summary>
    /// دریافت درخت شبکه من
    /// </summary>
    [HttpGet("my-tree")]
    [ProducesResponseType(typeof(MyNetworkTreeResponseDto), 200)]
    public async Task<IActionResult> GetMyTree([FromQuery] int depth = 3)
    {
        var query = new GetMyNetworkTreeQuery { Depth = depth };
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    /// <summary>
    /// دریافت موقعیت من در شبکه
    /// </summary>
    [HttpGet("my-position")]
    [ProducesResponseType(typeof(MyNetworkPositionResponseDto), 200)]
    public async Task<IActionResult> GetMyPosition()
    {
        var query = new GetMyNetworkPositionQuery();
        var result = await _mediator.Send(query);
        return Ok(result);
    }
}

Test Endpoints:

# Test 1: Get Tree
curl -H "Authorization: Bearer TOKEN" \
     "http://localhost:5002/api/networkmembership/my-tree?depth=3"

# Test 2: Get Position
curl -H "Authorization: Bearer TOKEN" \
     http://localhost:5002/api/networkmembership/my-position

📝 STEP 4: ایجاد UI - Network Pages

Task 4.1: Service Layer

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/FrontOffice.Main/Services/
nano NetworkMembershipService.cs
using System.Net.Http.Json;
using FrontOffice.Main.Models;

namespace FrontOffice.Main.Services;

public class NetworkMembershipService
{
    private readonly HttpClient _httpClient;
    
    public NetworkMembershipService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<MyNetworkTreeDto> GetMyTreeAsync(int depth = 3)
    {
        var response = await _httpClient.GetAsync($"/api/networkmembership/my-tree?depth={depth}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyNetworkTreeDto>();
    }
    
    public async Task<MyNetworkPositionDto> GetMyPositionAsync()
    {
        var response = await _httpClient.GetAsync("/api/networkmembership/my-position");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyNetworkPositionDto>();
    }
}

ثبت در Program.cs:

builder.Services.AddScoped<NetworkMembershipService>();

Task 4.2: Models

// Models/MyNetworkTreeDto.cs
namespace FrontOffice.Main.Models;

public class MyNetworkTreeDto
{
    public NetworkNodeDto CurrentUser { get; set; }
    public int TotalNetworkSize { get; set; }
    public int DirectChildrenCount { get; set; }
    public string LastUpdatePersian { get; set; }
}

public class NetworkNodeDto
{
    public long UserId { get; set; }
    public string FullName { get; set; }
    public string Mobile { get; set; }
    public string Position { get; set; }
    public string JoinDatePersian { get; set; }
    public bool HasLeftChild { get; set; }
    public bool HasRightChild { get; set; }
    public NetworkNodeDto LeftChild { get; set; }
    public NetworkNodeDto RightChild { get; set; }
    public string StatusBadge { get; set; }
    public string StatusColor { get; set; }
}

// Models/MyNetworkPositionDto.cs
public class MyNetworkPositionDto
{
    public bool HasParent { get; set; }
    public string ParentFullName { get; set; }
    public string ParentMobile { get; set; }
    public string MyPosition { get; set; }
    public string MyPositionIcon { get; set; }
    public int NetworkLevel { get; set; }
    public int TotalDownlineCount { get; set; }
    public string JoinDatePersian { get; set; }
}

Task 4.3: Component - NetworkTreeNode (Recursive Component)

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/FrontOffice.Main/Components/Network/
mkdir -p Network
nano NetworkTreeNode.razor
@* Component برای نمایش یک نود درخت (Recursive) *@

<MudCard Class="ma-2" Style="min-width: 250px;">
    <MudCardHeader>
        <CardHeaderContent>
            <MudText Typo="Typo.body1"><strong>@Node.FullName</strong></MudText>
            <MudText Typo="Typo.body2" Color="Color.Secondary">@Node.Mobile</MudText>
        </CardHeaderContent>
        <CardHeaderActions>
            <MudChip Size="Size.Small" 
                     Color="@(Node.StatusColor == "success" ? Color.Success : Color.Error)">
                @Node.StatusBadge
            </MudChip>
        </CardHeaderActions>
    </MudCardHeader>
    <MudCardContent>
        <MudText Typo="Typo.caption">موقعیت: @Node.Position</MudText>
        <MudText Typo="Typo.caption">تاریخ: @Node.JoinDatePersian</MudText>
    </MudCardContent>
</MudCard>

@if (Node.LeftChild != null || Node.RightChild != null)
{
    <MudGrid Class="mt-2" Justify="Justify.Center">
        @if (Node.LeftChild != null)
        {
            <MudItem xs="6">
                <div style="border-right: 2px solid #ccc; padding-right: 10px;">
                    <MudText Typo="Typo.caption" Color="Color.Primary">← چپ</MudText>
                    <NetworkTreeNode Node="@Node.LeftChild" />
                </div>
            </MudItem>
        }
        
        @if (Node.RightChild != null)
        {
            <MudItem xs="6">
                <div style="border-left: 2px solid #ccc; padding-left: 10px;">
                    <MudText Typo="Typo.caption" Color="Color.Secondary">راست →</MudText>
                    <NetworkTreeNode Node="@Node.RightChild" />
                </div>
            </MudItem>
        }
    </MudGrid>
}

@code {
    [Parameter]
    public NetworkNodeDto Node { get; set; }
}

Task 4.4: Page - NetworkTreePage

nano /home/masoud/Apps/project/FourSat/FrontOffice/src/FrontOffice.Main/Pages/Network/NetworkTreePage.razor
@page "/network/tree"
@inject NetworkMembershipService NetworkService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">شبکه باینری من</MudText>
    
    @if (_loading)
    {
        <MudProgressLinear Indeterminate="true" Color="Color.Primary" />
    }
    else if (_tree != null)
    {
        <MudGrid>
            <MudItem xs="12" md="4">
                <MudCard>
                    <MudCardContent>
                        <MudStack Spacing="2">
                            <MudText Typo="Typo.h6">آمار کلی</MudText>
                            <MudText>
                                <strong>تعداد کل اعضا:</strong> @_tree.TotalNetworkSize نفر
                            </MudText>
                            <MudText>
                                <strong>فرزندان مستقیم:</strong> @_tree.DirectChildrenCount نفر
                            </MudText>
                            <MudText>
                                <strong>آخرین به‌روزرسانی:</strong> @_tree.LastUpdatePersian
                            </MudText>
                        </MudStack>
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <MudItem xs="12" md="8">
                <MudText Typo="Typo.h6" Class="mb-2">درخت شبکه</MudText>
                <div style="overflow-x: auto;">
                    <NetworkTreeNode Node="@_tree.CurrentUser" />
                </div>
            </MudItem>
        </MudGrid>
    }
</MudContainer>

@code {
    private MyNetworkTreeDto? _tree;
    private bool _loading = true;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadTree();
    }
    
    private async Task LoadTree()
    {
        try
        {
            _loading = true;
            _tree = await NetworkService.GetMyTreeAsync(depth: 3);
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
}

Task 4.5: Page - NetworkPositionPage

nano /home/masoud/Apps/project/FourSat/FrontOffice/src/FrontOffice.Main/Pages/Network/NetworkPositionPage.razor
@page "/network/position"
@inject NetworkMembershipService NetworkService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">موقعیت من در شبکه</MudText>
    
    @if (_loading)
    {
        <MudProgressLinear Indeterminate="true" />
    }
    else if (_position != null)
    {
        <MudCard>
            <MudCardContent>
                <MudGrid>
                    @if (_position.HasParent)
                    {
                        <MudItem xs="12" md="6">
                            <MudPaper Class="pa-4" Elevation="2">
                                <MudText Typo="Typo.h6" Class="mb-2">معرف من</MudText>
                                <MudText><strong>نام:</strong> @_position.ParentFullName</MudText>
                                <MudText><strong>موبایل:</strong> @_position.ParentMobile</MudText>
                            </MudPaper>
                        </MudItem>
                    }
                    
                    <MudItem xs="12" md="6">
                        <MudPaper Class="pa-4" Elevation="2">
                            <MudText Typo="Typo.h6" Class="mb-2">موقعیت من</MudText>
                            <MudText>
                                <MudIcon Icon="@_position.MyPositionIcon" />
                                <strong>@_position.MyPosition</strong>
                            </MudText>
                            <MudText><strong>سطح:</strong> @_position.NetworkLevel</MudText>
                        </MudPaper>
                    </MudItem>
                    
                    <MudItem xs="12">
                        <MudPaper Class="pa-4" Elevation="2">
                            <MudText Typo="Typo.h6" Class="mb-2">آمار زیرمجموعه</MudText>
                            <MudText>
                                <strong>تعداد کل افراد زیر مجموعه:</strong> @_position.TotalDownlineCount نفر
                            </MudText>
                            <MudText>
                                <strong>تاریخ پیوستن:</strong> @_position.JoinDatePersian
                            </MudText>
                        </MudPaper>
                    </MudItem>
                </MudGrid>
            </MudCardContent>
        </MudCard>
    }
</MudContainer>

@code {
    private MyNetworkPositionDto? _position;
    private bool _loading = true;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadPosition();
    }
    
    private async Task LoadPosition()
    {
        try
        {
            _loading = true;
            _position = await NetworkService.GetMyPositionAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
}

Task 4.6: اضافه کردن به NavMenu

<MudNavGroup Title="شبکه من" Icon="@Icons.Material.Filled.AccountTree">
    <MudNavLink Href="/network/tree" Icon="@Icons.Material.Filled.Schema">
        درخت شبکه
    </MudNavLink>
    <MudNavLink Href="/network/position" Icon="@Icons.Material.Filled.MyLocation">
        موقعیت من
    </MudNavLink>
</MudNavGroup>

Checkpoint نهایی STEP 4

# Build & Test
cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/
dotnet build

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/
dotnet build

چیزهایی که باید کار کنند:

[ ] BFF Build می‌شود (2 Query, 2 Controller endpoints)
[ ] FrontOffice Build می‌شود
[ ] صفحه /network/tree درخت نمایش می‌دهد
[ ] صفحه /network/position موقعیت نمایش می‌دهد
[ ] Recursive Component به درستی کار می‌کند
[ ] Mock data با 2 فرزند نمایش داده می‌شود

📊 آماری از کارهای انجام شده

مورد تعداد وضعیت
Queries پیاده شده 2 از 4 50%
Commands پیاده شده 0 از 3 0%
Handlers 2 Mock Data
Controllers 1 2 Endpoints
UI Pages 2
UI Components 1 Recursive Tree
Services 1

زمان تخمینی تا اینجا: 5 ساعت
کارهای باقی‌مانده: GetNetworkHistory Query + اتصال واقعی به CMS


💡 نکات مهم برای Developer

  1. Recursive Component: NetworkTreeNode به صورت Recursive خودش را صدا می‌زند - مراقب Performance باش
  2. Depth Control: هرگز depth > 5 نگذار (درخت خیلی بزرگ می‌شود)
  3. UI Overflow: از overflow-x: auto برای درخت‌های بزرگ استفاده شد
  4. CMS Integration: بعد از اتصال به CMS، حتماً Handle کن که LeftChild/RightChild ممکنه null باشند

💰 مرحله 4: راهنمای گام‌به‌گام - Commission + Withdrawal (کمیسیون و برداشت)

📊 خلاصه ماژول

هدف کسب‌وکار: مشتری باید بتواند کمیسیون‌های خود را مشاهده کند، درخواست برداشت بدهد، وضعیت برداشت‌ها را پیگیری کند، و موجودی قابل برداشت خود را ببیند.

اجزای موجود در CMS:

  • CommissionPayout Entity (مبلغ، هفته، وضعیت، تاریخ)
  • WithdrawalRequest Entity (مبلغ، وضعیت: Pending/Approved/Rejected/Paid)
  • 8 Commands: RequestWithdrawal, ApproveWithdrawal, RejectWithdrawal, PayWithdrawal, CancelWithdrawal, RecalculateCommission, AdjustBalance, TransferCommission
  • 8 Queries: GetUserCommissionPayouts, GetUserBalance, GetWithdrawalHistory, GetWeeklyReport, GetPoolShare, GetDownlineCommissions, GetCommissionStatistics, GetAvailableBalance

چیزهای غایب در BFF:

  • فقط 10% پیاده شده (GetUserCommissionPayouts Query)
  • هیچ Command برای RequestWithdrawal
  • هیچ Query برای موجودی و برداشت‌ها

UI غایب:

  • صفحه نمایش کمیسیون‌ها
  • صفحه درخواست برداشت
  • صفحه تاریخچه برداشت‌ها

📝 STEP 1: بررسی CMS Commission Module

Task 1.1: بررسی Entities

cd /home/masoud/Apps/project/FourSat/CMS/src/

# 1. بررسی CommissionPayout Entity
cat CMSMicroservice.Domain/Entities/CommissionPayout.cs
# فیلدهای کلیدی:
#   - UserId: کاربر دریافت‌کننده
#   - Amount: مبلغ کمیسیون (decimal)
#   - WeekNumber: شماره هفته
#   - PayoutDate: تاریخ پرداخت
#   - Status: Pending/Calculated/Paid
#   - PayoutType: Direct/Binary/Pool/Club

# 2. بررسی WithdrawalRequest Entity
cat CMSMicroservice.Domain/Entities/WithdrawalRequest.cs
# فیلدهای کلیدی:
#   - UserId: کاربر درخواست‌دهنده
#   - Amount: مبلغ درخواستی
#   - Status: Pending/Approved/Rejected/Paid/Cancelled
#   - RequestDate: تاریخ درخواست
#   - ProcessDate: تاریخ پردازش
#   - BankAccountInfo: اطلاعات حساب (شماره کارت/شبا)
#   - RejectReason: دلیل رد (اگر رد شده)

# 3. بررسی UserBalance (موجودی)
cat CMSMicroservice.Domain/Entities/UserBalance.cs
# فیلدها:
#   - UserId
#   - CommissionBalance: موجودی کمیسیون
#   - WithdrawableBalance: قابل برداشت
#   - PendingWithdrawal: در انتظار برداشت
#   - TotalEarned: کل درآمد

Output Task 1.1:

[ ] CommissionPayout Entity را خواندم
[ ] WithdrawalRequest Entity را خواندم
[ ] UserBalance Entity را خواندم
[ ] Status enums را یادداشت کردم

Task 1.2: بررسی Queries موجود در CMS

# Query 1: GetUserCommissionPayouts
cat CMSMicroservice.Application/CommissionCQ/Queries/GetUserCommissionPayouts/GetUserCommissionPayoutsQueryHandler.cs
# Input: UserId, FromDate, ToDate, PageNumber, PageSize
# Output: List<CommissionPayoutDto> + TotalCount

# Query 2: GetUserBalance
cat CMSMicroservice.Application/CommissionCQ/Queries/GetUserBalance/GetUserBalanceQueryHandler.cs
# Input: UserId
# Output: CommissionBalance, WithdrawableBalance, PendingWithdrawal, TotalEarned

# Query 3: GetWithdrawalHistory
cat CMSMicroservice.Application/CommissionCQ/Queries/GetWithdrawalHistory/GetWithdrawalHistoryQueryHandler.cs
# Input: UserId, FromDate, ToDate, Status (optional)
# Output: List<WithdrawalRequestDto>

# Query 4: GetAvailableBalance
cat CMSMicroservice.Application/CommissionCQ/Queries/GetAvailableBalance/GetAvailableBalanceQueryHandler.cs
# Input: UserId
# Output: AvailableAmount, MinWithdrawalAmount, MaxWithdrawalAmount

Task 1.3: بررسی Command RequestWithdrawal

cat CMSMicroservice.Application/CommissionCQ/Commands/RequestWithdrawal/RequestWithdrawalCommandHandler.cs
# Input:
#   - UserId
#   - Amount
#   - BankAccountNumber (شماره کارت/شبا)
# Logic:
#   1. بررسی موجودی کافی
#   2. بررسی حداقل/حداکثر مبلغ
#   3. ایجاد WithdrawalRequest
#   4. کسر از WithdrawableBalance
#   5. اضافه به PendingWithdrawal
# Output: WithdrawalRequestId

📝 STEP 2: ایجاد BFF Module - CommissionCQ

Task 2.1: ساخت فولدرها

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/FrontOffice.BFF.Application/

mkdir -p CommissionCQ/Queries/GetMyCommissionPayouts
mkdir -p CommissionCQ/Queries/GetMyBalance
mkdir -p CommissionCQ/Queries/GetMyWithdrawalHistory
mkdir -p CommissionCQ/Commands/RequestMyWithdrawal

tree CommissionCQ/

Expected Output:

CommissionCQ/
├── Commands/
│   └── RequestMyWithdrawal/
└── Queries/
    ├── GetMyCommissionPayouts/
    ├── GetMyBalance/
    └── GetMyWithdrawalHistory/

Task 2.2: Query #1 - GetMyCommissionPayouts

فایل 1: GetMyCommissionPayoutsQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyCommissionPayouts;

public record GetMyCommissionPayoutsQuery : IRequest<MyCommissionPayoutsResponseDto>
{
    /// <summary>
    /// تعداد آیتم در هر صفحه (پیش‌فرض: 10)
    /// </summary>
    public int PageSize { get; init; } = 10;
    
    /// <summary>
    /// شماره صفحه (پیش‌فرض: 1)
    /// </summary>
    public int PageNumber { get; init; } = 1;
    
    /// <summary>
    /// فیلتر بر اساس نوع کمیسیون (اختیاری)
    /// </summary>
    public string PayoutType { get; init; }
}

فایل 2: MyCommissionPayoutsResponseDto.cs

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyCommissionPayouts;

public class MyCommissionPayoutsResponseDto
{
    public List<CommissionPayoutItemDto> Payouts { get; set; }
    public int TotalCount { get; set; }
    public int CurrentPage { get; set; }
    public int TotalPages { get; set; }
    public decimal TotalAmount { get; set; }       // مجموع کل کمیسیون‌ها
}

public class CommissionPayoutItemDto
{
    public long Id { get; set; }
    public string WeekDisplay { get; set; }        // "هفته 48 - سال 1403"
    public decimal Amount { get; set; }
    public string AmountFormatted { get; set; }    // "1,250,000 تومان"
    public string PayoutType { get; set; }         // "مستقیم" / "باینری" / "پول" / "باشگاه"
    public string PayoutTypeIcon { get; set; }     // Icon name for UI
    public string Status { get; set; }             // "در انتظار" / "محاسبه شده" / "پرداخت شده"
    public string StatusColor { get; set; }        // "warning" / "info" / "success"
    public string PayoutDatePersian { get; set; }
}

فایل 3: GetMyCommissionPayoutsQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyCommissionPayouts;

public class GetMyCommissionPayoutsQueryHandler 
    : IRequestHandler<GetMyCommissionPayoutsQuery, MyCommissionPayoutsResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly CommissionServiceClient _cmsClient;
    
    public GetMyCommissionPayoutsQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyCommissionPayoutsResponseDto> Handle(
        GetMyCommissionPayoutsQuery request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: فراخوانی CMS
        // var cmsResult = await _cmsClient.GetUserCommissionPayoutsAsync(
        //     new GetUserCommissionPayoutsRequest { 
        //         UserId = userId, 
        //         PageNumber = request.PageNumber,
        //         PageSize = request.PageSize 
        //     });
        
        // Mock Data
        return new MyCommissionPayoutsResponseDto
        {
            Payouts = new List<CommissionPayoutItemDto>
            {
                new() {
                    Id = 1,
                    WeekDisplay = "هفته 48 - سال 1403",
                    Amount = 1250000,
                    AmountFormatted = "1,250,000 تومان",
                    PayoutType = "مستقیم",
                    PayoutTypeIcon = "trending_up",
                    Status = "پرداخت شده",
                    StatusColor = "success",
                    PayoutDatePersian = "20 آذر 1403"
                },
                new() {
                    Id = 2,
                    WeekDisplay = "هفته 47 - سال 1403",
                    Amount = 850000,
                    AmountFormatted = "850,000 تومان",
                    PayoutType = "باینری",
                    PayoutTypeIcon = "account_tree",
                    Status = "پرداخت شده",
                    StatusColor = "success",
                    PayoutDatePersian = "13 آذر 1403"
                }
            },
            TotalCount = 2,
            CurrentPage = 1,
            TotalPages = 1,
            TotalAmount = 2100000
        };
    }
}

Task 2.3: Query #2 - GetMyBalance (موجودی)

فایل 1: GetMyBalanceQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyBalance;

public record GetMyBalanceQuery : IRequest<MyBalanceResponseDto>
{
}

فایل 2: MyBalanceResponseDto.cs

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyBalance;

public class MyBalanceResponseDto
{
    public decimal TotalEarned { get; set; }           // کل درآمد تاکنون
    public string TotalEarnedFormatted { get; set; }
    
    public decimal CurrentBalance { get; set; }        // موجودی فعلی
    public string CurrentBalanceFormatted { get; set; }
    
    public decimal WithdrawableBalance { get; set; }   // قابل برداشت
    public string WithdrawableBalanceFormatted { get; set; }
    
    public decimal PendingWithdrawal { get; set; }     // در انتظار برداشت
    public string PendingWithdrawalFormatted { get; set; }
    
    public bool CanRequestWithdrawal { get; set; }     // آیا می‌تواند برداشت کند؟
    public string MinWithdrawalAmount { get; set; }    // حداقل مبلغ برداشت
    public string MaxWithdrawalAmount { get; set; }    // حداکثر مبلغ برداشت
}

فایل 3: GetMyBalanceQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyBalance;

public class GetMyBalanceQueryHandler 
    : IRequestHandler<GetMyBalanceQuery, MyBalanceResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public GetMyBalanceQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyBalanceResponseDto> Handle(
        GetMyBalanceQuery request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        
        // Mock Data
        return new MyBalanceResponseDto
        {
            TotalEarned = 15750000,
            TotalEarnedFormatted = "15,750,000 تومان",
            CurrentBalance = 8500000,
            CurrentBalanceFormatted = "8,500,000 تومان",
            WithdrawableBalance = 7000000,
            WithdrawableBalanceFormatted = "7,000,000 تومان",
            PendingWithdrawal = 1500000,
            PendingWithdrawalFormatted = "1,500,000 تومان",
            CanRequestWithdrawal = true,
            MinWithdrawalAmount = "100,000 تومان",
            MaxWithdrawalAmount = "7,000,000 تومان"
        };
    }
}

Task 2.4: Query #3 - GetMyWithdrawalHistory

فایل 1: GetMyWithdrawalHistoryQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyWithdrawalHistory;

public record GetMyWithdrawalHistoryQuery : IRequest<MyWithdrawalHistoryResponseDto>
{
    public int PageSize { get; init; } = 10;
    public int PageNumber { get; init; } = 1;
}

فایل 2: MyWithdrawalHistoryResponseDto.cs

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyWithdrawalHistory;

public class MyWithdrawalHistoryResponseDto
{
    public List<WithdrawalItemDto> Withdrawals { get; set; }
    public int TotalCount { get; set; }
}

public class WithdrawalItemDto
{
    public long Id { get; set; }
    public decimal Amount { get; set; }
    public string AmountFormatted { get; set; }
    public string Status { get; set; }              // "در انتظار" / "تایید" / "رد" / "پرداخت شده"
    public string StatusColor { get; set; }         // "warning" / "success" / "error" / "info"
    public string RequestDatePersian { get; set; }
    public string ProcessDatePersian { get; set; }
    public string BankAccount { get; set; }         // "6037-****-****-1234"
    public string RejectReason { get; set; }        // دلیل رد (اگر رد شده)
}

فایل 3: GetMyWithdrawalHistoryQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyWithdrawalHistory;

public class GetMyWithdrawalHistoryQueryHandler 
    : IRequestHandler<GetMyWithdrawalHistoryQuery, MyWithdrawalHistoryResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public GetMyWithdrawalHistoryQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyWithdrawalHistoryResponseDto> Handle(
        GetMyWithdrawalHistoryQuery request,
        CancellationToken cancellationToken)
    {
        // TODO: Call CMS
        
        return new MyWithdrawalHistoryResponseDto
        {
            Withdrawals = new List<WithdrawalItemDto>
            {
                new() {
                    Id = 1,
                    Amount = 1500000,
                    AmountFormatted = "1,500,000 تومان",
                    Status = "در انتظار",
                    StatusColor = "warning",
                    RequestDatePersian = "25 آذر 1403",
                    ProcessDatePersian = "-",
                    BankAccount = "6037-****-****-1234"
                },
                new() {
                    Id = 2,
                    Amount = 2000000,
                    AmountFormatted = "2,000,000 تومان",
                    Status = "پرداخت شده",
                    StatusColor = "success",
                    RequestDatePersian = "15 آذر 1403",
                    ProcessDatePersian = "18 آذر 1403",
                    BankAccount = "6037-****-****-1234"
                }
            },
            TotalCount = 2
        };
    }
}

Task 2.5: Command - RequestMyWithdrawal

فایل 1: RequestMyWithdrawalCommand.cs

using MediatR;

namespace FrontOffice.BFF.Application.CommissionCQ.Commands.RequestMyWithdrawal;

public record RequestMyWithdrawalCommand : IRequest<RequestMyWithdrawalResponseDto>
{
    public decimal Amount { get; init; }
    public string BankAccountNumber { get; init; }    // شماره کارت یا شبا
}

فایل 2: RequestMyWithdrawalResponseDto.cs

namespace FrontOffice.BFF.Application.CommissionCQ.Commands.RequestMyWithdrawal;

public class RequestMyWithdrawalResponseDto
{
    public bool Success { get; set; }
    public long WithdrawalRequestId { get; set; }
    public string Message { get; set; }               // "درخواست شما با موفقیت ثبت شد"
    public string NewWithdrawableBalance { get; set; } // موجودی جدید قابل برداشت
}

فایل 3: RequestMyWithdrawalCommandHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.CommissionCQ.Commands.RequestMyWithdrawal;

public class RequestMyWithdrawalCommandHandler 
    : IRequestHandler<RequestMyWithdrawalCommand, RequestMyWithdrawalResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly CommissionServiceClient _cmsClient;
    
    public RequestMyWithdrawalCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<RequestMyWithdrawalResponseDto> Handle(
        RequestMyWithdrawalCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: فراخوانی CMS
        // var cmsResult = await _cmsClient.RequestWithdrawalAsync(
        //     new RequestWithdrawalRequest {
        //         UserId = userId,
        //         Amount = request.Amount,
        //         BankAccountNumber = request.BankAccountNumber
        //     });
        
        // Mock Response
        return new RequestMyWithdrawalResponseDto
        {
            Success = true,
            WithdrawalRequestId = 123,
            Message = "درخواست برداشت شما با موفقیت ثبت شد و در انتظار تایید است.",
            NewWithdrawableBalance = "5,500,000 تومان"
        };
    }
}

فایل 4: RequestMyWithdrawalCommandValidator.cs

using FluentValidation;

namespace FrontOffice.BFF.Application.CommissionCQ.Commands.RequestMyWithdrawal;

public class RequestMyWithdrawalCommandValidator : AbstractValidator<RequestMyWithdrawalCommand>
{
    public RequestMyWithdrawalCommandValidator()
    {
        RuleFor(x => x.Amount)
            .GreaterThan(0).WithMessage("مبلغ باید بیشتر از صفر باشد")
            .LessThanOrEqualTo(50000000).WithMessage("حداکثر مبلغ برداشت 50 میلیون تومان است");
        
        RuleFor(x => x.BankAccountNumber)
            .NotEmpty().WithMessage("شماره کارت الزامی است")
            .Length(16, 24).WithMessage("شماره کارت یا شبا نامعتبر است");
    }
}

📝 STEP 3: اضافه کردن Controller

فایل: CommissionController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
using FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyBalance;
using FrontOffice.BFF.Application.CommissionCQ.Queries.GetMyWithdrawalHistory;
using FrontOffice.BFF.Application.CommissionCQ.Commands.RequestMyWithdrawal;

namespace FrontOffice.BFF.WebApi.Controllers;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class CommissionController : ControllerBase
{
    private readonly IMediator _mediator;
    
    public CommissionController(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    /// <summary>
    /// دریافت لیست کمیسیون‌های من
    /// </summary>
    [HttpGet("my-payouts")]
    [ProducesResponseType(typeof(MyCommissionPayoutsResponseDto), 200)]
    public async Task<IActionResult> GetMyPayouts(
        [FromQuery] int pageNumber = 1,
        [FromQuery] int pageSize = 10)
    {
        var query = new GetMyCommissionPayoutsQuery 
        { 
            PageNumber = pageNumber, 
            PageSize = pageSize 
        };
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    /// <summary>
    /// دریافت موجودی من
    /// </summary>
    [HttpGet("my-balance")]
    [ProducesResponseType(typeof(MyBalanceResponseDto), 200)]
    public async Task<IActionResult> GetMyBalance()
    {
        var query = new GetMyBalanceQuery();
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    /// <summary>
    /// دریافت تاریخچه برداشت‌های من
    /// </summary>
    [HttpGet("my-withdrawal-history")]
    [ProducesResponseType(typeof(MyWithdrawalHistoryResponseDto), 200)]
    public async Task<IActionResult> GetMyWithdrawalHistory(
        [FromQuery] int pageNumber = 1,
        [FromQuery] int pageSize = 10)
    {
        var query = new GetMyWithdrawalHistoryQuery 
        { 
            PageNumber = pageNumber, 
            PageSize = pageSize 
        };
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    /// <summary>
    /// درخواست برداشت
    /// </summary>
    [HttpPost("request-withdrawal")]
    [ProducesResponseType(typeof(RequestMyWithdrawalResponseDto), 200)]
    public async Task<IActionResult> RequestWithdrawal(
        [FromBody] RequestMyWithdrawalCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(result);
    }
}

Test Endpoints:

# Test 1: Get Payouts
curl -H "Authorization: Bearer TOKEN" \
     "http://localhost:5002/api/commission/my-payouts?pageNumber=1&pageSize=10"

# Test 2: Get Balance
curl -H "Authorization: Bearer TOKEN" \
     http://localhost:5002/api/commission/my-balance

# Test 3: Get Withdrawal History
curl -H "Authorization: Bearer TOKEN" \
     http://localhost:5002/api/commission/my-withdrawal-history

# Test 4: Request Withdrawal
curl -X POST \
     -H "Authorization: Bearer TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"amount": 1500000, "bankAccountNumber": "6037997012345678"}' \
     http://localhost:5002/api/commission/request-withdrawal

📝 STEP 4: ایجاد UI - Commission Pages

Task 4.1: Service Layer

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/FrontOffice.Main/Services/
nano CommissionService.cs
using System.Net.Http.Json;
using FrontOffice.Main.Models;

namespace FrontOffice.Main.Services;

public class CommissionService
{
    private readonly HttpClient _httpClient;
    
    public CommissionService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<MyCommissionPayoutsDto> GetMyPayoutsAsync(int pageNumber = 1, int pageSize = 10)
    {
        var response = await _httpClient.GetAsync(
            $"/api/commission/my-payouts?pageNumber={pageNumber}&pageSize={pageSize}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyCommissionPayoutsDto>();
    }
    
    public async Task<MyBalanceDto> GetMyBalanceAsync()
    {
        var response = await _httpClient.GetAsync("/api/commission/my-balance");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyBalanceDto>();
    }
    
    public async Task<MyWithdrawalHistoryDto> GetMyWithdrawalHistoryAsync(int pageNumber = 1)
    {
        var response = await _httpClient.GetAsync(
            $"/api/commission/my-withdrawal-history?pageNumber={pageNumber}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyWithdrawalHistoryDto>();
    }
    
    public async Task<RequestWithdrawalResultDto> RequestWithdrawalAsync(decimal amount, string bankAccount)
    {
        var request = new { Amount = amount, BankAccountNumber = bankAccount };
        var response = await _httpClient.PostAsJsonAsync("/api/commission/request-withdrawal", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<RequestWithdrawalResultDto>();
    }
}

ثبت در Program.cs:

builder.Services.AddScoped<CommissionService>();

Task 4.2: Models (در فولدر Models/)

// کپی DTOها از BFF به FrontOffice.Main/Models/
// MyCommissionPayoutsDto.cs
// MyBalanceDto.cs
// MyWithdrawalHistoryDto.cs
// RequestWithdrawalResultDto.cs

Task 4.3: Page - CommissionPayoutsPage (صفحه کمیسیون‌ها)

@page "/commission/payouts"
@inject CommissionService CommissionService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">کمیسیون‌های من</MudText>
    
    @if (_loading)
    {
        <MudProgressLinear Indeterminate="true" />
    }
    else if (_payouts != null)
    {
        <MudCard Class="mb-4">
            <MudCardContent>
                <MudText Typo="Typo.h6">
                    مجموع کل: <strong style="color: green;">@_payouts.TotalAmount.ToString("N0") تومان</strong>
                </MudText>
            </MudCardContent>
        </MudCard>
        
        <MudTable Items="@_payouts.Payouts" Hover="true" Striped="true">
            <HeaderContent>
                <MudTh>هفته</MudTh>
                <MudTh>نوع</MudTh>
                <MudTh>مبلغ</MudTh>
                <MudTh>وضعیت</MudTh>
                <MudTh>تاریخ پرداخت</MudTh>
            </HeaderContent>
            <RowTemplate>
                <MudTd>@context.WeekDisplay</MudTd>
                <MudTd>
                    <MudIcon Icon="@context.PayoutTypeIcon" Size="Size.Small" />
                    @context.PayoutType
                </MudTd>
                <MudTd><strong>@context.AmountFormatted</strong></MudTd>
                <MudTd>
                    <MudChip Size="Size.Small" Color="@GetStatusColor(context.StatusColor)">
                        @context.Status
                    </MudChip>
                </MudTd>
                <MudTd>@context.PayoutDatePersian</MudTd>
            </RowTemplate>
        </MudTable>
        
        <MudPagination Class="mt-4" 
                       Count="_payouts.TotalPages" 
                       Selected="_currentPage"
                       SelectedChanged="OnPageChanged" />
    }
</MudContainer>

@code {
    private MyCommissionPayoutsDto? _payouts;
    private bool _loading = true;
    private int _currentPage = 1;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadPayouts();
    }
    
    private async Task LoadPayouts()
    {
        try
        {
            _loading = true;
            _payouts = await CommissionService.GetMyPayoutsAsync(_currentPage, 10);
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
    
    private async Task OnPageChanged(int page)
    {
        _currentPage = page;
        await LoadPayouts();
    }
    
    private Color GetStatusColor(string color)
    {
        return color switch
        {
            "success" => Color.Success,
            "warning" => Color.Warning,
            "error" => Color.Error,
            "info" => Color.Info,
            _ => Color.Default
        };
    }
}

Task 4.4: Page - WithdrawalPage (صفحه برداشت)

@page "/commission/withdrawal"
@inject CommissionService CommissionService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">برداشت وجه</MudText>
    
    @if (_loadingBalance)
    {
        <MudProgressLinear Indeterminate="true" />
    }
    else if (_balance != null)
    {
        <MudGrid>
            <MudItem xs="12" md="6">
                <MudCard Class="mb-4">
                    <MudCardHeader>
                        <CardHeaderContent>
                            <MudText Typo="Typo.h6">موجودی من</MudText>
                        </CardHeaderContent>
                    </MudCardHeader>
                    <MudCardContent>
                        <MudStack Spacing="2">
                            <MudText>
                                <strong>کل درآمد:</strong> 
                                <span style="color: blue;">@_balance.TotalEarnedFormatted</span>
                            </MudText>
                            <MudText>
                                <strong>موجودی فعلی:</strong> 
                                <span style="color: green;">@_balance.CurrentBalanceFormatted</span>
                            </MudText>
                            <MudText>
                                <strong>قابل برداشت:</strong> 
                                <span style="color: orange; font-size: 1.2em;">@_balance.WithdrawableBalanceFormatted</span>
                            </MudText>
                            <MudText>
                                <strong>در انتظار برداشت:</strong> @_balance.PendingWithdrawalFormatted
                            </MudText>
                        </MudStack>
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <MudItem xs="12" md="6">
                <MudCard>
                    <MudCardHeader>
                        <CardHeaderContent>
                            <MudText Typo="Typo.h6">درخواست برداشت جدید</MudText>
                        </CardHeaderContent>
                    </MudCardHeader>
                    <MudCardContent>
                        <MudForm @ref="_form">
                            <MudNumericField @bind-Value="_withdrawalAmount"
                                           Label="مبلغ (تومان)"
                                           Variant="Variant.Outlined"
                                           Required="true"
                                           Min="100000"
                                           Max="_balance.WithdrawableBalance" />
                            
                            <MudTextField @bind-Value="_bankAccount"
                                        Label="شماره کارت یا شبا"
                                        Variant="Variant.Outlined"
                                        Required="true"
                                        MaxLength="24"
                                        Class="mt-3" />
                            
                            <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
                                حداقل: @_balance.MinWithdrawalAmount | حداکثر: @_balance.MaxWithdrawalAmount
                            </MudText>
                        </MudForm>
                    </MudCardContent>
                    <MudCardActions>
                        <MudButton Variant="Variant.Filled"
                                 Color="Color.Primary"
                                 Disabled="@(!_balance.CanRequestWithdrawal || _submitting)"
                                 OnClick="SubmitWithdrawal">
                            @if (_submitting)
                            {
                                <MudProgressCircular Size="Size.Small" Indeterminate="true" />
                                <span class="ms-2">در حال ارسال...</span>
                            }
                            else
                            {
                                <span>ثبت درخواست</span>
                            }
                        </MudButton>
                    </MudCardActions>
                </MudCard>
            </MudItem>
        </MudGrid>
        
        <MudText Typo="Typo.h5" Class="mt-6 mb-3">تاریخچه برداشت‌ها</MudText>
        
        @if (_loadingHistory)
        {
            <MudProgressLinear Indeterminate="true" />
        }
        else if (_history != null)
        {
            <MudTable Items="@_history.Withdrawals" Hover="true">
                <HeaderContent>
                    <MudTh>مبلغ</MudTh>
                    <MudTh>وضعیت</MudTh>
                    <MudTh>تاریخ درخواست</MudTh>
                    <MudTh>تاریخ پردازش</MudTh>
                    <MudTh>شماره کارت</MudTh>
                </HeaderContent>
                <RowTemplate>
                    <MudTd><strong>@context.AmountFormatted</strong></MudTd>
                    <MudTd>
                        <MudChip Size="Size.Small" Color="@GetStatusColor(context.StatusColor)">
                            @context.Status
                        </MudChip>
                    </MudTd>
                    <MudTd>@context.RequestDatePersian</MudTd>
                    <MudTd>@context.ProcessDatePersian</MudTd>
                    <MudTd>@context.BankAccount</MudTd>
                </RowTemplate>
            </MudTable>
        }
    }
</MudContainer>

@code {
    private MyBalanceDto? _balance;
    private MyWithdrawalHistoryDto? _history;
    private bool _loadingBalance = true;
    private bool _loadingHistory = true;
    private bool _submitting = false;
    
    private MudForm _form;
    private decimal _withdrawalAmount;
    private string _bankAccount = "";
    
    protected override async Task OnInitializedAsync()
    {
        await Task.WhenAll(LoadBalance(), LoadHistory());
    }
    
    private async Task LoadBalance()
    {
        try
        {
            _loadingBalance = true;
            _balance = await CommissionService.GetMyBalanceAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا در بارگذاری موجودی: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loadingBalance = false;
        }
    }
    
    private async Task LoadHistory()
    {
        try
        {
            _loadingHistory = true;
            _history = await CommissionService.GetMyWithdrawalHistoryAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا در بارگذاری تاریخچه: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loadingHistory = false;
        }
    }
    
    private async Task SubmitWithdrawal()
    {
        await _form.Validate();
        if (!_form.IsValid) return;
        
        try
        {
            _submitting = true;
            var result = await CommissionService.RequestWithdrawalAsync(_withdrawalAmount, _bankAccount);
            
            if (result.Success)
            {
                Snackbar.Add(result.Message, Severity.Success);
                _withdrawalAmount = 0;
                _bankAccount = "";
                await Task.WhenAll(LoadBalance(), LoadHistory());
            }
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _submitting = false;
        }
    }
    
    private Color GetStatusColor(string color)
    {
        return color switch
        {
            "success" => Color.Success,
            "warning" => Color.Warning,
            "error" => Color.Error,
            _ => Color.Default
        };
    }
}

Task 4.5: اضافه کردن به NavMenu

<MudNavGroup Title="کمیسیون و برداشت" Icon="@Icons.Material.Filled.AccountBalanceWallet">
    <MudNavLink Href="/commission/payouts" Icon="@Icons.Material.Filled.Payments">
        کمیسیون‌های من
    </MudNavLink>
    <MudNavLink Href="/commission/withdrawal" Icon="@Icons.Material.Filled.LocalAtm">
        برداشت وجه
    </MudNavLink>
</MudNavGroup>

Checkpoint نهایی

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/
dotnet build

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/
dotnet build

چیزهایی که باید کار کنند:

[ ] BFF Build شود (3 Queries + 1 Command + Validator)
[ ] FrontOffice Build شود
[ ] صفحه /commission/payouts نمایش داده شود
[ ] صفحه /commission/withdrawal کار کند
[ ] فرم درخواست برداشت Validate شود
[ ] Mock data نمایش داده شود

📊 آماری از کارهای انجام شده

مورد تعداد وضعیت
Queries پیاده شده 3 از 8 37.5%
Commands پیاده شده 1 از 8 12.5%
Handlers 4 Mock Data
Validators 1 FluentValidation
Controllers 1 4 Endpoints
UI Pages 2
Services 1

زمان تخمینی تا اینجا: 6 ساعت
کارهای باقی‌مانده:

  • 5 Query دیگر (Weekly Report, Pool Share, Downline, Statistics, Available)
  • 7 Command دیگر (Approve, Reject, Pay, Cancel, Recalculate, Adjust, Transfer)
  • اتصال واقعی به CMS

💡 نکات بسیار مهم برای Developer

  1. Validation: از FluentValidation استفاده شد - حتماً Validator را در DI ثبت کن
  2. Amount Formatting: همه مبالغ با Format "N0" نمایش داده می‌شوند (1,250,000)
  3. Bank Account Masking: شماره کارت را Mask کن: "6037---1234"
  4. Minimum Withdrawal: در CMS حداقل مبلغ برداشت را Check کن (معمولاً 100,000 تومان)
  5. Concurrent Requests: کاربر نباید بتواند همزمان چند درخواست برداشت بزند
  6. Status Colors: از Color mapping استفاده کن برای نمایش بهتر وضعیت‌ها

🎒 مرحله 5: تکمیل UserWallet (کیف پول)

📊 وضعیت فعلی

موجود در BFF (60%):

  • GetUserWallet Query
  • GetWalletTransactions Query
  • ChargeWallet Command (ولی ناقص)
  • ⚠️ Withdrawal Handler خالی است (TODO)

غایب (40%):

  • DiscountBalance (موجودی تخفیف) - هیچ Query و UI ندارد
  • GetDiscountTransactions Query
  • UseDiscount Command (استفاده از تخفیف در خرید)
  • صفحه نمایش موجودی تخفیف در UI

📝 STEP 1: بررسی DiscountBalance در CMS

Task 1.1: بررسی UserWallet Entity

cd /home/masoud/Apps/project/FourSat/CMS/src/

cat CMSMicroservice.Domain/Entities/UserWallet.cs
# باید ببینی:
#   - MainBalance: موجودی اصلی ✅
#   - DiscountBalance: موجودی تخفیف ❌ (این قسمت غایب است)
#   - RewardBalance: موجودی پاداش ✅

Task 1.2: بررسی Queries موجود

ls CMSMicroservice.Application/UserWalletCQ/Queries/
# باید ببینی:
#   - GetUserWallet/ ✅
#   - GetWalletTransactions/ ✅
#   - GetDiscountTransactions/ (ممکن است وجود نداشته باشد)

# اگر GetDiscountTransactions وجود داشت:
cat CMSMicroservice.Application/UserWalletCQ/Queries/GetDiscountTransactions/GetDiscountTransactionsQueryHandler.cs

Output Task 1.2:

[ ] GetUserWallet Query را بررسی کردم
[ ] چک کردم DiscountBalance در DTO موجود است یا خیر
[ ] GetDiscountTransactions را پیدا کردم (یا متوجه شدم که وجود ندارد)

📝 STEP 2: تکمیل BFF - DiscountBalance

Task 2.1: اضافه کردن DiscountBalance به GetMyWallet

فایل موجود: FrontOffice.BFF.Application/UserWalletCQ/Queries/GetMyWallet/MyWalletResponseDto.cs

اگر DiscountBalance وجود ندارد، اضافه کن:

namespace FrontOffice.BFF.Application.UserWalletCQ.Queries.GetMyWallet;

public class MyWalletResponseDto
{
    // موجود:
    public decimal MainBalance { get; set; }
    public string MainBalanceFormatted { get; set; }
    
    public decimal RewardBalance { get; set; }
    public string RewardBalanceFormatted { get; set; }
    
    // اضافه کن:
    public decimal DiscountBalance { get; set; }
    public string DiscountBalanceFormatted { get; set; }
    
    // مجموع کل
    public decimal TotalBalance { get; set; }
    public string TotalBalanceFormatted { get; set; }
    
    // UI Helpers
    public bool HasDiscount { get; set; }           // آیا تخفیف دارد؟
    public string DiscountPercentage { get; set; }  // "15%" (اگر applicable)
}

آپدیت Handler:

// در GetMyWalletQueryHandler.cs
public async Task<MyWalletResponseDto> Handle(...)
{
    var userId = _currentUser.UserId;
    
    // TODO: Call CMS
    // var wallet = await _cmsClient.GetUserWalletAsync(new { UserId = userId });
    
    // Mock Data با DiscountBalance
    var mainBalance = 5000000m;
    var rewardBalance = 1200000m;
    var discountBalance = 800000m;  // اضافه شد
    var total = mainBalance + rewardBalance + discountBalance;
    
    return new MyWalletResponseDto
    {
        MainBalance = mainBalance,
        MainBalanceFormatted = mainBalance.ToString("N0") + " تومان",
        
        RewardBalance = rewardBalance,
        RewardBalanceFormatted = rewardBalance.ToString("N0") + " تومان",
        
        DiscountBalance = discountBalance,
        DiscountBalanceFormatted = discountBalance.ToString("N0") + " تومان",
        
        TotalBalance = total,
        TotalBalanceFormatted = total.ToString("N0") + " تومان",
        
        HasDiscount = discountBalance > 0,
        DiscountPercentage = "15%"
    };
}

Task 2.2: ایجاد Query جدید - GetMyDiscountTransactions

فایل 1: GetMyDiscountTransactionsQuery.cs

mkdir -p FrontOffice.BFF.Application/UserWalletCQ/Queries/GetMyDiscountTransactions/
nano GetMyDiscountTransactionsQuery.cs
using MediatR;

namespace FrontOffice.BFF.Application.UserWalletCQ.Queries.GetMyDiscountTransactions;

public record GetMyDiscountTransactionsQuery : IRequest<MyDiscountTransactionsResponseDto>
{
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
}

فایل 2: MyDiscountTransactionsResponseDto.cs

namespace FrontOffice.BFF.Application.UserWalletCQ.Queries.GetMyDiscountTransactions;

public class MyDiscountTransactionsResponseDto
{
    public List<DiscountTransactionItemDto> Transactions { get; set; }
    public int TotalCount { get; set; }
}

public class DiscountTransactionItemDto
{
    public long Id { get; set; }
    public string Type { get; set; }            // "دریافت" / "استفاده"
    public string TypeIcon { get; set; }        // "add_circle" / "remove_circle"
    public string TypeColor { get; set; }       // "success" / "error"
    public decimal Amount { get; set; }
    public string AmountFormatted { get; set; }
    public string Description { get; set; }     // "تخفیف خرید محصول X"
    public string DatePersian { get; set; }
}

فایل 3: GetMyDiscountTransactionsQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.UserWalletCQ.Queries.GetMyDiscountTransactions;

public class GetMyDiscountTransactionsQueryHandler 
    : IRequestHandler<GetMyDiscountTransactionsQuery, MyDiscountTransactionsResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public GetMyDiscountTransactionsQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyDiscountTransactionsResponseDto> Handle(
        GetMyDiscountTransactionsQuery request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        
        // Mock Data
        return new MyDiscountTransactionsResponseDto
        {
            Transactions = new List<DiscountTransactionItemDto>
            {
                new() {
                    Id = 1,
                    Type = "دریافت",
                    TypeIcon = "add_circle",
                    TypeColor = "success",
                    Amount = 500000,
                    AmountFormatted = "500,000 تومان",
                    Description = "تخفیف خرید بسته طلایی",
                    DatePersian = "20 آذر 1403"
                },
                new() {
                    Id = 2,
                    Type = "استفاده",
                    TypeIcon = "remove_circle",
                    TypeColor = "error",
                    Amount = -200000,
                    AmountFormatted = "200,000 تومان",
                    Description = "استفاده در خرید محصول A",
                    DatePersian = "22 آذر 1403"
                }
            },
            TotalCount = 2
        };
    }
}

Task 2.3: آپدیت Controller

فایل موجود: UserWalletController.cs

// اضافه کردن endpoint جدید
using FrontOffice.BFF.Application.UserWalletCQ.Queries.GetMyDiscountTransactions;

[HttpGet("my-discount-transactions")]
[ProducesResponseType(typeof(MyDiscountTransactionsResponseDto), 200)]
public async Task<IActionResult> GetMyDiscountTransactions(
    [FromQuery] int pageNumber = 1,
    [FromQuery] int pageSize = 10)
{
    var query = new GetMyDiscountTransactionsQuery 
    { 
        PageNumber = pageNumber, 
        PageSize = pageSize 
    };
    var result = await _mediator.Send(query);
    return Ok(result);
}

📝 STEP 3: تکمیل Withdrawal Handler

Task 3.1: پیدا کردن WithdrawalFromWallet Handler

find FrontOffice.BFF.Application/UserWalletCQ/ -name "*Withdrawal*"
# باید پیدا کنی: Commands/WithdrawalFromWallet/WithdrawalFromWalletCommandHandler.cs

Task 3.2: تکمیل Handler خالی

// فایل موجود: WithdrawalFromWalletCommandHandler.cs
using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.UserWalletCQ.Commands.WithdrawalFromWallet;

public class WithdrawalFromWalletCommandHandler 
    : IRequestHandler<WithdrawalFromWalletCommand, WithdrawalFromWalletResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly UserWalletServiceClient _cmsClient;
    
    public WithdrawalFromWalletCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<WithdrawalFromWalletResponseDto> Handle(
        WithdrawalFromWalletCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: فراخوانی CMS
        // var result = await _cmsClient.WithdrawalFromWalletAsync(new {
        //     UserId = userId,
        //     Amount = request.Amount,
        //     WalletType = request.WalletType  // Main / Reward / Discount
        // });
        
        // Mock Response
        return new WithdrawalFromWalletResponseDto
        {
            Success = true,
            TransactionId = 456,
            Message = "برداشت با موفقیت انجام شد",
            NewBalance = "4,500,000 تومان"
        };
    }
}

Task 3.3: اضافه کردن Validator

// فایل جدید: WithdrawalFromWalletCommandValidator.cs
using FluentValidation;

namespace FrontOffice.BFF.Application.UserWalletCQ.Commands.WithdrawalFromWallet;

public class WithdrawalFromWalletCommandValidator : AbstractValidator<WithdrawalFromWalletCommand>
{
    public WithdrawalFromWalletCommandValidator()
    {
        RuleFor(x => x.Amount)
            .GreaterThan(0).WithMessage("مبلغ باید بیشتر از صفر باشد")
            .LessThanOrEqualTo(10000000).WithMessage("حداکثر مبلغ برداشت 10 میلیون تومان است");
        
        RuleFor(x => x.WalletType)
            .NotEmpty().WithMessage("نوع کیف پول الزامی است")
            .Must(x => new[] { "Main", "Reward", "Discount" }.Contains(x))
            .WithMessage("نوع کیف پول نامعتبر است");
    }
}

📝 STEP 4: آپدیت UI - WalletPage

Task 4.1: اضافه کردن DiscountBalance به Service

// فایل موجود: FrontOffice.Main/Services/UserWalletService.cs
public async Task<MyDiscountTransactionsDto> GetMyDiscountTransactionsAsync(int pageNumber = 1)
{
    var response = await _httpClient.GetAsync(
        $"/api/userwallet/my-discount-transactions?pageNumber={pageNumber}");
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<MyDiscountTransactionsDto>();
}

Task 4.2: آپدیت WalletPage.razor

اضافه کردن Card برای DiscountBalance:

@page "/wallet"
@inject UserWalletService WalletService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">کیف پول من</MudText>
    
    @if (_loading)
    {
        <MudProgressLinear Indeterminate="true" />
    }
    else if (_wallet != null)
    {
        <MudGrid>
            <!-- موجودی اصلی -->
            <MudItem xs="12" md="4">
                <MudCard Style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
                    <MudCardContent>
                        <MudText Typo="Typo.h6">موجودی اصلی</MudText>
                        <MudText Typo="Typo.h4" Class="mt-2">
                            <strong>@_wallet.MainBalanceFormatted</strong>
                        </MudText>
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <!-- موجودی پاداش -->
            <MudItem xs="12" md="4">
                <MudCard Style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white;">
                    <MudCardContent>
                        <MudText Typo="Typo.h6">موجودی پاداش</MudText>
                        <MudText Typo="Typo.h4" Class="mt-2">
                            <strong>@_wallet.RewardBalanceFormatted</strong>
                        </MudText>
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <!-- موجودی تخفیف (جدید) -->
            <MudItem xs="12" md="4">
                <MudCard Style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white;">
                    <MudCardContent>
                        <MudText Typo="Typo.h6">موجودی تخفیف</MudText>
                        <MudText Typo="Typo.h4" Class="mt-2">
                            <strong>@_wallet.DiscountBalanceFormatted</strong>
                        </MudText>
                        @if (_wallet.HasDiscount)
                        {
                            <MudChip Size="Size.Small" Color="Color.Success" Class="mt-2">
                                @_wallet.DiscountPercentage تخفیف
                            </MudChip>
                        }
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <!-- مجموع کل -->
            <MudItem xs="12">
                <MudCard Elevation="5">
                    <MudCardContent>
                        <MudText Typo="Typo.h5" Align="Align.Center">
                            مجموع کل: <strong style="color: green;">@_wallet.TotalBalanceFormatted</strong>
                        </MudText>
                    </MudCardContent>
                </MudCard>
            </MudItem>
        </MudGrid>
        
        <!-- Tabs برای تراکنش‌ها -->
        <MudTabs Class="mt-6" ApplyEffectsToContainer="true" PanelClass="pa-4">
            <MudTabPanel Text="تراکنش‌های اصلی" Icon="@Icons.Material.Filled.Receipt">
                <!-- کد موجود برای Main Transactions -->
            </MudTabPanel>
            
            <MudTabPanel Text="تراکنش‌های پاداش" Icon="@Icons.Material.Filled.CardGiftcard">
                <!-- کد موجود برای Reward Transactions -->
            </MudTabPanel>
            
            <!-- Tab جدید برای DiscountTransactions -->
            <MudTabPanel Text="تراکنش‌های تخفیف" Icon="@Icons.Material.Filled.Discount">
                @if (_loadingDiscountTxs)
                {
                    <MudProgressLinear Indeterminate="true" />
                }
                else if (_discountTransactions != null)
                {
                    <MudTable Items="@_discountTransactions.Transactions" Hover="true">
                        <HeaderContent>
                            <MudTh>نوع</MudTh>
                            <MudTh>مبلغ</MudTh>
                            <MudTh>شرح</MudTh>
                            <MudTh>تاریخ</MudTh>
                        </HeaderContent>
                        <RowTemplate>
                            <MudTd>
                                <MudIcon Icon="@context.TypeIcon" 
                                        Color="@(context.TypeColor == "success" ? Color.Success : Color.Error)" />
                                @context.Type
                            </MudTd>
                            <MudTd>
                                <strong style="color: @(context.Amount > 0 ? "green" : "red")">
                                    @context.AmountFormatted
                                </strong>
                            </MudTd>
                            <MudTd>@context.Description</MudTd>
                            <MudTd>@context.DatePersian</MudTd>
                        </RowTemplate>
                    </MudTable>
                }
            </MudTabPanel>
        </MudTabs>
    }
</MudContainer>

@code {
    private MyWalletDto? _wallet;
    private MyDiscountTransactionsDto? _discountTransactions;
    private bool _loading = true;
    private bool _loadingDiscountTxs = true;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadWallet();
        await LoadDiscountTransactions();
    }
    
    private async Task LoadWallet()
    {
        try
        {
            _loading = true;
            _wallet = await WalletService.GetMyWalletAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
    
    private async Task LoadDiscountTransactions()
    {
        try
        {
            _loadingDiscountTxs = true;
            _discountTransactions = await WalletService.GetMyDiscountTransactionsAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا در بارگذاری تراکنش‌های تخفیف: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loadingDiscountTxs = false;
        }
    }
}

Checkpoint نهایی

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/
dotnet build

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/
dotnet build

چیزهایی که باید کار کنند:

[ ] GetMyWallet شامل DiscountBalance است
[ ] GetMyDiscountTransactions Query کار می‌کند
[ ] WithdrawalFromWallet Handler تکمیل شده
[ ] Validator برای Withdrawal اضافه شده
[ ] UI سه کارت موجودی نمایش می‌دهد
[ ] Tab جدید "تراکنش‌های تخفیف" کار می‌کند

📊 آماری از تکمیل UserWallet

مورد قبل بعد وضعیت
Queries 2 3 +1
Commands 2 2 Handler تکمیل شد
Validators 1 2 +1
UI Cards 2 3 +1
UI Tabs 2 3 +1
درصد تکمیل 60% 100% 🎉

زمان تخمینی: 2 ساعت


💡 نکات مهم

  1. DiscountBalance vs RewardBalance: تخفیف فقط در خرید استفاده می‌شود، پاداش قابل برداشت است
  2. Gradient Colors: از Linear Gradient برای Cards استفاده شد - زیباتر است
  3. Tabs Performance: از MudTabs استفاده کن - بهتر از Separate Pages
  4. Amount Sign: در DiscountTransactions مبلغ‌های منفی را با رنگ قرمز نشان بده
  5. Validator Registration: فراموش نکن Validator را در DI ثبت کنی

🛒 مرحله 6: تکمیل ShoppingCart (سبد خرید)

📊 وضعیت فعلی

موجود در BFF (50%):

  • GetMyCart Query
  • AddToCart Command
  • UpdateCartItemQuantity Command

غایب (50%):

  • ClearCart Command (پاک کردن کل سبد)
  • DeleteCartItem Command (حذف یک آیتم)
  • MergeGuestCart Command (ادغام سبد مهمان با سبد کاربر لاگین شده)
  • ApplyDiscount Command (اعمال کد تخفیف)

UI غایب:

  • دکمه "پاک کردن سبد"
  • دکمه "حذف" برای هر آیتم
  • فرم اعمال کد تخفیف

📝 STEP 1: بررسی CMS ShoppingCart

Task 1.1: بررسی Commands موجود

cd /home/masoud/Apps/project/FourSat/CMS/src/

ls CMSMicroservice.Application/ShoppingCartCQ/Commands/
# باید ببینی:
#   - AddToCart/ ✅
#   - UpdateCartItemQuantity/ ✅
#   - DeleteCartItem/ (چک کن وجود دارد؟)
#   - ClearCart/ (چک کن وجود دارد؟)
#   - MergeGuestCart/ (چک کن وجود دارد؟)
#   - ApplyDiscountCode/ (چک کن وجود دارد؟)

Task 1.2: بررسی DeleteCartItem در CMS

# اگر وجود داشت:
cat CMSMicroservice.Application/ShoppingCartCQ/Commands/DeleteCartItem/DeleteCartItemCommandHandler.cs
# Input:
#   - UserId
#   - CartItemId
# Logic:
#   - پیدا کردن CartItem
#   - حذف از دیتابیس
#   - به‌روزرسانی TotalPrice سبد

Task 1.3: بررسی ClearCart در CMS

# اگر وجود داشت:
cat CMSMicroservice.Application/ShoppingCartCQ/Commands/ClearCart/ClearCartCommandHandler.cs
# Input:
#   - UserId
# Logic:
#   - حذف همه CartItems کاربر
#   - TotalPrice = 0

📝 STEP 2: پیاده‌سازی Commands غایب در BFF

Task 2.1: Command - DeleteMyCartItem

فایل 1: DeleteMyCartItemCommand.cs

mkdir -p FrontOffice.BFF.Application/ShoppingCartCQ/Commands/DeleteMyCartItem/
nano DeleteMyCartItemCommand.cs
using MediatR;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.DeleteMyCartItem;

/// <summary>
/// حذف یک آیتم از سبد خرید من
/// </summary>
public record DeleteMyCartItemCommand : IRequest<DeleteMyCartItemResponseDto>
{
    public long CartItemId { get; init; }
}

فایل 2: DeleteMyCartItemResponseDto.cs

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.DeleteMyCartItem;

public class DeleteMyCartItemResponseDto
{
    public bool Success { get; set; }
    public string Message { get; set; }             // "آیتم با موفقیت حذف شد"
    public decimal NewTotalPrice { get; set; }
    public string NewTotalPriceFormatted { get; set; }
    public int RemainingItemsCount { get; set; }    // تعداد آیتم‌های باقی‌مانده
}

فایل 3: DeleteMyCartItemCommandHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.DeleteMyCartItem;

public class DeleteMyCartItemCommandHandler 
    : IRequestHandler<DeleteMyCartItemCommand, DeleteMyCartItemResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly ShoppingCartServiceClient _cmsClient;
    
    public DeleteMyCartItemCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<DeleteMyCartItemResponseDto> Handle(
        DeleteMyCartItemCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        // var result = await _cmsClient.DeleteCartItemAsync(new {
        //     UserId = userId,
        //     CartItemId = request.CartItemId
        // });
        
        // Mock Response
        return new DeleteMyCartItemResponseDto
        {
            Success = true,
            Message = "محصول از سبد خرید حذف شد",
            NewTotalPrice = 4500000,
            NewTotalPriceFormatted = "4,500,000 تومان",
            RemainingItemsCount = 2
        };
    }
}

فایل 4: DeleteMyCartItemCommandValidator.cs

using FluentValidation;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.DeleteMyCartItem;

public class DeleteMyCartItemCommandValidator : AbstractValidator<DeleteMyCartItemCommand>
{
    public DeleteMyCartItemCommandValidator()
    {
        RuleFor(x => x.CartItemId)
            .GreaterThan(0).WithMessage("شناسه آیتم نامعتبر است");
    }
}

Task 2.2: Command - ClearMyCart

فایل 1: ClearMyCartCommand.cs

mkdir -p FrontOffice.BFF.Application/ShoppingCartCQ/Commands/ClearMyCart/
nano ClearMyCartCommand.cs
using MediatR;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.ClearMyCart;

/// <summary>
/// پاک کردن کل سبد خرید من
/// </summary>
public record ClearMyCartCommand : IRequest<ClearMyCartResponseDto>
{
    // هیچ ورودی ندارد - UserId از Token می‌آید
}

فایل 2: ClearMyCartResponseDto.cs

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.ClearMyCart;

public class ClearMyCartResponseDto
{
    public bool Success { get; set; }
    public string Message { get; set; }  // "سبد خرید شما خالی شد"
    public int DeletedItemsCount { get; set; }
}

فایل 3: ClearMyCartCommandHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.ClearMyCart;

public class ClearMyCartCommandHandler 
    : IRequestHandler<ClearMyCartCommand, ClearMyCartResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public ClearMyCartCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<ClearMyCartResponseDto> Handle(
        ClearMyCartCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        // var result = await _cmsClient.ClearCartAsync(new { UserId = userId });
        
        return new ClearMyCartResponseDto
        {
            Success = true,
            Message = "سبد خرید شما با موفقیت خالی شد",
            DeletedItemsCount = 3
        };
    }
}

Task 2.3: Command - MergeGuestCart (اختیاری - پیچیده‌تر)

فایل 1: MergeGuestCartCommand.cs

mkdir -p FrontOffice.BFF.Application/ShoppingCartCQ/Commands/MergeGuestCart/
nano MergeGuestCartCommand.cs
using MediatR;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.MergeGuestCart;

/// <summary>
/// ادغام سبد مهمان با سبد کاربر لاگین شده
/// زمانی استفاده می‌شود که کاربر بدون لاگین خرید می‌کند و بعد لاگین می‌کند
/// </summary>
public record MergeGuestCartCommand : IRequest<MergeGuestCartResponseDto>
{
    public string GuestCartId { get; init; }  // GUID سبد مهمان (از LocalStorage)
}

فایل 2: MergeGuestCartResponseDto.cs

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.MergeGuestCart;

public class MergeGuestCartResponseDto
{
    public bool Success { get; set; }
    public string Message { get; set; }
    public int MergedItemsCount { get; set; }      // تعداد آیتم‌های ادغام شده
    public decimal NewTotalPrice { get; set; }
    public string NewTotalPriceFormatted { get; set; }
}

فایل 3: MergeGuestCartCommandHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.MergeGuestCart;

public class MergeGuestCartCommandHandler 
    : IRequestHandler<MergeGuestCartCommand, MergeGuestCartResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public MergeGuestCartCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MergeGuestCartResponseDto> Handle(
        MergeGuestCartCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        // Logic:
        //   1. دریافت سبد مهمان از GuestCartId
        //   2. دریافت سبد کاربر فعلی
        //   3. ادغام آیتم‌ها (اگر محصول تکراری بود، Quantity جمع شود)
        //   4. حذف سبد مهمان
        
        return new MergeGuestCartResponseDto
        {
            Success = true,
            Message = "سبد خرید شما با موفقیت ادغام شد",
            MergedItemsCount = 2,
            NewTotalPrice = 6500000,
            NewTotalPriceFormatted = "6,500,000 تومان"
        };
    }
}

فایل 4: MergeGuestCartCommandValidator.cs

using FluentValidation;

namespace FrontOffice.BFF.Application.ShoppingCartCQ.Commands.MergeGuestCart;

public class MergeGuestCartCommandValidator : AbstractValidator<MergeGuestCartCommand>
{
    public MergeGuestCartCommandValidator()
    {
        RuleFor(x => x.GuestCartId)
            .NotEmpty().WithMessage("شناسه سبد مهمان الزامی است")
            .Must(BeValidGuid).WithMessage("شناسه سبد نامعتبر است");
    }
    
    private bool BeValidGuid(string guestCartId)
    {
        return Guid.TryParse(guestCartId, out _);
    }
}

📝 STEP 3: آپدیت Controller

فایل موجود: ShoppingCartController.cs

اضافه کردن 3 endpoint جدید:

using FrontOffice.BFF.Application.ShoppingCartCQ.Commands.DeleteMyCartItem;
using FrontOffice.BFF.Application.ShoppingCartCQ.Commands.ClearMyCart;
using FrontOffice.BFF.Application.ShoppingCartCQ.Commands.MergeGuestCart;

/// <summary>
/// حذف یک آیتم از سبد خرید
/// </summary>
[HttpDelete("items/{cartItemId}")]
[ProducesResponseType(typeof(DeleteMyCartItemResponseDto), 200)]
public async Task<IActionResult> DeleteCartItem(long cartItemId)
{
    var command = new DeleteMyCartItemCommand { CartItemId = cartItemId };
    var result = await _mediator.Send(command);
    return Ok(result);
}

/// <summary>
/// پاک کردن کل سبد خرید
/// </summary>
[HttpDelete("clear")]
[ProducesResponseType(typeof(ClearMyCartResponseDto), 200)]
public async Task<IActionResult> ClearCart()
{
    var command = new ClearMyCartCommand();
    var result = await _mediator.Send(command);
    return Ok(result);
}

/// <summary>
/// ادغام سبد مهمان
/// </summary>
[HttpPost("merge-guest")]
[ProducesResponseType(typeof(MergeGuestCartResponseDto), 200)]
public async Task<IActionResult> MergeGuestCart([FromBody] MergeGuestCartCommand command)
{
    var result = await _mediator.Send(command);
    return Ok(result);
}

Test Endpoints:

# Test 1: Delete Item
curl -X DELETE \
     -H "Authorization: Bearer TOKEN" \
     http://localhost:5002/api/shoppingcart/items/123

# Test 2: Clear Cart
curl -X DELETE \
     -H "Authorization: Bearer TOKEN" \
     http://localhost:5002/api/shoppingcart/clear

# Test 3: Merge Guest Cart
curl -X POST \
     -H "Authorization: Bearer TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"guestCartId": "550e8400-e29b-41d4-a716-446655440000"}' \
     http://localhost:5002/api/shoppingcart/merge-guest

📝 STEP 4: آپدیت UI - CartPage

Task 4.1: اضافه کردن متدها به Service

// فایل موجود: FrontOffice.Main/Services/ShoppingCartService.cs

public async Task<DeleteCartItemResultDto> DeleteCartItemAsync(long cartItemId)
{
    var response = await _httpClient.DeleteAsync($"/api/shoppingcart/items/{cartItemId}");
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<DeleteCartItemResultDto>();
}

public async Task<ClearCartResultDto> ClearCartAsync()
{
    var response = await _httpClient.DeleteAsync("/api/shoppingcart/clear");
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<ClearCartResultDto>();
}

public async Task<MergeGuestCartResultDto> MergeGuestCartAsync(string guestCartId)
{
    var request = new { GuestCartId = guestCartId };
    var response = await _httpClient.PostAsJsonAsync("/api/shoppingcart/merge-guest", request);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<MergeGuestCartResultDto>();
}

Task 4.2: آپدیت CartPage.razor

اضافه کردن دکمه‌های حذف:

@page "/cart"
@inject ShoppingCartService CartService
@inject ISnackbar Snackbar
@inject IDialogService DialogService

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudGrid>
        <MudItem xs="12">
            <MudText Typo="Typo.h4" Class="mb-4">سبد خرید من</MudText>
        </MudItem>
        
        @if (_loading)
        {
            <MudProgressLinear Indeterminate="true" />
        }
        else if (_cart != null && _cart.Items.Any())
        {
            <MudItem xs="12" md="8">
                <MudCard>
                    <MudCardHeader>
                        <CardHeaderContent>
                            <MudText Typo="Typo.h6">
                                محصولات (@_cart.TotalItemsCount مورد)
                            </MudText>
                        </CardHeaderContent>
                        <CardHeaderActions>
                            <MudButton StartIcon="@Icons.Material.Filled.DeleteSweep"
                                     Color="Color.Error"
                                     Variant="Variant.Text"
                                     OnClick="ClearCartWithConfirm">
                                پاک کردن سبد
                            </MudButton>
                        </CardHeaderActions>
                    </MudCardHeader>
                    <MudCardContent>
                        @foreach (var item in _cart.Items)
                        {
                            <MudPaper Class="pa-3 mb-3" Elevation="2">
                                <MudGrid>
                                    <MudItem xs="12" md="6">
                                        <MudText Typo="Typo.h6">@item.ProductName</MudText>
                                        <MudText Typo="Typo.body2" Color="Color.Secondary">
                                            @item.ProductDescription
                                        </MudText>
                                    </MudItem>
                                    <MudItem xs="6" md="2">
                                        <MudNumericField @bind-Value="item.Quantity"
                                                       Label="تعداد"
                                                       Min="1"
                                                       Max="10"
                                                       Variant="Variant.Outlined"
                                                       OnChange="() => UpdateQuantity(item.CartItemId, item.Quantity)" />
                                    </MudItem>
                                    <MudItem xs="6" md="2">
                                        <MudText Typo="Typo.body1">
                                            <strong>@item.TotalPriceFormatted</strong>
                                        </MudText>
                                    </MudItem>
                                    <MudItem xs="12" md="2">
                                        <MudButton StartIcon="@Icons.Material.Filled.Delete"
                                                 Color="Color.Error"
                                                 Variant="Variant.Text"
                                                 FullWidth="true"
                                                 OnClick="() => DeleteItem(item.CartItemId)">
                                            حذف
                                        </MudButton>
                                    </MudItem>
                                </MudGrid>
                            </MudPaper>
                        }
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <MudItem xs="12" md="4">
                <MudCard>
                    <MudCardHeader>
                        <CardHeaderContent>
                            <MudText Typo="Typo.h6">خلاصه سبد خرید</MudText>
                        </CardHeaderContent>
                    </MudCardHeader>
                    <MudCardContent>
                        <MudStack Spacing="2">
                            <MudText>
                                <strong>تعداد کل:</strong> @_cart.TotalItemsCount مورد
                            </MudText>
                            <MudText>
                                <strong>قیمت کل:</strong> 
                                <span style="color: green; font-size: 1.2em;">
                                    @_cart.TotalPriceFormatted
                                </span>
                            </MudText>
                            <MudDivider />
                            <MudButton Variant="Variant.Filled"
                                     Color="Color.Primary"
                                     FullWidth="true"
                                     StartIcon="@Icons.Material.Filled.ShoppingCart">
                                تکمیل خرید
                            </MudButton>
                        </MudStack>
                    </MudCardContent>
                </MudCard>
            </MudItem>
        }
        else
        {
            <MudItem xs="12">
                <MudAlert Severity="Severity.Info">
                    سبد خرید شما خالی است
                </MudAlert>
            </MudItem>
        }
    </MudGrid>
</MudContainer>

@code {
    private MyCartDto? _cart;
    private bool _loading = true;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadCart();
    }
    
    private async Task LoadCart()
    {
        try
        {
            _loading = true;
            _cart = await CartService.GetMyCartAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
    
    private async Task UpdateQuantity(long cartItemId, int newQuantity)
    {
        try
        {
            await CartService.UpdateCartItemQuantityAsync(cartItemId, newQuantity);
            Snackbar.Add("تعداد به‌روزرسانی شد", Severity.Success);
            await LoadCart();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
    }
    
    private async Task DeleteItem(long cartItemId)
    {
        bool? confirm = await DialogService.ShowMessageBox(
            "تایید حذف",
            "آیا از حذف این محصول اطمینان دارید؟",
            yesText: "بله", cancelText: "خیر");
        
        if (confirm == true)
        {
            try
            {
                var result = await CartService.DeleteCartItemAsync(cartItemId);
                Snackbar.Add(result.Message, Severity.Success);
                await LoadCart();
            }
            catch (Exception ex)
            {
                Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
            }
        }
    }
    
    private async Task ClearCartWithConfirm()
    {
        bool? confirm = await DialogService.ShowMessageBox(
            "پاک کردن سبد",
            "آیا از پاک کردن کل سبد خرید اطمینان دارید؟",
            yesText: "بله، پاک کن", cancelText: "خیر");
        
        if (confirm == true)
        {
            try
            {
                var result = await CartService.ClearCartAsync();
                Snackbar.Add(result.Message, Severity.Success);
                await LoadCart();
            }
            catch (Exception ex)
            {
                Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
            }
        }
    }
}

Checkpoint نهایی

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/
dotnet build

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/
dotnet build

چیزهایی که باید کار کنند:

[ ] DeleteMyCartItem Command کار می‌کند
[ ] ClearMyCart Command کار می‌کند
[ ] MergeGuestCart Command کار می‌کند
[ ] 3 Validator اضافه شده
[ ] 3 endpoint جدید در Controller
[ ] UI دکمه "حذف" برای هر آیتم دارد
[ ] UI دکمه "پاک کردن سبد" دارد
[ ] Confirmation Dialog نمایش داده می‌شود

📊 آماری از تکمیل ShoppingCart

مورد قبل بعد وضعیت
Commands 3 6 +3
Validators 1 4 +3
Controller Endpoints 3 6 +3
UI Delete Button
UI Clear Button
Confirmation Dialogs
درصد تکمیل 50% 100% 🎉

زمان تخمینی: 3 ساعت


💡 نکات بسیار مهم

  1. Confirmation Dialog: همیشه قبل از حذف از کاربر تایید بگیر (UX بهتر)
  2. DeleteCartItem vs ClearCart: Delete یک آیتم حذف می‌کند، Clear همه را پاک می‌کند
  3. MergeGuestCart: این قابلیت برای زمانی است که کاربر بدون لاگین خرید کرده و بعد لاگین کند
  4. LocalStorage: سبد مهمان در LocalStorage ذخیره شود (GuestCartId = Guid)
  5. Quantity Update: بلافاصله بعد از تغییر Quantity، Cart را reload کن
  6. HTTP Methods: Delete → DeleteAsync, Clear → DeleteAsync, Merge → PostAsync
  7. Icon Usage: از DeleteSweep برای Clear و Delete برای DeleteItem استفاده کن

💳 مرحله 7: DayaLoan UI + Contract Completion

📊 خلاصه این مرحله

دو کار اصلی:

  1. DayaLoan UI: نمایش وضعیت وام دایا برای مشتری (Backend در CMS آماده است)
  2. Contract Completion: تکمیل Queries غایب در Contract Module

بخش اول: DayaLoan - نمایش وضعیت وام

📝 STEP 1: بررسی DayaLoan در CMS

Task 1.1: بررسی موجودی‌ها

cd /home/masoud/Apps/project/FourSat/CMS/src/

# بررسی Entity
cat CMSMicroservice.Domain/Entities/DayaLoanContract.cs
# باید ببینی:
#   - NationalCode: کد ملی
#   - LoanStatus: PendingReceive / Approved / Rejected
#   - ContractNumber: شماره قرارداد (بعد از تایید)
#   - RequestAmount: 56,000,000 (برای هر کیف پول)
#   - LastCheckDate: آخرین بار استعلام
#   - ApprovalDate: تاریخ تایید

# بررسی Commands
ls CMSMicroservice.Application/DayaLoanCQ/Commands/
# باید ببینی:
#   - CheckDayaLoanStatus/ ✅
#   - ProcessDayaLoanApproval/ ✅

# بررسی Worker
find . -name "*DayaLoanWorker*"
# Worker که هر 15 دقیقه استعلام می‌کند

Output Task 1.1:

[ ] DayaLoanContract Entity را بررسی کردم
[ ] CheckDayaLoanStatus Command را دیدم
[ ] ProcessDayaLoanApproval Command را دیدم
[ ] Worker را پیدا کردم

📝 STEP 2: ایجاد BFF Module - DayaLoanCQ

Task 2.1: ساخت فولدرها

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/FrontOffice.BFF.Application/

mkdir -p DayaLoanCQ/Queries/GetMyDayaLoanStatus
mkdir -p DayaLoanCQ/Commands/RequestDayaLoanCheck

tree DayaLoanCQ/

Expected Output:

DayaLoanCQ/
├── Commands/
│   └── RequestDayaLoanCheck/
└── Queries/
    └── GetMyDayaLoanStatus/

Task 2.2: Query - GetMyDayaLoanStatus

فایل 1: GetMyDayaLoanStatusQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.DayaLoanCQ.Queries.GetMyDayaLoanStatus;

/// <summary>
/// دریافت وضعیت وام دایا برای کاربر جاری
/// </summary>
public record GetMyDayaLoanStatusQuery : IRequest<MyDayaLoanStatusResponseDto>
{
}

فایل 2: MyDayaLoanStatusResponseDto.cs

namespace FrontOffice.BFF.Application.DayaLoanCQ.Queries.GetMyDayaLoanStatus;

public class MyDayaLoanStatusResponseDto
{
    public bool HasActiveLoan { get; set; }           // آیا وام فعال دارد؟
    public string Status { get; set; }                // "در انتظار دریافت" / "تایید شده" / "رد شده"
    public string StatusColor { get; set; }           // "warning" / "success" / "error"
    public string StatusIcon { get; set; }            // Icon name
    
    public string NationalCode { get; set; }
    public decimal RequestAmount { get; set; }        // 56,000,000
    public string RequestAmountFormatted { get; set; }
    
    public string ContractNumber { get; set; }        // شماره قرارداد (اگر تایید شده)
    public string LastCheckDatePersian { get; set; }  // آخرین استعلام
    public string ApprovalDatePersian { get; set; }   // تاریخ تایید (اگر تایید شده)
    
    public bool CanRequestCheck { get; set; }         // آیا می‌تواند درخواست استعلام دهد؟
    public string NextCheckAvailable { get; set; }    // "امکان استعلام بعد از 1 ساعت"
    
    // برای نمایش جزئیات شارژ کیف پول
    public List<WalletChargeDto> WalletCharges { get; set; }
}

public class WalletChargeDto
{
    public string WalletType { get; set; }            // "Main" / "Reward" / "Discount"
    public string WalletTypePersian { get; set; }     // "کیف پول اصلی"
    public decimal Amount { get; set; }               // 56,000,000
    public string AmountFormatted { get; set; }
    public bool IsCharged { get; set; }               // آیا شارژ شده؟
    public string ChargedDatePersian { get; set; }
}

فایل 3: GetMyDayaLoanStatusQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.DayaLoanCQ.Queries.GetMyDayaLoanStatus;

public class GetMyDayaLoanStatusQueryHandler 
    : IRequestHandler<GetMyDayaLoanStatusQuery, MyDayaLoanStatusResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    // TODO: private readonly DayaLoanServiceClient _cmsClient;
    
    public GetMyDayaLoanStatusQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyDayaLoanStatusResponseDto> Handle(
        GetMyDayaLoanStatusQuery request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        // var result = await _cmsClient.GetDayaLoanStatusAsync(new { UserId = userId });
        
        // Mock Data - وضعیت "تایید شده"
        return new MyDayaLoanStatusResponseDto
        {
            HasActiveLoan = true,
            Status = "تایید شده",
            StatusColor = "success",
            StatusIcon = "check_circle",
            
            NationalCode = "1234567890",
            RequestAmount = 56000000,
            RequestAmountFormatted = "56,000,000 تومان",
            
            ContractNumber = "DL-1403-001234",
            LastCheckDatePersian = "25 آذر 1403",
            ApprovalDatePersian = "25 آذر 1403",
            
            CanRequestCheck = false,
            NextCheckAvailable = "وام شما قبلاً تایید شده است",
            
            WalletCharges = new List<WalletChargeDto>
            {
                new() {
                    WalletType = "Main",
                    WalletTypePersian = "کیف پول اصلی",
                    Amount = 56000000,
                    AmountFormatted = "56,000,000 تومان",
                    IsCharged = true,
                    ChargedDatePersian = "25 آذر 1403"
                },
                new() {
                    WalletType = "Reward",
                    WalletTypePersian = "کیف پول پاداش",
                    Amount = 56000000,
                    AmountFormatted = "56,000,000 تومان",
                    IsCharged = true,
                    ChargedDatePersian = "25 آذر 1403"
                },
                new() {
                    WalletType = "Discount",
                    WalletTypePersian = "کیف پول تخفیف",
                    Amount = 56000000,
                    AmountFormatted = "56,000,000 تومان",
                    IsCharged = true,
                    ChargedDatePersian = "25 آذر 1403"
                }
            }
        };
    }
}

Task 2.3: Command - RequestDayaLoanCheck (اختیاری)

فایل 1: RequestDayaLoanCheckCommand.cs

using MediatR;

namespace FrontOffice.BFF.Application.DayaLoanCQ.Commands.RequestDayaLoanCheck;

/// <summary>
/// درخواست استعلام فوری وضعیت وام دایا
/// معمولاً Worker این کار را انجام می‌دهد، اما کاربر می‌تواند استعلام فوری بزند
/// </summary>
public record RequestDayaLoanCheckCommand : IRequest<RequestDayaLoanCheckResponseDto>
{
}

فایل 2: RequestDayaLoanCheckResponseDto.cs

namespace FrontOffice.BFF.Application.DayaLoanCQ.Commands.RequestDayaLoanCheck;

public class RequestDayaLoanCheckResponseDto
{
    public bool Success { get; set; }
    public string Message { get; set; }    // "استعلام با موفقیت انجام شد"
    public string NewStatus { get; set; }  // وضعیت جدید
}

فایل 3: RequestDayaLoanCheckCommandHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.DayaLoanCQ.Commands.RequestDayaLoanCheck;

public class RequestDayaLoanCheckCommandHandler 
    : IRequestHandler<RequestDayaLoanCheckCommand, RequestDayaLoanCheckResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public RequestDayaLoanCheckCommandHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<RequestDayaLoanCheckResponseDto> Handle(
        RequestDayaLoanCheckCommand request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS CheckDayaLoanStatus Command
        
        return new RequestDayaLoanCheckResponseDto
        {
            Success = true,
            Message = "استعلام وضعیت وام با موفقیت انجام شد. نتیجه در صفحه نمایش داده می‌شود.",
            NewStatus = "در انتظار دریافت"
        };
    }
}

📝 STEP 3: Controller - DayaLoanController

فایل جدید: DayaLoanController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using FrontOffice.BFF.Application.DayaLoanCQ.Queries.GetMyDayaLoanStatus;
using FrontOffice.BFF.Application.DayaLoanCQ.Commands.RequestDayaLoanCheck;

namespace FrontOffice.BFF.WebApi.Controllers;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DayaLoanController : ControllerBase
{
    private readonly IMediator _mediator;
    
    public DayaLoanController(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    /// <summary>
    /// دریافت وضعیت وام دایا من
    /// </summary>
    [HttpGet("my-status")]
    [ProducesResponseType(typeof(MyDayaLoanStatusResponseDto), 200)]
    public async Task<IActionResult> GetMyStatus()
    {
        var query = new GetMyDayaLoanStatusQuery();
        var result = await _mediator.Send(query);
        return Ok(result);
    }
    
    /// <summary>
    /// درخواست استعلام فوری
    /// </summary>
    [HttpPost("request-check")]
    [ProducesResponseType(typeof(RequestDayaLoanCheckResponseDto), 200)]
    public async Task<IActionResult> RequestCheck()
    {
        var command = new RequestDayaLoanCheckCommand();
        var result = await _mediator.Send(command);
        return Ok(result);
    }
}

📝 STEP 4: UI - DayaLoanPage

Task 4.1: Service

// فایل جدید: FrontOffice.Main/Services/DayaLoanService.cs
using System.Net.Http.Json;
using FrontOffice.Main.Models;

namespace FrontOffice.Main.Services;

public class DayaLoanService
{
    private readonly HttpClient _httpClient;
    
    public DayaLoanService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<MyDayaLoanStatusDto> GetMyStatusAsync()
    {
        var response = await _httpClient.GetAsync("/api/dayaloan/my-status");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<MyDayaLoanStatusDto>();
    }
    
    public async Task<RequestCheckResultDto> RequestCheckAsync()
    {
        var response = await _httpClient.PostAsync("/api/dayaloan/request-check", null);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<RequestCheckResultDto>();
    }
}

ثبت در Program.cs:

builder.Services.AddScoped<DayaLoanService>();

Task 4.2: Page - DayaLoanStatusPage.razor

mkdir -p FrontOffice/src/FrontOffice.Main/Pages/Loan/
nano DayaLoanStatusPage.razor
@page "/loan/daya-status"
@inject DayaLoanService LoanService
@inject ISnackbar Snackbar

<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
    <MudText Typo="Typo.h4" Class="mb-4">وضعیت وام دایا</MudText>
    
    @if (_loading)
    {
        <MudProgressLinear Indeterminate="true" />
    }
    else if (_status != null)
    {
        <MudGrid>
            <!-- کارت وضعیت اصلی -->
            <MudItem xs="12" md="6">
                <MudCard Elevation="5">
                    <MudCardHeader Style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
                        <CardHeaderContent>
                            <MudText Typo="Typo.h5">وضعیت درخواست</MudText>
                        </CardHeaderContent>
                        <CardHeaderActions>
                            <MudIcon Icon="@_status.StatusIcon" Size="Size.Large" />
                        </CardHeaderActions>
                    </MudCardHeader>
                    <MudCardContent>
                        <MudStack Spacing="3">
                            <MudAlert Severity="@GetSeverity(_status.StatusColor)" Dense="true">
                                <strong>@_status.Status</strong>
                            </MudAlert>
                            
                            <MudText>
                                <strong>کد ملی:</strong> @_status.NationalCode
                            </MudText>
                            
                            <MudText>
                                <strong>مبلغ درخواستی (هر کیف پول):</strong> 
                                <span style="color: green; font-size: 1.1em;">
                                    @_status.RequestAmountFormatted
                                </span>
                            </MudText>
                            
                            @if (!string.IsNullOrEmpty(_status.ContractNumber))
                            {
                                <MudText>
                                    <strong>شماره قرارداد:</strong> 
                                    <MudChip Size="Size.Small" Color="Color.Info">
                                        @_status.ContractNumber
                                    </MudChip>
                                </MudText>
                            }
                            
                            <MudText>
                                <strong>آخرین استعلام:</strong> @_status.LastCheckDatePersian
                            </MudText>
                            
                            @if (!string.IsNullOrEmpty(_status.ApprovalDatePersian))
                            {
                                <MudText>
                                    <strong>تاریخ تایید:</strong> @_status.ApprovalDatePersian
                                </MudText>
                            }
                        </MudStack>
                    </MudCardContent>
                    
                    @if (_status.CanRequestCheck)
                    {
                        <MudCardActions>
                            <MudButton Variant="Variant.Filled"
                                     Color="Color.Primary"
                                     StartIcon="@Icons.Material.Filled.Refresh"
                                     Disabled="_checking"
                                     OnClick="RequestCheck">
                                @if (_checking)
                                {
                                    <MudProgressCircular Size="Size.Small" Indeterminate="true" />
                                    <span class="ms-2">در حال استعلام...</span>
                                }
                                else
                                {
                                    <span>استعلام فوری</span>
                                }
                            </MudButton>
                        </MudCardActions>
                    }
                    else
                    {
                        <MudCardActions>
                            <MudText Typo="Typo.caption" Color="Color.Secondary">
                                @_status.NextCheckAvailable
                            </MudText>
                        </MudCardActions>
                    }
                </MudCard>
            </MudItem>
            
            <!-- کارت جزئیات شارژ کیف پول‌ها -->
            <MudItem xs="12" md="6">
                <MudCard Elevation="5">
                    <MudCardHeader>
                        <CardHeaderContent>
                            <MudText Typo="Typo.h6">جزئیات شارژ کیف پول‌ها</MudText>
                        </CardHeaderContent>
                    </MudCardHeader>
                    <MudCardContent>
                        @if (_status.WalletCharges != null && _status.WalletCharges.Any())
                        {
                            <MudStack Spacing="3">
                                @foreach (var wallet in _status.WalletCharges)
                                {
                                    <MudPaper Class="pa-3" Elevation="2">
                                        <MudGrid>
                                            <MudItem xs="8">
                                                <MudText Typo="Typo.body1">
                                                    <strong>@wallet.WalletTypePersian</strong>
                                                </MudText>
                                                <MudText Typo="Typo.body2" Color="Color.Success">
                                                    @wallet.AmountFormatted
                                                </MudText>
                                            </MudItem>
                                            <MudItem xs="4" Class="d-flex align-center justify-end">
                                                @if (wallet.IsCharged)
                                                {
                                                    <MudIcon Icon="@Icons.Material.Filled.CheckCircle" 
                                                           Color="Color.Success" 
                                                           Size="Size.Large" />
                                                }
                                                else
                                                {
                                                    <MudIcon Icon="@Icons.Material.Filled.HourglassEmpty" 
                                                           Color="Color.Warning" 
                                                           Size="Size.Large" />
                                                }
                                            </MudItem>
                                        </MudGrid>
                                        @if (wallet.IsCharged)
                                        {
                                            <MudText Typo="Typo.caption" Color="Color.Secondary">
                                                شارژ شده در: @wallet.ChargedDatePersian
                                            </MudText>
                                        }
                                    </MudPaper>
                                }
                                
                                <MudDivider />
                                
                                <MudText Typo="Typo.h6" Align="Align.Center">
                                    مجموع کل شارژ: 
                                    <strong style="color: green;">
                                        @((56000000m * 3).ToString("N0")) تومان
                                    </strong>
                                </MudText>
                            </MudStack>
                        }
                        else
                        {
                            <MudAlert Severity="Severity.Info">
                                هنوز کیف پولی شارژ نشده است
                            </MudAlert>
                        }
                    </MudCardContent>
                </MudCard>
            </MudItem>
            
            <!-- راهنما -->
            <MudItem xs="12">
                <MudCard>
                    <MudCardContent>
                        <MudText Typo="Typo.h6" Class="mb-2">
                            <MudIcon Icon="@Icons.Material.Filled.Info" /> راهنما
                        </MudText>
                        <MudList Dense="true">
                            <MudListItem Icon="@Icons.Material.Filled.Circle" IconSize="Size.Small">
                                وام دایا برای هر کیف پول (اصلی، پاداش، تخفیف) به مبلغ 56 میلیون تومان است
                            </MudListItem>
                            <MudListItem Icon="@Icons.Material.Filled.Circle" IconSize="Size.Small">
                                سیستم هر 15 دقیقه یکبار وضعیت وام شما را بررسی می‌کند
                            </MudListItem>
                            <MudListItem Icon="@Icons.Material.Filled.Circle" IconSize="Size.Small">
                                در صورت تایید، کیف پول‌های شما به صورت خودکار شارژ خواهند شد
                            </MudListItem>
                        </MudList>
                    </MudCardContent>
                </MudCard>
            </MudItem>
        </MudGrid>
    }
</MudContainer>

@code {
    private MyDayaLoanStatusDto? _status;
    private bool _loading = true;
    private bool _checking = false;
    
    protected override async Task OnInitializedAsync()
    {
        await LoadStatus();
    }
    
    private async Task LoadStatus()
    {
        try
        {
            _loading = true;
            _status = await LoanService.GetMyStatusAsync();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _loading = false;
        }
    }
    
    private async Task RequestCheck()
    {
        try
        {
            _checking = true;
            var result = await LoanService.RequestCheckAsync();
            Snackbar.Add(result.Message, Severity.Success);
            
            // Reload status after 2 seconds
            await Task.Delay(2000);
            await LoadStatus();
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _checking = false;
        }
    }
    
    private Severity GetSeverity(string color)
    {
        return color switch
        {
            "success" => Severity.Success,
            "warning" => Severity.Warning,
            "error" => Severity.Error,
            "info" => Severity.Info,
            _ => Severity.Normal
        };
    }
}

Task 4.3: اضافه کردن به NavMenu

<MudNavLink Href="/loan/daya-status" Icon="@Icons.Material.Filled.AccountBalance">
    وام دایا
</MudNavLink>

بخش دوم: Contract Completion

📝 STEP 5: تکمیل Contract Module

وضعیت فعلی (80%):

  • CreateContract Command
  • UpdateContract Command
  • GetContract Query (غایب)
  • GetAllMyContracts Query (غایب)

Task 5.1: Query - GetMyContract

فایل 1: GetMyContractQuery.cs

mkdir -p FrontOffice.BFF.Application/ContractCQ/Queries/GetMyContract/
nano GetMyContractQuery.cs
using MediatR;

namespace FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContract;

public record GetMyContractQuery : IRequest<MyContractResponseDto>
{
    public long ContractId { get; init; }
}

فایل 2: MyContractResponseDto.cs

namespace FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContract;

public class MyContractResponseDto
{
    public long Id { get; set; }
    public string ContractNumber { get; set; }
    public string Type { get; set; }                 // "خرید" / "عضویت" / "وام"
    public string Status { get; set; }               // "فعال" / "غیرفعال" / "منقضی"
    public string StatusColor { get; set; }
    
    public decimal TotalAmount { get; set; }
    public string TotalAmountFormatted { get; set; }
    
    public string StartDatePersian { get; set; }
    public string EndDatePersian { get; set; }
    
    public string Description { get; set; }
    public string Terms { get; set; }                // شرایط قرارداد
}

فایل 3: GetMyContractQueryHandler.cs

using MediatR;
using FrontOffice.BFF.Application.Common.Interfaces;

namespace FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContract;

public class GetMyContractQueryHandler 
    : IRequestHandler<GetMyContractQuery, MyContractResponseDto>
{
    private readonly ICurrentUserService _currentUser;
    
    public GetMyContractQueryHandler(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }
    
    public async Task<MyContractResponseDto> Handle(
        GetMyContractQuery request,
        CancellationToken cancellationToken)
    {
        var userId = _currentUser.UserId;
        
        // TODO: Call CMS
        
        return new MyContractResponseDto
        {
            Id = request.ContractId,
            ContractNumber = "CNT-1403-001234",
            Type = "خرید محصول",
            Status = "فعال",
            StatusColor = "success",
            TotalAmount = 5000000,
            TotalAmountFormatted = "5,000,000 تومان",
            StartDatePersian = "1 آذر 1403",
            EndDatePersian = "1 آذر 1404",
            Description = "قرارداد خرید بسته طلایی",
            Terms = "شرایط و قوانین قرارداد..."
        };
    }
}

Task 5.2: Query - GetMyContracts

فایل 1: GetMyContractsQuery.cs

using MediatR;

namespace FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContracts;

public record GetMyContractsQuery : IRequest<MyContractsResponseDto>
{
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
}

فایل 2: MyContractsResponseDto.cs

namespace FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContracts;

public class MyContractsResponseDto
{
    public List<ContractItemDto> Contracts { get; set; }
    public int TotalCount { get; set; }
}

public class ContractItemDto
{
    public long Id { get; set; }
    public string ContractNumber { get; set; }
    public string Type { get; set; }
    public string Status { get; set; }
    public string StatusColor { get; set; }
    public string TotalAmountFormatted { get; set; }
    public string StartDatePersian { get; set; }
}

Task 5.3: آپدیت ContractController

using FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContract;
using FrontOffice.BFF.Application.ContractCQ.Queries.GetMyContracts;

[HttpGet("{contractId}")]
[ProducesResponseType(typeof(MyContractResponseDto), 200)]
public async Task<IActionResult> GetContract(long contractId)
{
    var query = new GetMyContractQuery { ContractId = contractId };
    var result = await _mediator.Send(query);
    return Ok(result);
}

[HttpGet("my-contracts")]
[ProducesResponseType(typeof(MyContractsResponseDto), 200)]
public async Task<IActionResult> GetMyContracts(
    [FromQuery] int pageNumber = 1,
    [FromQuery] int pageSize = 10)
{
    var query = new GetMyContractsQuery { PageNumber = pageNumber, PageSize = pageSize };
    var result = await _mediator.Send(query);
    return Ok(result);
}

Checkpoint نهایی

cd /home/masoud/Apps/project/FourSat/FrontOffice.BFF/src/
dotnet build

cd /home/masoud/Apps/project/FourSat/FrontOffice/src/
dotnet build

چیزهایی که باید کار کنند:

[ ] DayaLoan: GetMyDayaLoanStatus Query کار می‌کند
[ ] DayaLoan: RequestDayaLoanCheck Command کار می‌کند
[ ] DayaLoan: Controller با 2 endpoint
[ ] DayaLoan: UI صفحه کامل با نمایش 3 کیف پول
[ ] Contract: GetMyContract Query کار می‌کند
[ ] Contract: GetMyContracts Query کار می‌کند
[ ] Contract: Controller آپدیت شد

📊 آماری از مرحله 7

ماژول Queries قبل Queries بعد Commands قبل Commands بعد وضعیت
DayaLoan 0 1 0 1 100%
Contract 0 2 2 2 100%

زمان تخمینی: 4 ساعت


💡 نکات مهم

  1. Worker: Worker در CMS هر 15 دقیقه استعلام می‌کند - کاربر نباید بیش از حد استعلام فوری بزند
  2. 168M Total: 56M × 3 کیف پول = 168 میلیون تومان کل شارژ
  3. Status Icons: از Icons.Material.Filled استفاده کن برای نمایش بهتر
  4. Gradient Background: برای کارت وضعیت از Gradient استفاده شد
  5. Contract Module: فقط 2 Query اضافه شد تا 100% شود

🔍 مرحله 8: بررسی نهایی - فقط کارهای ناتمام قبلی (بدون فیچرهای جدید)

⚠️ تذکر مهم

این مرحله فقط روی قابلیت‌هایی تمرکز دارد که:

  1. در CMS از قبل موجود است
  2. در FrontOffice.BFF یا FrontOffice پیاده‌سازی نشده
  3. 🎯 مختص مشتری است (نه Admin)

حذف شده از لیست:

  • DayaLoan (فیچر جدید - هنوز در CMS کامل نیست)
  • Manual Payment (فیچر Admin)
  • ClubMembership Admin Commands (مثل Deactivate, AssignFeature)

📝 STEP 1: بررسی دقیق CMS vs BFF

Task 1.1: مقایسه Commands/Queries موجود

cd /home/masoud/Apps/project/FourSat

# بررسی CMS Modules
echo "=== CMS Modules ===" > /tmp/cms_modules.txt
find CMS/src/CMSMicroservice.Application -type d -name "*CQ" | grep -v "bin\|obj" | sort >> /tmp/cms_modules.txt

# بررسی BFF Modules
echo "=== BFF Modules ===" > /tmp/bff_modules.txt
find FrontOffice.BFF/src/FrontOffice.BFF.Application -type d -name "*CQ" | grep -v "bin\|obj" | sort >> /tmp/bff_modules.txt

# مقایسه
echo "=== Comparison ===" > /tmp/comparison.txt
comm -3 <(find CMS/src/CMSMicroservice.Application -type d -name "*CQ" | xargs -I {} basename {} | sort -u) \
        <(find FrontOffice.BFF/src/FrontOffice.BFF.Application -type d -name "*CQ" | xargs -I {} basename {} | sort -u) \
        >> /tmp/comparison.txt

cat /tmp/comparison.txt

Task 1.2: فیلتر کردن Customer-Facing فقط

# ماژول‌هایی که حتماً Customer-Facing هستند:
echo "Customer-Facing Modules که در BFF غایب هستند:" > /tmp/customer_missing.txt
echo "1. ClubMembershipCQ - نیاز به UI برای مشتری" >> /tmp/customer_missing.txt
echo "2. NetworkMembershipCQ - نیاز به UI درخت" >> /tmp/customer_missing.txt
echo "3. CommissionCQ - بخش‌های ناقص (Pool, Downline)" >> /tmp/customer_missing.txt

cat /tmp/customer_missing.txt

📊 ماژول‌های ناقص واقعی (بدون فیچرهای جدید)

1. ClubMembership (Priority 0 - حیاتی)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/ClubMembershipCQ/Commands/
# ActivateClubMembership/      ← مشتری می‌خواهد عضو شود
# DeactivateClubMembership/    ← Admin only
# AssignClubFeature/           ← Admin only

ls CMS/src/CMSMicroservice.Application/ClubMembershipCQ/Queries/
# GetClubMembership/           ← مشتری می‌خواهد ببیند
# GetAllClubMemberships/       ← Admin only
# GetClubMembershipHistory/    ← مشتری می‌خواهد تاریخچه ببیند
# GetClubStatistics/           ← Admin + مشتری

غایب در BFF:

  • Query: GetMyClubMembership (نمایش عضویت من)
  • Query: GetMyClubHistory (تاریخچه عضویت من)
  • Query: GetClubFeatures (لیست امکانات باشگاه برای انتخاب)
  • Command: ActivateMyClubMembership (فعال‌سازی عضویت - پرداخت 56M)

UI غایب:

  • صفحه نمایش وضعیت عضویت
  • صفحه لیست امکانات باشگاه
  • دکمه فعال‌سازی عضویت

2. NetworkMembership (Priority 0 - حیاتی)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/NetworkMembershipCQ/Queries/
# GetNetworkTree/              ← مشتری می‌خواهد درخت ببیند
# GetUserPosition/             ← مشتری می‌خواهد موقعیت خود را ببیند
# GetNetworkHistory/           ← مشتری می‌خواهد تاریخچه ببیند
# GetNetworkStatistics/        ← مشتری می‌خواهد آمار ببیند

ls CMS/src/CMSMicroservice.Application/NetworkMembershipCQ/Commands/
# JoinNetwork/                 ← Admin (وقت ثبت‌نام)
# MoveInNetwork/               ← Admin only
# RemoveFromNetwork/           ← Admin only

غایب در BFF:

  • Query: GetMyNetworkTree (درخت شبکه من)
  • Query: GetMyNetworkPosition (موقعیت من)
  • Query: GetMyNetworkHistory (تاریخچه جابجایی‌ها)
  • Query: GetMyNetworkStatistics (آمار شبکه من: تعداد افراد، عمق، ...)

UI غایب:

  • صفحه نمایش درخت باینری
  • Component نمایش Recursive Tree
  • صفحه آمار شبکه

3. Commission (Priority 0 - حیاتی)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/CommissionCQ/Queries/
# GetUserCommissionPayouts/    ← مشتری می‌خواهد کمیسیون‌ها را ببیند
# GetUserBalance/              ← مشتری می‌خواهد موجودی ببیند
# GetWithdrawalHistory/        ← مشتری می‌خواهد تاریخچه برداشت ببیند
# GetWeeklyReport/             ← مشتری می‌خواهد گزارش هفتگی ببیند
# GetPoolShare/                ← مشتری می‌خواهد سهم پول ببیند
# GetDownlineCommissions/      ← مشتری می‌خواهد کمیسیون زیرمجموعه ببیند
# GetCommissionStatistics/     ← مشتری می‌خواهد آمار ببیند
# GetAvailableBalance/         ← مشتری می‌خواهد مبلغ قابل برداشت ببیند

ls CMS/src/CMSMicroservice.Application/CommissionCQ/Commands/
# RequestWithdrawal/           ← مشتری می‌خواهد برداشت کند
# ApproveWithdrawal/           ← Admin only
# RejectWithdrawal/            ← Admin only
# PayWithdrawal/               ← Admin only
# CancelWithdrawal/            ← مشتری می‌تواند لغو کند
# RecalculateCommission/       ← Admin only
# AdjustBalance/               ← Admin only
# TransferCommission/          ← Admin only

موجود در BFF (10%):

  • Query: GetUserCommissionPayouts (ولی ناقص)

غایب در BFF (90%):

  • Query: GetMyBalance (موجودی کامل)
  • Query: GetMyWithdrawalHistory (تاریخچه برداشت‌ها)
  • Query: GetMyWeeklyReport (گزارش هفتگی)
  • Query: GetMyPoolShare (سهم من از پول)
  • Query: GetMyDownlineCommissions (کمیسیون زیرمجموعه‌های من)
  • Query: GetMyCommissionStatistics (آمار کمیسیون‌های من)
  • Command: RequestMyWithdrawal (درخواست برداشت)
  • Command: CancelMyWithdrawal (لغو درخواست برداشت)

UI غایب:

  • صفحه نمایش موجودی کامل
  • صفحه درخواست برداشت
  • صفحه تاریخچه برداشت‌ها
  • صفحه گزارش هفتگی
  • صفحه سهم پول
  • صفحه کمیسیون زیرمجموعه‌ها

4. UserWallet (Priority 1)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/UserWalletCQ/Queries/
# GetUserWallet/               ← مشتری می‌خواهد موجودی ببیند
# GetWalletTransactions/       ← مشتری می‌خواهد تراکنش‌ها را ببیند
# GetDiscountTransactions/     ← مشتری می‌خواهد تراکنش‌های تخفیف ببیند (اگر وجود دارد)

ls CMS/src/CMSMicroservice.Application/UserWalletCQ/Commands/
# ChargeWallet/                ← Admin یا Gateway
# WithdrawFromWallet/          ← مشتری می‌تواند برداشت کند
# TransferBetweenWallets/      ← مشتری می‌تواند انتقال دهد (اگر مجاز باشد)

موجود در BFF (60%):

  • Query: GetUserWallet
  • Query: GetWalletTransactions
  • Command: ChargeWallet (ناقص)
  • ⚠️ Command: WithdrawFromWallet (Handler خالی است)

غایب در BFF (40%):

  • Query: GetDiscountTransactions (اگر در CMS هست)
  • تکمیل WithdrawFromWallet Handler
  • Command: TransferBetweenWallets (اگر مجاز باشد)

UI غایب:

  • تب تراکنش‌های تخفیف (اگر DiscountBalance موجود است)
  • دکمه/فرم برداشت از کیف پول
  • فرم انتقال بین کیف پول‌ها

5. ShoppingCart (Priority 1)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/ShoppingCartCQ/Commands/
# AddToCart/                   ← مشتری اضافه می‌کند
# UpdateCartItemQuantity/      ← مشتری تغییر می‌دهد
# DeleteCartItem/              ← مشتری حذف می‌کند
# ClearCart/                   ← مشتری پاک می‌کند
# MergeGuestCart/              ← سیستم ادغام می‌کند (بعد از Login)
# ApplyDiscountCode/           ← مشتری کد تخفیف وارد می‌کند

موجود در BFF (50%):

  • Query: GetMyCart
  • Command: AddToCart
  • Command: UpdateCartItemQuantity

غایب در BFF (50%):

  • Command: DeleteCartItem
  • Command: ClearCart
  • Command: MergeGuestCart
  • Command: ApplyDiscountCode

UI غایب:

  • دکمه حذف آیتم
  • دکمه پاک کردن سبد
  • فرم کد تخفیف

6. Contract (Priority 2)

موجود در CMS:

ls CMS/src/CMSMicroservice.Application/ContractCQ/Queries/
# GetContract/                 ← مشتری می‌خواهد قرارداد ببیند
# GetAllContracts/             ← مشتری می‌خواهد لیست قراردادها را ببیند
# GetContractDetails/          ← مشتری می‌خواهد جزئیات ببیند

ls CMS/src/CMSMicroservice.Application/ContractCQ/Commands/
# CreateContract/              ← سیستم ایجاد می‌کند
# UpdateContract/              ← Admin
# SignContract/                ← مشتری امضا می‌کند (اگر نیاز باشد)

موجود در BFF (20%):

  • Command: CreateContract (ولی مشتری استفاده نمی‌کند - سیستم استفاده می‌کند)

غایب در BFF (80%):

  • Query: GetMyContract
  • Query: GetMyContracts
  • Query: GetMyContractDetails
  • Command: SignMyContract (اگر نیاز باشد)

UI غایب:

  • صفحه لیست قراردادهای من
  • صفحه جزئیات قرارداد
  • دکمه امضای قرارداد

📋 خلاصه کارهای باقی‌مانده (فقط Customer-Facing)

ماژول Queries غایب Commands غایب UI Pages غایب اولویت
ClubMembership 3 1 2 P0 🔥
NetworkMembership 4 0 3 P0 🔥
Commission 7 2 6 P0 🔥
UserWallet 1 1 (تکمیل) 2 P1
ShoppingCart 0 4 1 P1
Contract 3 1 2 P2

جمع کل:

  • Queries: 18
  • Commands: 9
  • UI Pages: 16

🎯 توصیه نهایی برای Developer

اولویت 1 (حیاتی - باید حتماً باشد):

  1. Commission + Withdrawal: مشتری باید بتواند پولش را ببیند و برداشت کند
  2. ClubMembership: مشتری باید بتواند عضو باشگاه شود
  3. NetworkMembership: مشتری باید درخت شبکه خود را ببیند

اولویت 2 (مهم):

  1. UserWallet Completion: تکمیل برداشت + تخفیف
  2. ShoppingCart Completion: حذف آیتم + پاک کردن سبد + کد تخفیف

اولویت 3 (نرمال):

  1. Contract: نمایش قراردادها

💡 نکته بسیار مهم

چیزهایی که حذف شدند (چون جدید هستند یا Admin هستند):

  • DayaLoan (فیچر جدید - هنوز در CMS کامل نیست)
  • Manual Payment (فیچر جدید)
  • Admin Commands در همه ماژول‌ها (Approve, Reject, Recalculate, Adjust, ...)
  • Admin Queries (GetAll, GetStatistics با دسترسی Admin)

فقط روی اینها تمرکز کن:

  • Queries که مشتری می‌خواهد ببیند (GetMy...)
  • Commands که مشتری می‌خواهد اجرا کند (ActivateMy..., RequestMy..., DeleteMy...)
  • UI Pages که مشتری می‌خواهد استفاده کند

📊 تخمین زمان واقعی (بدون فیچرهای جدید)

کار زمان تخمینی
Commission (7 Query + 2 Command + 6 Page) 12 ساعت
ClubMembership (3 Query + 1 Command + 2 Page) 6 ساعت
NetworkMembership (4 Query + 3 Page) 8 ساعت
UserWallet Completion (1 Query + 1 تکمیل + 2 Page) 3 ساعت
ShoppingCart Completion (4 Command + 1 Page) 4 ساعت
Contract (3 Query + 1 Command + 2 Page) 4 ساعت
جمع کل 37 ساعت (تقریباً 5 روز کاری)

این زمان واقع‌بینانه‌تر است چون فیچرهای جدید (DayaLoan, Manual Payment) حذف شدند.


📝 اصطلاحات جایگزین (MLM-Sensitive Terminology)

آخرین بروزرسانی: ۹ دی ۱۴۰۴ (29 دسامبر 2025)

برای جلوگیری از حساسیت مشتریان به کلمات مرتبط با MLM، از اصطلاحات جایگزین زیر در UI مشتری استفاده شود:

کلمه حساس (فارسی) جایگزین پیشنهادی توضیح
کمیسیون پاداش Commission → Reward
شبکه‌سازی تیم‌سازی Network Building → Team Building
شبکه تیم Network → Team (در context MLM)
شاخه چپ/راست تیم اول/دوم Left/Right Leg → Team 1/2
زیرمجموعه اعضای تیم Downline → Team Members
تعادل امتیاز/جفت Balance → Points/Pairs
درخت شبکه نمودار سازمانی Network Tree → Org Chart
سقف حداکثر Cap → Maximum
Binary دوبخشی Binary → Two-part

⚠️ موارد استثنا (نباید تغییر کنند):

  • شبکه‌های اجتماعی - Social Networks (مرتبط با MLM نیست)
  • درخت دسته‌بندی - Category Tree (مرتبط با محصولات)
  • پنل ادمین (BackOffice) - نیاز به صراحت اصطلاحات دارد

فایل‌های تغییر یافته (۹ دی):

  • WeeklyBalancePage.razor - کمیسیون → پاداش
  • CommissionDashboardPage.razor - کمیسیون → پاداش
  • MyPackages.razor - مشاهده شبکه → مشاهده تیم
  • Index.razor - شبکه‌سازی → تیم‌سازی
  • About.razor - شبکه‌های فروش → تیم‌های فروش
  • Footer.razor - شبکه‌های فروش → تیم‌های فروش
  • NetworkStatisticsPage.razor - آمار شبکه → آمار تیم