Merge branch 'feature/network-club-system' into kub-stage

This commit is contained in:
masoodafar-web
2025-12-05 20:41:52 +03:30
731 changed files with 72336 additions and 2627 deletions

258
README.md
View File

@@ -1,2 +1,258 @@
# CMS
# CMS Microservice - Network & Club Commission System
[![Status](https://img.shields.io/badge/Status-Production%20Ready-success)]()
[![Progress](https://img.shields.io/badge/Progress-85%25-blue)]()
[![MVP](https://img.shields.io/badge/MVP-100%25%20Complete-brightgreen)]()
## 📊 Project Status (2025-12-01)
**Overall Progress**: 85% Complete (7/10 phases)
**Production Readiness**: 95%
**MVP Status**: ✅ 100% Complete
### ✅ Completed Phases (7)
1. ✅ Domain Layer (Entities, Enums, Value Objects)
2. ✅ Club Membership System
3. ✅ Binary Network Tree
4.**Commission Calculation & Background Worker** (MVP)
5. ✅ Protobuf gRPC Services
6. ✅ History & Configuration Management
7. ✅ Database Migration & Seed Data
### 🟡 Partially Complete (1)
- Phase 10: Withdrawal & Settlement (40%)
- ✅ Commands & Database
- ❌ Payment Gateway Integration
### ❌ Not Started (1)
- Phase 9: Club Shop & Product Integration (0%)
### ⏸️ Postponed (1)
- Phase 7: Testing (Unit, Integration, Load tests)
---
## 🚀 Recent Updates (2025-12-01)
### Email & SMS Notifications - COMPLETED ✅
-**MailKit 4.14.1** for Email (SMTP with HTML templates)
-**Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
- ✅ User.Email field added with migration
- ✅ 3 notification types: Commission, Club activation, Errors
- ✅ Persian RTL templates with rich formatting
- ✅ Production configuration guide created
### Hangfire Job Scheduling - COMPLETED ✅
- ✅ Dashboard UI at `/hangfire`
- ✅ Cron schedule: Sunday 00:05 UTC
- ✅ SQL Server persistence
- ✅ Manual trigger API endpoints
- ✅ Distributed execution support
### Infrastructure Enhancements - COMPLETED ✅
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
- ✅ AlertService (structured logging for Sentry/Slack)
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
- ✅ WorkerExecutionLog (database audit trail)
- ✅ CurrentUserService (JWT authentication context)
---
## 🏗️ Architecture
**Clean Architecture** with 4 layers:
```
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
CMSMicroservice.WebApi/ # gRPC Services, Controllers
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
```
**Technology Stack**:
- .NET 9.0
- Entity Framework Core 9.0.11
- gRPC + JSON Transcoding
- Hangfire 1.8.22 (Job Scheduling)
- MediatR 13.0.0 (CQRS)
- Polly 8.5.0 (Resilience)
- MailKit 4.14.1 (Email)
- Kavenegar 1.2.5 (SMS)
- SQL Server
---
## 📖 Documentation
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
---
## 🚀 Quick Start
### Prerequisites
- .NET 9.0 SDK
- SQL Server (local or remote)
- (Optional) Gmail account for Email
- (Optional) Kavenegar account for SMS
### 1. Clone & Build
```bash
cd /home/masoud/Apps/project/FourSat/CMS/src
dotnet build
```
### 2. Configure Database
Update `appsettings.json` with your SQL Server connection:
```json
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
}
```
### 3. Apply Migrations
```bash
cd CMSMicroservice.WebApi
dotnet ef database update
```
### 4. Configure Notifications (Optional)
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
### 5. Run
```bash
dotnet run --urls="http://localhost:5133"
```
### 6. Access Endpoints
- **Health**: http://localhost:5133/health
- **Hangfire Dashboard**: http://localhost:5133/hangfire
- **gRPC**: localhost:5133 (HTTP/2)
---
## 🔧 Configuration
### Email (SMTP)
```json
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-gmail-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
}
```
### SMS (Kavenegar)
```json
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "YOUR_API_KEY",
"Sender": "10008663"
}
```
### Background Worker
```csharp
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
"weekly-commission-calculation",
job => job.ExecuteAsync(CancellationToken.None),
"5 0 * * 0");
```
---
## 🧪 Testing
### Manual Trigger (via API)
```bash
# Trigger weekly calculation immediately
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
# Trigger recurring job now
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
# Get recurring jobs status
curl http://localhost:5133/api/admin/recurring-jobs-status
```
### Health Checks
```bash
curl http://localhost:5133/health # Overall health
curl http://localhost:5133/health/ready # Readiness probe (K8s)
curl http://localhost:5133/health/live # Liveness probe (K8s)
```
---
## 📊 What's Remaining?
### High Priority
1. **Payment Gateway Integration** (Phase 10 - 1 week)
- Daya or Bank Mellat API integration
- IBAN transfer automation
- Admin approval UI in BackOffice
2. **Production Configuration** (30 minutes)
- Gmail App Password setup
- Kavenegar API key registration
- Update `appsettings.Production.json`
### Medium Priority
3. **Club Shop Integration** (Phase 9 - 2 weeks)
- Product catalog for club memberships
- Shopping cart integration
- Auto-activation on purchase
### Low Priority
4. **Testing** (Phase 7 - Postponed)
- Unit tests for business logic
- Integration tests for API
- Load testing for background worker
### Optional Enhancements
- Redis distributed locks (multi-server deployment)
- Sentry error tracking (API key needed)
- Slack notifications (webhook needed)
- FCM push notifications
---
## 🎯 MVP Features (100% Complete)
✅ Binary network tree with automatic placement
✅ Club membership (Member/Trial) with different commission rates
✅ Weekly commission calculation (Lesser Leg algorithm)
✅ Background worker with Hangfire (cron scheduling)
✅ Balance carryover logic (rollover unused volumes)
✅ MaxWeeklyBalances cap enforcement
✅ Health check endpoints (Kubernetes-ready)
✅ Manual trigger API (admin control)
✅ Email + SMS notifications (MailKit + Kavenegar)
✅ Retry logic with exponential backoff (Polly)
✅ Audit trail (WorkerExecutionLog, History tables)
✅ Structured logging (AlertService for Sentry/Slack)
✅ JWT authentication context (CurrentUserService)
---
## 👥 Team
**Development**: FourSat Team
**Last Updated**: 2025-12-01
---
## 📝 License
Proprietary - FourSat Company

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
سیستم کر مرکزی خب سیستم کارگزاری کیف پول داره که کیف پولی که اینا میرن خرید میکنن از دایا برمی‌گردن وامشون واریز میشه این کیف پول شارژ میشه ۵۶ تومان حالا بازار یه فروشگاه داره یه فروشگاه اینترنتی داره که با این ۵۶ تومن که فعلا امتیازی که باید برن حتما از دایا خرید کنن برگردن بعدا قراره خودشون نقدی کیف پولشون رو شارژ کنن یعنی با سلیقه درگاه بیان کیف پولشونو شارژ کنن. تو هر دوتا حالتش از این فروشگاه می‌تونن خرید کنن حالا بعد اینکه کیف پولشون شارژ میشه حالا از طریق دایه‌ها یا از هر طریق دیگه به اون اندازه‌ای که ما متوجه بشیم که این شارژ کیف پول به دلیل عضویت در باشگاه مشتریان بوده
برتری ممکنه طرف بیاد یه میلیون کیف پولشو شارژ کنه اون یه میلیونه مثلا ما یه باشگاه مشتریانم جدا داریم یعنی آره خود باشگاه مشتری که فعال فعال میشه ۱. الان فعلاً در حال حاضر دایا خرید کنی وام بگیری خب وامشو بگیری هم باز باید واسم یه قسمتشو انگار مثلاً یه دکمه باید بزنی اختصاص بده به باشگاه مشتری یا نه دقیقاً یعنی یه دکمه میزنی این اختصاص داده میشه یعنی توی خود کارا بازار یه دکمه‌ای وجود داره میزنی و بعد از اینکه پرداختتون انجام دادی که پولتو شارژ کردی این دکمه رو میزنی و شما عضو باشگاه مشتریان میشی یعنی ما می‌سنجیم ببینیم اینکه تو. پرداختیتو انجام دادی اول بعد باشگاه مشتریان میشی حدوداً ۲۰ ۲۵ میلیونش از این ۵۶ میلیونی که تامین اعتبار میشه
جدا میشه جدا میشه میره تو باشگاه مشتری میره تو باشگاه مشتریان که از اونجا دیگه مدیریت اون محاسبه پورسانته دقیقاً انجام حالا باشگاه مشتریان چی داره باشگاه مشتریان خودش خودش برای خودش به صورت مجزا یه فروشگاه تور داره که تو اون فروشگاهه صرفا یه سری تخفیف وجود داره یعنی متفاوت با این فروشگاه اصلی اون فروشگاه یه سری تخفیف داره. a۵۵ ۳۰ درصد تخفیف این ۳۰ درصد تخفیف تو چجوری میتونی استفاده کنی حالتی که رفته باشی کیف پول اصلی تو کیف پول اصلیتو شارژ کرده باشی حالا از طریق دایه یا نقدی کیف پول اصلیتو شارژ کرده باشی یه ۵۶ تومان که به کیف پول اصلیت واریز میشه
هیچ یه ۵۶ تومان هم به کیف پول تخفیف تو باشگاه مشتریان اضافه میشه که اون گوشی ۵۵ که مثلا ۳۰ درصد تخفیف داره رو ۵۶ تومن واریز میشه ۵۶ تومن واریز میشه. به کیف پول تخفیفت یعنی اون یه ۲۵ میلیون برای باشگاه مشتریانه وقتی باشگاه مشتری فعال می‌کنی ۵۶ میلیون اعتبار تخفیف برات فعال میشه که از اون فروشگاه دوم میتونی خرید کنی ولی چه جوری میتونی خرید کنی فقط همون درصد تخفیف رو میتونی از این ۵۶ تومان استفاده میشه اوکی پس چی شد اگه گوشی مثلا. ۲۰ درصدش تخفیف خورده اون ۲۰% رو می‌تونی از این ۵۶ تومانه استفاده کنی مابقیشو باید نقدی اینجوری میفهمم من باید یه تیبل داشته باشم کسایی که میان
میرن جز باشگاه مشتریان میشن رو اونجا ثبت بکنم یعنی وصل به تیبل یوزرمون بعد اونجا ثبت میشه آها این شخص جز باشگاه مشتری حالا خود باشگاه مشتریان یادته که دکتر گفتش که آقا یه سری لیست داره که اونا فعال میشن فعال شده شماره بیمه چیه یا اگه مثلا فلان چی فعال شده برات این چیه خب مثلا من. تو ذهنم اینجوری بود که خیلی ساده که آپشنای باشگاه مشتریانه اول که میگیم آقا این کاربر جز باشه مشتریان شده است یا خیر ۱ فیلدی که میگه شده است یا خیر یه تیبل دیگه است که میگه آقا این فیچرهایی که از این باشگاه مشتری گرفته کدوماشو گرفته یه تیبل دیگه هست که فیچرها رو اون تو میزنیم باشگاه مشتری داریم آره یه تیبل واسطه مشتریان و یوزر داریم که آقا این یوزر این فیچر براش باز شده با این توضیحات دقیقا اوکی حالا. بعد من علاوه بر این یه کیف پول تخفیف هم باید به کیف به فیلدهای ولتم اضافه کنم یعنی الان یه تیبل ولت دارم یه موجودی شبکه داره یه موجودی خالص داره یه موجودی تخفیف هم باید داشته باشه یعنی سه تا موجودی باید داشته باشه درسته حالا این سه تا موجودی زمانی موجودی تخفیف فعال میشه که کاربر جزو باشگاه مشتریان شده
باشه خب بعد از این فروشگاه یعنی ممکنه محصولاتشم حتی فرق داشته فعال بکنه که آقا من میخوام از. کیف پول تخفیفی بخرم تخفیفا رو نمایش بده اگه نه می‌خوام از تخفیفیم نخرم هادیا رو نمایشگاه باید ایمپلیمنت باشه حالا این پس این باشگاه مشتریان که من میتونم جزئیات باشگاه مشتری خیلی جالبه این فروشگاه رو تو مثلا یه گوشی با یه لپ تاپ میخری گوشی ۲۰ درصد تخفیف داره لپ تاپ ۵۰ درصد تخفیف داره تو اون ۲۰% ۵۰% رو از این کیف پول تخفیفت میتونی استفاده کنی شارژ شده مابقیش هم نقدی میره مستقیم برو نقدی پرداخت کن. ما به صورت هفتگی محاسبه کارمزد داریم یعنی به صورت هفتگی کارم محاسبه می‌کنیم
پلن نتورک این شبکه هم پلن باینره که یه تعادلی ایجاد میشه فقط هم دو نفره دیگه فقط دو نفر بله دو نفر یعنی شما یه دست راست داری یه دست چپ داری بیشتر از اون نداری یعنی سه تا دست و چهار تا دست نداریم ما الان دو تا دست داریم یعنی من. یوزر یه دست راست دارم یه دست چپ دست راستم مثلاً آقای ایکس دست چپم خانم یعنی هیچ چیز اضافه تری نداره ما یه حالا ما توی محاسبه پورسان با کدوم یک از این اعتبارا کار دارم فقط ۵۰ میلیون تومن ۵۶ میلیون تومن تو کیف پول اصلی واریز میشه یه ۵۶ میلیون تومن توی کیف پول تخفیف واریز میشه یه دونه ۲۵ میلیون تومان هم میره توی کارمزد نتورک میره اونجا که بخواد کارمزدش محاسبه بشه.
آخر هفته ما محاسبه میکنیم میگیم مثلا میثم مقدم دو نفر زیر مجموعه داره مثلا ایکس و ایگرگ آقای ایکس و خانم ایگرگ این دو نفر زیر مجموعه هر کدوم اومدن ۵۶ تومان خرید کردن خب خودمم که ۵۶ تومان همون اول خرید کرده بودم یعنی پکیج خریده بودم سرمایه گذاری کرده بودم. این ۵۶ تومان با این ۵۶ تومان میشه حدوداً صد و ۱۱۲ تومن با ۵۶ تومان خودم میشه ۱۶۸ تومن درسته ۱۶۸ تومن توی مخزنمون هست خب ۱۶۸ تومن تو مخزنمون هست حالا بذار من این چیزمو نگاه کنم خب نگاه کن ما به ازای هر تعادلی که ایجاد میشه یک امتیاز به. الان مثلاً من گفتم آقای ایکس و خانم دیگه خب یه تعادل ایجاد کردم درسته یعنی امتیازمون یعنی امتیاز من چنده یه دونه تعادل ایجاد کردم تو هر هفته تعداد تعادل رو محاسبه میکنیم اوکی تعداد تعادل های هر نفر را محاسبه. حالا ده تا تعادل یعنی چی من که یه دونه بیشتر تعادل نمیتونم بزنم اگه من زیر مجموعهم یه تعادل بزنه برای من حساب میشه
بله خب نه نگاه کن الان من زیر مجموعه سمت راستم یه تعادل زده یعنی دو نفرو جذب کرده این میشه خب همین یه طرف هم میشه اگه اون طرف هم تعادل همون دیگه یعنی من هرچقدر سطحم میره پایین تر تعداد تعادل باید ضربدر دو بشه. یعنی من توی لول اول خودم اگه یه دونه دو نفرو جذب بکنم میشه یه تعادل ولی اگه می‌خوام دومین تعادلو داشته باشم بعد سمت راستم یه تعادل یعنی یه دو نفر جذب بکنه سمت چپم یه دو نفر جذب بکنه سمت راست سمت چپت بعد هر کدوم یه دونه جذب بکنه هر کدومشون باید یه تعادل بزنند که برای تو دوتا تعادل حساب بشه
یعنی نگاه کن تو خودت که الان فرض میکنیم تو هفته اول یه اتفاقی افتاده اتفاقی اینه تو خودت دو نفرو جذب کردی یعنی میثم مقدم آقای ایکس و خانم ایگرگ رو جذب کرده آقای ایکس دو نفرو جذب کرده. خانم ایگرگم دو نفرو جذب کرده خب تو دوتا تعادل یه دونه تعادل که خودت زدی چون آقای ایکس خانم ایگرگ رو جذب کردی یه دونه تعادل اینورت زده یه دونه تعادل جمع میشه چند تا تعادل سه تا تعادل تو زدی درست شد نشد دیگه گفتیم دوتا تعادل میشه نه دیگه چرا دوتا تعادل گفتی که آقا من وقتی که توازن برقرار بشه بهش میگیم یه تعادل دیگه خب خب من وقتی که خودم یه دو نفر جذب می کنم میشه
تعادل وقتی زیر مجموعه تعادل جذب میکنه هنوز برای من تعادل نیست چون زیر مجموعه دوم هم باید تعادل بزنه دیگه. تعادل هر کدوم نفری براشون یه تعادل ولی برای تو تعادل اونا که حساب نمیشه برای تو یه تعادل از یه سطح بالاتر حساب میشه دیگه اینجوری نیست مگه نه اونجوری که تو همیشه یه تعادل دوتا تعادل میتونی داشته باشی نه چون دو تا دست داری اینا هر کدوم تعادل تعادل تعادل بزنن یه دونه تعاد. مبلغ کیف پوله مگه شرط نیست اون چیزی که تو صندوق جمع شده مگه شرط نیست نه به اون کاری نداریم الان تعداد تعادل چگونه محاسبه میشود چه جوری ما حساب میکنیم تو چند تا تعادل زدی تو یه دستت یه تعادل بزنه یه دسته دیگه هم یه تعادل تو دو تا تعادل زدی متوجه شدی تو تونستی دوتا دوتا جذب کنی خب دو تا تعادل حالا بگذریم از همون خیلی ساده‌شو
بگیریم من میثم مقدم دو نفرو جذب کردم آقای ایگرگ خانم ایکس درسته. امتیاز تو شد ۱ به تعداد تعادل مساوی با امتیاز یعنی تعداد تعادل مساوی است با امتیاز تعداد تعادل هر شخص مساوی است با امتیاز اون شخص حالا هرچی که مبلغ توی صندوق جمع شده یعنی من خودم ۵۶ تومن دادم دست راستم ۵۶ تومن داده دست داده درسته البته که اینا که دارم میگم اشتباهه. ۵۶ تومنه یکیش واسه کیف پول تخفیفه یکیش واسه کیف پول اصلیه ما اینجا ۲۵ تومان داریم دست خودم ۲۵ تومان آوردم تو باشگاه مشتریان دست راستم ۲۵ تومان آورده دست چپم ۲۵ تومان آورده جمعاً میشه ۷۵ تومان یعنی ۷۵ میلیون تومن تو صندوق جمع شده
درسته من چه امتیازی دارم ۱ درسته دست راستم چه امتیازی داره صفر دست چپم چه امتیازی داره صفر درسته ما با اونا کار نداریم الان مبلغ پورسانت من چی میشه من یک امتیاز دارم اون ۷۵ تومن تقسیم بر یک. اون دوتا که صفر بودن دیگه اگه اون دوتا نفر یک بودن میشد مثلا تقسیم بر سه خب میشه مبلغ ریالی هر امتیاز یعنی مجموع کل امتیازهایی که همه کاربرها جمع کردن و مجموعه کل امتیازها اینا رو یه دست نگهدار این عددی که تو صندوق جمع شده تقسیم بر مجموعه کل امتیازها یعنی عددی که تو صندوق جمع شده تقسیم بر کل تعداد تعادل‌های این هفته مساوی است با مبلغ ریالی هر امتیاز حالا تو چند امتیاز داشتم ۷۵ میلیون تقسیم بر ۱. یعنی مبلغ ریالی هر امتیاز میشه ۷۵ میلیون درسته حالا من چند امتیاز داشتم ۱ پس ۷۵ میلیون ضربدر یک میشه
یعنی ۷۵ میلیون تومان باید کارمزد بگیرم یه لول میاد پایین تر خب من اگر این هفته جدید تعادل جدیدی ثبت نکنم که دیگه برام تعادل حساب نمیشه یعنی من وقتی تعادل زدم پولشم گرفتم دیگه اون تعادل پاک میشه اون تعادل دیگه پاک میشه دیگه برای تو تعادل جدید حساب نمیشه خب. حالا من توی شبکه هم دست چپ و راستم رفتی یه لول پایین تر اونا هم یه دونه مثلاً شده هفته بعد اونا هم یه تعادل دیگه زدن برای من دوتا تعادل حساب میشه برای خودشون چند تا هر کدوم نفری یه دونه درسته هفته اول دیگه چون خود من دو نفر جذب کردم میشه ۱ درسته اونا هر کدوم دو نفر جذب کردن ۱ ۱ برای من میشه سه. هفته اوله حالا شده ۵ هرچی که تو صندوق از اون ۲۵ میلیون ۲۵ میلیون جدید درسته یعنی اونایی که دیگه همش هفته اول همش جدیده دیگه ثبت شده
تقسیم میشه بین اون امتیازها حالا کی چقدر امتیاز داره همون پول میگیره درسته چه اتفاقی افتاده من ۲۵ میلیون دست راستم ۲۵ میلیون ۷۵. هر کدوم از اونا نفری دو نفرو جذب کردن که دو تا ۲۵ میلیون اونور ۵۰ ۵۰ ۱۰۰ میلیون ۱۰۰ میلیون با ۷۵ میلیون میشه ۱۷۵ میلیون ۱۷۵ میلیون تقسیم بر ۵ میشه حدوداً ۳۵ میلیون یعنی ۳۵ میلیون ارزش ریالی هر امتیازه بعد حالا هر کی چقدر امتیاز داره همونقدر بهش تعلق می‌گیره من چقدر امتیاز دارم ۳ امتیاز دارم ۳۵ میلیون ضربدر ۳ ۳ تا ۳۵ میلیون هم باید بگیرم یه دونه ۳۵ میلیون دست راستم باید بگیره یه ۳۵ میلیون دست چپم باید بگیره خب من مثلا میتونم یه تیبل داشته باشم خب که. هر کسی هر هفته‌ای که تعادل میزنه خب اونو اونجا ثبت بشه
تعداد تعادل‌های هر شخص توی هر هفته باید ثبت بشه خب تعداد تعادل‌های هر شخص تو هر هفته باید ثبت بشه یعنی اگه اون مثلاً من زیر مجموعه‌هام هزار تا ۲۰۰۰ نفر بشه اون پایینم یه نفر یه تعادل بزنه برای من یه تعادل ثبت میشه حالا اگه یه دستم یه تعادل بزنه بازم برای من یه تعادل ثبت میشه یعنی من نباید تلاش کنم چرا دست دوم باید همونقدر تعادل بزنه یعنی اگه مساوی بزنن تعادل حساب میشه. هفته اولم باشه فقط آقای ایکس یه تعادل بزنه من برای خودش تعادل حساب میشه پس من باید توازن داشته باشم دیگه باز خب اگر توازن داشته باشم یعنی مثلا من حالا مثلا یه لول رفته
جلوتر سه تا تعادل این دستم زده دو تا تعادل این دستم زده برای من ۲ حساب میشه دو اینور دو این ور میشه چهار یعنی من هر موقعی که یه تعادلی شکل میگیره باید برم دست مقابل اونم نگاه کنم ببینم تعادلی وجود داره تازه میشه یه تعاد. تعادل بعدی اگه اونور وجود داشت که هیچی اگر وجود نداشت اگه وجود داشت که خب دیگه تعادله اگه وجود نداشتم که هیچی این دست نگاه کنم ببینم که مثلاً این دست که حالت تعادل زده این دستش یه تعادل داره در هر صورت بخوام یه فرمول کلی بگم تو دست چپت تو اعماق اصلا ده لول ۱۵ رفته پایین این نتورک تا لول ۱۵ رفته
پایین دست چپت اون پایین مایا چهار تا تعادل میزنه دست راستتم حداقل باید چهار تا تعادل بزنه تا بره تو یه چیزی محاسبه بشه یعنی اگه دست. چپ تو خوب دوتا تعادل زده دست راستت چهار تا تعادل زده دو تا تعادل واسه تو حساب میشه دوتا اینور دوتا اونور جمع میشه چهار تا اگه دست راستتو پنج تا تعادل زده دست چپتو هیچ تعادلی نزده پس در نتیجه هیچ تعادلی واسه تو حساب نمیشه اگه دست راستتو دو تا تعادل زده دست چپتم دو تا تعادل زده دقیقا حالا با همدیگه مساوی چهار تا تعادل اگه دست راست تو ده تا تعادل زده ۱۰۰ تا تعادل زده ولی دست چپت دوتا تعادل زده کلاً دو تا تعادل حساب میشه دو تا راست دو تا چپ میشه
چهار تا. تعادل یه نفر حساب کنی این شکلی باید حساب کنیم خب من الان مثلا اون تیبلی که میزارم باید چه شکلی باشه یعنی همون لحظه که یه نفر ثبت نام میکنه من کسی که عضو باشگاه مشتریان میشه تو یه جا ثبت کن که آقا این نفر عضو باشگاه مشتریان شد حالا آخر هفته محاسبه می‌کنی اون نفری که عضو باشگاه مشتری اینا شده والدش کی بوده والدش کی بوده والد والت همینجوری تا آخر آیا تعادل خورده است یا خیر یعنی تو هفتگی باید حساب کنی تو این هفته ورودی های این هفته رو باید حساب کنی. خب من نمی‌تونم مثلاً وقتی که یه نفر جزو باشگاه مشتریان میشه
همون لحظه تعادل همه بالا سریاشو حساب کنم نه شاید تعادل بیشتر بزنه خب باشه وقتی بیشتر زد دوباره افزایش نمی‌دونم شاید بشه بعد اینو حساب کتاب کنی بعد با دکترم جلسه بذاری که ببینی دقیقاً این چه جوریه مثلا هفته پیش یه نفر یه تعادل زده این هفته کلاً پوچ میشه تعادلاش چون من تا جایی که یادمه باید سعی کنه طرف تو هفته دو تا تعادل این دستشو بزنه وگرنه پوچ میشه یعنی از دست دادتش. حله و در مجموع پس هر کدوم من میگم اون تیبلی که دارم حتما باید یه چیزی تحت عنوان امتیاز باشه اگه همون تعداد تعادل خب بعد عددی که جمع میشه هم یه جا باید من یه جا نگهش دارم عددی که تو این هفته جمع میشه
تعداد تعادل این هفته و مبلغی که تو این هفته تو باشگاه مشتریان جمع شده حالا این تقسیم برای امتیاز هرکی به نسبت امتیازی که داره یه مبلغی براش ثبت میشه که اون مبلغ در نهایت میره تو کیف پول شبکه یا کیف پول کارمزد اصلا کیف پول نذاریم بذاریم کارمزد کمیسیون. یه چیزی باید باشه ولی یه مخزنی هست دیگه یه جایی هستش که تو هر هفته مبلغی که با استفاده از اون پلن شبکت دریافت کردی میره اونجا واریز میشه حالا این مبلغی که توی کیف پول شبکه یا کیف پول کارمزد هست یا کیف پول طلایی اسمشو بذاریم چون اسم این امتیازها امتیازهای طلاییه اسم اون کیف پوله رو بذاریم کیف پول طلایی چون سه تا کیف پول شد یک کیف پول اصلی که تو میتونی بری از فروشگاه بازار خرید کنی مستقیمه دو کیف پول تخفیف که تو میتونی بری از فروشگاه که بعد از باش
مشتریان این اتفاق. یکی هم کیف پول طلاییت یا همون کیف پول کارمزدت این میشه سه تا کیف پول حالا کیف پول کارمزد چه جوری میتونی برداشت کنی دو طریق داره یک نقدی برداشت کنید یعنی شماره شبا بدیم و نقدی برات پرداخت کنیم ۲ بری از دایا الماس بخری حالا یه چیزی من الان ۵۶ میلیون تومنو یعنی ما الماس بهت بدیم اوکی ما الان ۵۶ میلیون تومنو آوردیم توی کیف پول که میتونه بره خرید بکنه اگه باشگاه مشتری اینو بزنیم ۲۵ میلیون ازش کم میشه دیگه کم میشه دیگه. میلیون تومن توی باشگاه مشتریان شارژ میشه جدای از این یعنی میشه چی میشه یه ۵۶ میلیون تومن توی کیف پول اصلی یعنی ۵۶ میلیون تومن تو کیف پول ۲۵ میلیون تومان توی خود باشگاه اوکی حالا بذارید تحلیل بکنم ببینم چی میتونم در بیارم.
masoud moghaddam, [11/29/25 6:23AM]
کاربر A: فعال‌سازی (۲۵M به استخر)
├─ فرزند Left: کاربر B (فعال‌سازی ۲۵M)
└─ فرزند Right: کاربر C (فعال‌سازی ۲۵M)
استخر هفته اول: ۷۵M
تعادل کاربر A: MIN(1, 1) = 1
تعادل کاربر B: 0
تعادل کاربر C: 0
مجموع تعادل‌ها: 1
ارزش هر امتیاز: 75M ÷ 1 = 75M
کمیسیون کاربر A: 1 × 75M = 75M
کاربر B: جذب دو نفر (D و E) → تعادل ۱
کاربر C: جذب دو نفر (F و G) → تعادل ۱
استخر هفته دوم: ۴ × ۲۵M = ۱۰۰M
تعادل کاربر A: MIN(1, 1) = 1 (از B و C)
تعادل کاربر B: 1
تعادل کاربر C: 1
مجموع تعادل‌ها: 3
ارزش هر امتیاز: 100M ÷ 3 ≈ 33.33M
کمیسیون کاربر A: 1 × 33.33M = 33.33M
کمیسیون کاربر B: 1 × 33.33M = 33.33M
کمیسیون کاربر C: 1 × 33.33M = 33.33M
masoud moghaddam, [11/29/25 6:24AM]
این نوع محاسبه درسته ؟
Doctor
Doctor Seif, [12/1/25 4:37PM]
سلام
نصفش درسته، نصفش نه
Doctor Seif, [12/1/25 4:42PM]
کاربر A: فعال‌سازی (۲۵M به استخر)
  ├─ فرزند Left: کاربر B (فعال‌سازی ۲۵M)
  └─ فرزند Right: کاربر C (فعال‌سازی ۲۵M)
استخر هفته اول: ۷۵M
تعادل کاربر A: MIN(1, 1) = 1
تعادل کاربر B: 0
تعادل کاربر C: 0
مجموع تعادل‌ها: 1
ارزش هر امتیاز: 75M ÷ 1 = 75M
کمیسیون کاربر A: 1 × 75M = 75M
کاربر B: جذب دو نفر (D و E) → تعادل ۱
کاربر C: جذب دو نفر (F و G) → تعادل ۱
استخر هفته دوم: ۴ × ۱۰۰M = ۲۵M
تعادل کاربر A: MIN(2, 2)=2 = 1 (از B و C)
تعادل کاربر B: 1
تعادل کاربر C: 1
مجموع تعادل‌ها: 4
ارزش هر امتیاز: 100M ÷ 4 = 25M
کمیسیون کاربر A: 2 × 25M = 50M
کمیسیون کاربر B: 1 × 25M = 25M
کمیسیون کاربر C: 1 × 25M = 25M
قصه محاسبه تعادل اینه که اون کاربر بالایی وقتی که کاربرهای پایینیش یعنی ای و بی تعادلش رو می‌گیرند خط تعادل اون که بین کاربر ای و بیه این سمتش دو نفر وارد میشه اون سمتش دو نفر یعنی دو تا یک به یک پس تعادل دوش فعال می‌شه برای اون دیگه تعادل یک نیست همونطور که زمانی که توی سمت بین همون که داری میگی مثلا شش نفر سمت ای باشن پنج نفر سمت بی تعادلش میشه ۵ یه نفر از اونایی که سمت ای اند. باقی میمونه برای محاسبات هفته آینده‌اش یعنی شما باید اون خط مرکز را بکشی و بعد به نسبت تعداد افراد سمت چپ که ای یا ای و تعداد افراد سمت بی اون نسبت رو می‌گیری اون میشه
تعداد تعادل اون فرد بالا برای بقیه افراد هم همینه یعنی هر فردی یک سازمان ای و یک سازمان بی داره تعداد تعادل‌ها می‌شه مجموع افراد ورودی هفته جدید به اضافه باقی مانده‌های هفته قبلی اگر باقی مانده توی اون سمتش مونده تعادلشون با مجموع تعداد افراد ورودی جدید. به اضافه باز باقیمانده‌های هفته قبلی اگر باقیمانده از هفته قبلی مونده جمع این دو تا پایین‌ترین عددش میشه میزان تعادل اون پایین‌ترین عدد منهای اون تعداد میشه باقیمانده تو هر دستی که بود چه ای بود چه بی بود میره سیو میشه برای هفته بعدی.

View File

@@ -0,0 +1,51 @@
-- Script to update WeeklyPoolContributionPercent from 10% to 20%
-- این script فقط در صورتی که رکورد وجود داشته باشد، آن را آپدیت می‌کند
-- بررسی وجود جدول SystemConfigurations
IF OBJECT_ID('SystemConfigurations', 'U') IS NOT NULL
BEGIN
PRINT 'جدول SystemConfigurations یافت شد. در حال آپدیت...'
-- آپدیت رکورد (در صورت وجود)
UPDATE SystemConfigurations
SET
Value = '20',
Description = N'درصد مشارکت در استخر هفتگی از کل فعال‌سازی‌های جدید شبکه (20%)',
LastModified = GETUTCDATE()
WHERE [Key] = 'Commission.WeeklyPoolContributionPercent'
-- اگر رکوردی وجود نداشت، اضافه کن
IF @@ROWCOUNT = 0
BEGIN
PRINT 'رکورد Configuration یافت نشد. در حال ایجاد...'
INSERT INTO SystemConfigurations
([Key], Value, Description, Scope, IsActive, DataType, Created)
VALUES
('Commission.WeeklyPoolContributionPercent', '20',
N'درصد مشارکت در استخر هفتگی از کل فعال‌سازی‌های جدید شبکه (20%)',
2, -- ConfigurationScope.Commission = 2
1, -- IsActive = true
'Int',
GETUTCDATE())
END
ELSE
BEGIN
PRINT 'رکورد با موفقیت آپدیت شد.'
END
END
ELSE
BEGIN
PRINT 'جدول SystemConfigurations هنوز ایجاد نشده است.'
PRINT 'لطفاً ابتدا سرویس را یکبار اجرا کنید تا جداول Seed شوند.'
END
-- نمایش وضعیت فعلی
IF OBJECT_ID('SystemConfigurations', 'U') IS NOT NULL
BEGIN
PRINT ''
PRINT 'وضعیت فعلی:'
SELECT [Key], Value, Description, Scope, IsActive
FROM SystemConfigurations
WHERE [Key] = 'Commission.WeeklyPoolContributionPercent'
END

View File

@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />

View File

@@ -13,7 +13,7 @@ public class CreateNewCategoryCommandHandler : IRequestHandler<CreateNewCategory
CancellationToken cancellationToken)
{
var entity = request.Adapt<Category>();
await _context.Categorys.AddAsync(entity, cancellationToken);
await _context.Categories.AddAsync(entity, cancellationToken);
entity.AddDomainEvent(new CreateNewCategoryEvent(entity));
await _context.SaveChangesAsync(cancellationToken);
return entity.Adapt<CreateNewCategoryResponseDto>();

View File

@@ -11,10 +11,10 @@ public class DeleteCategoryCommandHandler : IRequestHandler<DeleteCategoryComman
public async Task<Unit> Handle(DeleteCategoryCommand request, CancellationToken cancellationToken)
{
var entity = await _context.Categorys
var entity = await _context.Categories
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) ?? throw new NotFoundException(nameof(Category), request.Id);
entity.IsDeleted = true;
_context.Categorys.Update(entity);
_context.Categories.Update(entity);
entity.AddDomainEvent(new DeleteCategoryEvent(entity));
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;

View File

@@ -11,10 +11,10 @@ public class UpdateCategoryCommandHandler : IRequestHandler<UpdateCategoryComman
public async Task<Unit> Handle(UpdateCategoryCommand request, CancellationToken cancellationToken)
{
var entity = await _context.Categorys
var entity = await _context.Categories
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) ?? throw new NotFoundException(nameof(Category), request.Id);
request.Adapt(entity);
_context.Categorys.Update(entity);
_context.Categories.Update(entity);
entity.AddDomainEvent(new UpdateCategoryEvent(entity));
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;

View File

@@ -10,7 +10,7 @@ public class GetAllCategoryByFilterQueryHandler : IRequestHandler<GetAllCategory
public async Task<GetAllCategoryByFilterResponseDto> Handle(GetAllCategoryByFilterQuery request, CancellationToken cancellationToken)
{
var query = _context.Categorys
var query = _context.Categories
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();

View File

@@ -11,7 +11,7 @@ public class GetCategoryQueryHandler : IRequestHandler<GetCategoryQuery, GetCate
public async Task<GetCategoryResponseDto> Handle(GetCategoryQuery request,
CancellationToken cancellationToken)
{
var response = await _context.Categorys
var response = await _context.Categories
.AsNoTracking()
.Where(x => x.Id == request.Id)
.ProjectToType<GetCategoryResponseDto>()

View File

@@ -0,0 +1,15 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
/// <summary>
/// Command برای فعال‌سازی عضویت باشگاه مشتریان یک کاربر
/// </summary>
public record ActivateClubMembershipCommand : IRequest<bool>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
}

View File

@@ -0,0 +1,246 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClubMembershipCommand, bool>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly ILogger<ActivateClubMembershipCommandHandler> _logger;
public ActivateClubMembershipCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
ILogger<ActivateClubMembershipCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_logger = logger;
}
public async Task<bool> Handle(
ActivateClubMembershipCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Activating club membership for UserId: {UserId}",
request.UserId
);
// 1. بررسی کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی اینکه پکیج خریده باشد
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
_logger.LogWarning(
"User {UserId} has not purchased golden package yet",
request.UserId
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
);
}
// 3. بررسی موجودی کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < 56_000_000)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
request.UserId,
wallet.Balance
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید"
);
}
// 4. پیدا کردن UserOrder با PackageId
var packageOrder = await _context.UserOrders
.Include(o => o.Transaction)
.Where(o =>
o.UserId == user.Id &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (packageOrder == null)
{
_logger.LogWarning(
"No successful package order found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
}
// 5. بررسی Transaction
if (packageOrder.Transaction == null)
{
_logger.LogError(
"Transaction not found for OrderId: {OrderId}",
packageOrder.Id
);
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
}
var transaction = packageOrder.Transaction;
if (transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1)
{
_logger.LogWarning(
"Invalid transaction type for OrderId {OrderId}: {Type}",
packageOrder.Id,
transaction.Type
);
throw new BadRequestException(
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
);
}
// 6. بررسی عضویت فعلی
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
// 6.1. دریافت مبلغ هدیه از تنظیمات
var giftValueConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
cancellationToken
);
long giftValue = 25_200_000; // مقدار پیش‌فرض
if (giftValueConfig != null && long.TryParse(giftValueConfig.Value, out var configValue))
{
giftValue = configValue;
_logger.LogInformation(
"Using Club.MembershipGiftValue from configuration: {GiftValue}",
giftValue
);
}
else
{
_logger.LogWarning(
"Club.MembershipGiftValue not found in configuration, using default: {GiftValue}",
giftValue
);
}
ClubMembership entity;
bool isNewMembership = existingMembership == null;
var activationDate = DateTime.UtcNow;
if (isNewMembership)
{
// ایجاد عضویت جدید
entity = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
InitialContribution = 56_000_000,
GiftValue = giftValue, // مقدار از تنظیمات
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
_context.ClubMemberships.Add(entity);
_logger.LogInformation(
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
user.Id,
user.PackagePurchaseMethod,
giftValue
);
}
else
{
if (existingMembership.IsActive)
{
_logger.LogInformation(
"User {UserId} is already an active club member",
user.Id
);
return true;
}
// فعال‌سازی مجدد
entity = existingMembership;
entity.IsActive = true;
entity.ActivatedAt = activationDate;
entity.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(entity);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 7. ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = entity.Id,
UserId = entity.UserId,
OldIsActive = !isNewMembership && !existingMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = isNewMembership
? $"Initial activation via {user.PackagePurchaseMethod}"
: $"Reactivated via {user.PackagePurchaseMethod}",
PerformedBy = _currentUser.GetPerformedBy()
};
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
user.Id,
entity.Id
);
return true;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in ActivateClubMembershipCommand for UserId: {UserId}",
request.UserId
);
throw;
}
}
}

View File

@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
public class ActivateClubMembershipCommandValidator : AbstractValidator<ActivateClubMembershipCommand>
{
public ActivateClubMembershipCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<ActivateClubMembershipCommand>.CreateWithOptions(
(ActivateClubMembershipCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
/// <summary>
/// Command برای اختصاص Feature به عضو باشگاه
/// </summary>
public record AssignClubFeatureCommand : IRequest<long>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// شناسه Feature
/// </summary>
public long FeatureId { get; init; }
/// <summary>
/// تاریخ اعطای Feature (اختیاری - پیش‌فرض: الان)
/// </summary>
public DateTime? GrantedAt { get; init; }
/// <summary>
/// یادداشت اختیاری
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
public class AssignClubFeatureCommandHandler : IRequestHandler<AssignClubFeatureCommand, long>
{
private readonly IApplicationDbContext _context;
public AssignClubFeatureCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(AssignClubFeatureCommand request, CancellationToken cancellationToken)
{
// بررسی وجود عضویت فعال
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == request.UserId && x.IsActive, cancellationToken);
if (membership == null)
{
throw new NotFoundException(nameof(ClubMembership), $"Active membership for UserId: {request.UserId}");
}
// بررسی وجود Feature
var featureExists = await _context.ClubFeatures
.AnyAsync(x => x.Id == request.FeatureId && x.IsActive, cancellationToken);
if (!featureExists)
{
throw new NotFoundException(nameof(ClubFeature), request.FeatureId);
}
// بررسی وجود قبلی
var existingAssignment = await _context.UserClubFeatures
.FirstOrDefaultAsync(x =>
x.UserId == request.UserId &&
x.ClubFeatureId == request.FeatureId,
cancellationToken);
UserClubFeature entity;
if (existingAssignment != null)
{
// به‌روزرسانی notes
entity = existingAssignment;
entity.Notes = request.Notes;
_context.UserClubFeatures.Update(entity);
}
else
{
// ایجاد جدید
entity = new UserClubFeature
{
UserId = request.UserId,
ClubMembershipId = membership.Id,
ClubFeatureId = request.FeatureId,
GrantedAt = request.GrantedAt ?? DateTime.UtcNow,
Notes = request.Notes
};
await _context.UserClubFeatures.AddAsync(entity, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,33 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
public class AssignClubFeatureCommandValidator : AbstractValidator<AssignClubFeatureCommand>
{
public AssignClubFeatureCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.FeatureId)
.GreaterThan(0)
.WithMessage("شناسه Feature معتبر نیست");
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("طول یادداشت نباید بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Notes));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<AssignClubFeatureCommand>.CreateWithOptions(
(AssignClubFeatureCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
/// <summary>
/// Command برای غیرفعال‌سازی عضویت باشگاه مشتریان
/// </summary>
public record DeactivateClubMembershipCommand : IRequest<Unit>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// دلیل غیرفعال‌سازی
/// </summary>
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,54 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
public class DeactivateClubMembershipCommandHandler : IRequestHandler<DeactivateClubMembershipCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public DeactivateClubMembershipCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(DeactivateClubMembershipCommand request, CancellationToken cancellationToken)
{
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == request.UserId, cancellationToken);
if (membership == null)
{
throw new NotFoundException(nameof(ClubMembership), $"UserId: {request.UserId}");
}
// اگر از قبل غیرفعال است، هیچ کاری نکن
if (!membership.IsActive)
{
return Unit.Value;
}
membership.IsActive = false;
_context.ClubMemberships.Update(membership);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = membership.Id,
UserId = membership.UserId,
OldIsActive = true,
NewIsActive = false,
Action = ClubMembershipAction.Deactivated,
Reason = request.Reason ?? "Manual deactivation",
PerformedBy = _currentUser.GetPerformedBy()
};
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
public class DeactivateClubMembershipCommandValidator : AbstractValidator<DeactivateClubMembershipCommand>
{
public DeactivateClubMembershipCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.Reason)
.MaximumLength(500)
.WithMessage("دلیل غیرفعال‌سازی نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Reason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<DeactivateClubMembershipCommand>.CreateWithOptions(
(DeactivateClubMembershipCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,45 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
/// <summary>
/// Query برای دریافت لیست عضویت‌های باشگاه
/// </summary>
public record GetAllClubMembershipsQuery : IRequest<GetAllClubMembershipsResponseDto>
{
/// <summary>
/// موقعیت صفحه‌بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// فیلتر
/// </summary>
public GetAllClubMembershipsFilter? Filter { get; init; }
}
public class GetAllClubMembershipsFilter
{
/// <summary>
/// فیلتر بر اساس شناسه کاربر
/// </summary>
public long? UserId { get; set; }
/// <summary>
/// فقط عضویت‌های فعال
/// </summary>
public bool? IsActive { get; set; }
/// <summary>
/// فیلتر بر اساس تاریخ فعال‌سازی (از)
/// </summary>
public DateTimeOffset? ActivationDateFrom { get; set; }
/// <summary>
/// فیلتر بر اساس تاریخ فعال‌سازی (تا)
/// </summary>
public DateTimeOffset? ActivationDateTo { get; set; }
}

View File

@@ -0,0 +1,51 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembershipsQuery, GetAllClubMembershipsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllClubMembershipsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken)
{
var query = _context.ClubMemberships
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.UserId == null || x.UserId == request.Filter.UserId)
.Where(x => request.Filter.IsActive == null || x.IsActive == request.Filter.IsActive)
.Where(x => request.Filter.ActivationDateFrom == null || x.ActivatedAt >= request.Filter.ActivationDateFrom)
.Where(x => request.Filter.ActivationDateTo == null || x.ActivatedAt <= request.Filter.ActivationDateTo);
}
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllClubMembershipsResponseModel
{
Id = x.Id,
UserId = x.UserId,
IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt,
InitialContribution = x.InitialContribution,
TotalEarned = x.TotalEarned,
Created = x.Created,
LastModified = x.LastModified
})
.ToListAsync(cancellationToken);
return new GetAllClubMembershipsResponseDto
{
MetaData = meta,
Models = models
};
}
}

View File

@@ -0,0 +1,30 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
public class GetAllClubMembershipsQueryValidator : AbstractValidator<GetAllClubMembershipsQuery>
{
public GetAllClubMembershipsQueryValidator()
{
RuleFor(x => x.Filter.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.Filter?.UserId != null);
RuleFor(x => x.Filter.ActivationDateTo)
.GreaterThanOrEqualTo(x => x.Filter.ActivationDateFrom)
.WithMessage("تاریخ پایان باید بعد از تاریخ شروع باشد")
.When(x => x.Filter?.ActivationDateFrom != null && x.Filter?.ActivationDateTo != null);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetAllClubMembershipsQuery>.CreateWithOptions(
(GetAllClubMembershipsQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
public class GetAllClubMembershipsResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllClubMembershipsResponseModel> Models { get; set; }
}
public class GetAllClubMembershipsResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; }
public long TotalEarned { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? LastModified { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
/// <summary>
/// DTO برای نمایش اطلاعات عضویت باشگاه
/// </summary>
public class ClubMembershipDto
{
public long Id { get; set; }
public long UserId { get; set; }
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; }
public long TotalEarned { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? LastModified { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
/// <summary>
/// Query برای دریافت عضویت باشگاه یک کاربر
/// </summary>
public record GetClubMembershipQuery : IRequest<ClubMembershipDto?>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
{
private readonly IApplicationDbContext _context;
public GetClubMembershipQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
{
var membership = await _context.ClubMemberships
.AsNoTracking()
.Where(x => x.UserId == request.UserId)
.Select(x => new ClubMembershipDto
{
Id = x.Id,
UserId = x.UserId,
IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt,
InitialContribution = x.InitialContribution,
TotalEarned = x.TotalEarned,
Created = x.Created,
LastModified = x.LastModified
})
.FirstOrDefaultAsync(cancellationToken);
return membership;
}
}

View File

@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
public class GetClubMembershipQueryValidator : AbstractValidator<GetClubMembershipQuery>
{
public GetClubMembershipQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetClubMembershipQuery>.CreateWithOptions(
(GetClubMembershipQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات عضویت باشگاه
/// </summary>
public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto>
{
/// <summary>
/// شناسه عضویت (اختیاری)
/// </summary>
public long? MembershipId { get; init; }
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// موقعیت صفحه‌بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
}

View File

@@ -0,0 +1,56 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembershipHistoryQuery, GetClubMembershipHistoryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetClubMembershipHistoryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken)
{
var query = _context.ClubMembershipHistories
.AsNoTracking()
.AsQueryable();
if (request.MembershipId.HasValue)
{
query = query.Where(x => x.ClubMembershipId == request.MembershipId.Value);
}
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); // پیش‌فرض: جدیدترین اول
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetClubMembershipHistoryResponseModel
{
Id = x.Id,
ClubMembershipId = x.ClubMembershipId,
UserId = x.UserId,
OldIsActive = x.OldIsActive,
NewIsActive = x.NewIsActive,
OldInitialContribution = x.OldInitialContribution,
NewInitialContribution = x.NewInitialContribution,
Action = x.Action,
Reason = x.Reason,
PerformedBy = x.PerformedBy,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetClubMembershipHistoryResponseDto
{
MetaData = meta,
Models = models
};
}
}

View File

@@ -0,0 +1,34 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
public class GetClubMembershipHistoryQueryValidator : AbstractValidator<GetClubMembershipHistoryQuery>
{
public GetClubMembershipHistoryQueryValidator()
{
RuleFor(x => x.MembershipId)
.GreaterThan(0)
.WithMessage("شناسه عضویت معتبر نیست")
.When(x => x.MembershipId.HasValue);
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x)
.Must(x => x.MembershipId.HasValue || x.UserId.HasValue)
.WithMessage("حداقل یکی از MembershipId یا UserId باید مقداردهی شود");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetClubMembershipHistoryQuery>.CreateWithOptions(
(GetClubMembershipHistoryQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
public class GetClubMembershipHistoryResponseDto
{
public MetaData MetaData { get; set; }
public List<GetClubMembershipHistoryResponseModel> Models { get; set; }
}
public class GetClubMembershipHistoryResponseModel
{
public long Id { get; set; }
public long ClubMembershipId { get; set; }
public long UserId { get; set; }
public bool OldIsActive { get; set; }
public bool NewIsActive { get; set; }
public long? OldInitialContribution { get; set; }
public long? NewInitialContribution { get; set; }
public ClubMembershipAction Action { get; set; }
public string? Reason { get; set; }
public string? PerformedBy { get; set; }
public DateTimeOffset Created { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
public class GetClubStatisticsQuery : IRequest<GetClubStatisticsResponseDto>
{
// No parameters - returns overall statistics
}

View File

@@ -0,0 +1,95 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
public class GetClubStatisticsQueryHandler : IRequestHandler<GetClubStatisticsQuery, GetClubStatisticsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetClubStatisticsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetClubStatisticsResponseDto> Handle(GetClubStatisticsQuery request, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
// Basic statistics
var totalMembers = await _context.ClubMemberships.CountAsync(cancellationToken);
var activeMembers = await _context.ClubMemberships
.Where(x => x.IsActive)
.CountAsync(cancellationToken);
var inactiveMembers = totalMembers - activeMembers;
var expiredMembers = 0; // Since there's no expiration tracking in the model
double activePercentage = totalMembers > 0 ? (activeMembers / (double)totalMembers) * 100 : 0;
// Package distribution - ClubMembership doesn't have PackageId
// We'll return empty list for now or create mock data
var packageDistribution = new List<PackageLevelDistributionModel>();
// Monthly trend (last 6 months)
var sixMonthsAgo = now.AddMonths(-6);
var activations = await _context.ClubMemberships
.Where(x => x.ActivatedAt >= sixMonthsAgo && x.ActivatedAt != null)
.GroupBy(x => new { x.ActivatedAt!.Value.Year, x.ActivatedAt.Value.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
.ToListAsync(cancellationToken);
var monthlyTrend = new List<MonthlyMembershipTrendModel>();
for (int i = 5; i >= 0; i--)
{
var targetDate = now.AddMonths(-i);
var year = targetDate.Year;
var month = targetDate.Month;
var activationCount = activations.FirstOrDefault(x => x.Year == year && x.Month == month)?.Count ?? 0;
monthlyTrend.Add(new MonthlyMembershipTrendModel
{
Month = $"{year}-{month:D2}",
Activations = activationCount,
Expirations = 0, // No expiration tracking
NetChange = activationCount
});
}
// Total revenue - sum of initial contributions
var totalRevenue = await _context.ClubMemberships
.SumAsync(x => x.InitialContribution, cancellationToken);
// Average membership duration - calculate from ActivatedAt to now
var activeMemberships = await _context.ClubMemberships
.Where(x => x.IsActive && x.ActivatedAt != null)
.Select(x => x.ActivatedAt!.Value)
.ToListAsync(cancellationToken);
double averageDuration = 0;
if (activeMemberships.Any())
{
var durations = activeMemberships
.Select(activatedAt => (now - activatedAt).TotalDays)
.ToList();
averageDuration = durations.Average();
}
// Expiring soon count - not applicable since no expiration tracking
int expiringSoonCount = 0;
return new GetClubStatisticsResponseDto
{
TotalMembers = totalMembers,
ActiveMembers = activeMembers,
InactiveMembers = inactiveMembers,
ExpiredMembers = expiredMembers,
ActivePercentage = activePercentage,
PackageDistribution = packageDistribution,
MonthlyTrend = monthlyTrend,
TotalRevenue = totalRevenue,
AverageMembershipDurationDays = averageDuration,
ExpiringSoonCount = expiringSoonCount
};
}
}

View File

@@ -0,0 +1,31 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubStatistics;
public class GetClubStatisticsResponseDto
{
public int TotalMembers { get; set; }
public int ActiveMembers { get; set; }
public int InactiveMembers { get; set; }
public int ExpiredMembers { get; set; }
public double ActivePercentage { get; set; }
public List<PackageLevelDistributionModel> PackageDistribution { get; set; } = new();
public List<MonthlyMembershipTrendModel> MonthlyTrend { get; set; } = new();
public long TotalRevenue { get; set; }
public double AverageMembershipDurationDays { get; set; }
public int ExpiringSoonCount { get; set; }
}
public class PackageLevelDistributionModel
{
public long PackageId { get; set; }
public string PackageName { get; set; } = string.Empty;
public int MemberCount { get; set; }
public double Percentage { get; set; }
}
public class MonthlyMembershipTrendModel
{
public string Month { get; set; } = string.Empty;
public int Activations { get; set; }
public int Expirations { get; set; }
public int NetChange { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ApproveWithdrawal;
public class ApproveWithdrawalCommand : IRequest<Unit>
{
public long PayoutId { get; set; }
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,61 @@
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Application.Common.Exceptions;
namespace CMSMicroservice.Application.CommissionCQ.Commands.ApproveWithdrawal;
public class ApproveWithdrawalCommandHandler : IRequestHandler<ApproveWithdrawalCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public ApproveWithdrawalCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(ApproveWithdrawalCommand request, CancellationToken cancellationToken)
{
var payout = await _context.UserCommissionPayouts
.FirstOrDefaultAsync(x => x.Id == request.PayoutId, cancellationToken);
if (payout == null)
{
throw new NotFoundException($"Payout با شناسه {request.PayoutId} یافت نشد");
}
if (payout.Status != CommissionPayoutStatus.WithdrawRequested)
{
throw new BadRequestException($"فقط درخواست‌های در وضعیت WithdrawRequested قابل تایید هستند");
}
// Update status to Withdrawn (approved)
payout.Status = CommissionPayoutStatus.Withdrawn;
payout.WithdrawnAt = DateTime.UtcNow;
payout.ProcessedBy = _currentUser.GetPerformedBy();
payout.ProcessedAt = DateTime.UtcNow;
payout.LastModified = DateTime.UtcNow;
// TODO: Add PayoutHistory record
// var history = new CommissionPayoutHistory
// {
// PayoutId = payout.Id,
// UserId = payout.UserId,
// WeekNumber = payout.WeekNumber,
// AmountBefore = payout.TotalAmount,
// AmountAfter = payout.TotalAmount,
// OldStatus = (int)CommissionPayoutStatus.Pending,
// NewStatus = (int)CommissionPayoutStatus.Approved,
// Action = (int)CommissionPayoutAction.Approved,
// PerformedBy = "Admin", // TODO: Get from authenticated user
// Reason = request.Notes,
// Created = DateTime.UtcNow
// };
// _context.CommissionPayoutHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
/// <summary>
/// Command برای محاسبه تعادل‌های هفتگی شبکه
/// </summary>
public record CalculateWeeklyBalancesCommand : IRequest<int>
{
/// <summary>
/// شماره هفته (فرمت: YYYY-Www مثل 2025-W01)
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
/// <summary>
/// آیا محاسبه مجدد انجام شود؟ (پیش‌فرض: false)
/// </summary>
public bool ForceRecalculate { get; init; }
}

View File

@@ -0,0 +1,251 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
{
private readonly IApplicationDbContext _context;
public CalculateWeeklyBalancesCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{
// بررسی وجود محاسبه قبلی
var existingBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.ToListAsync(cancellationToken);
if (existingBalances.Any() && !request.ForceRecalculate)
{
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
}
// حذف محاسبات قبلی در صورت ForceRecalculate
if (existingBalances.Any())
{
_context.NetworkWeeklyBalances.RemoveRange(existingBalances);
await _context.SaveChangesAsync(cancellationToken);
}
// دریافت کاربران فعال در شبکه
var usersInNetwork = await _context.Users
.Where(x => x.NetworkParentId.HasValue)
.Select(x => new { x.Id })
.ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == previousWeekNumber)
.Select(x => new
{
x.UserId,
x.LeftLegRemainder,
x.RightLegRemainder
})
.ToDictionaryAsync(x => x.UserId, cancellationToken);
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.UtcNow;
// خواندن یکباره Configuration ها (بهینه‌سازی - به جای N query)
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Club.ActivationFee" ||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300"));
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
foreach (var user in usersInNetwork)
{
// دریافت باقیمانده هفته قبل
var leftCarryover = 0;
var rightCarryover = 0;
if (previousWeekCarryovers.ContainsKey(user.Id))
{
leftCarryover = previousWeekCarryovers[user.Id].LeftLegRemainder;
rightCarryover = previousWeekCarryovers[user.Id].RightLegRemainder;
}
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber, maxNetworkLevel, cancellationToken);
// محاسبه مجموع هر پا (جدید + باقیمانده)
var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover;
// ✅ اصلاح شده: اعمال سقف روی هر دست جداگانه (نه روی کل)
// سقف 300 برای دست چپ + 300 برای دست راست = حداکثر 600 تعادل در هفته
var cappedLeftTotal = Math.Min(leftTotal, maxBalancesPerLeg);
var cappedRightTotal = Math.Min(rightTotal, maxBalancesPerLeg);
// محاسبه تعادل (کمترین مقدار بعد از اعمال سقف)
var totalBalances = Math.Min(cappedLeftTotal, cappedRightTotal);
// محاسبه باقیمانده برای هفته بعد
// باقیمانده = مقداری که از سقف هر دست رد شده
// مثال: چپ=350، راست=450، سقف=300
// cappedLeft = MIN(350, 300) = 300
// cappedRight = MIN(450, 300) = 300
// totalBalances = MIN(300, 300) = 300
// leftRemainder = 350 - 300 = 50 (مازاد سقف)
// rightRemainder = 450 - 300 = 150 (مازاد سقف)
var leftRemainder = leftTotal - cappedLeftTotal;
var rightRemainder = rightTotal - cappedRightTotal;
// محاسبه سهم استخر (20% از مجموع فعال‌سازی‌های جدید کل شبکه)
// طبق گفته دکتر: کل افراد جدید در شبکه × هزینه فعال‌سازی × 20%
var totalNewMembers = leftNewMembers + rightNewMembers;
var weeklyPoolContribution = (long)(totalNewMembers * activationFee * poolPercent);
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekNumber = request.WeekNumber,
// اطلاعات جدید
LeftLegNewMembers = leftNewMembers,
RightLegNewMembers = rightNewMembers,
LeftLegCarryover = leftCarryover,
RightLegCarryover = rightCarryover,
// مجموع
LeftLegTotal = leftTotal,
RightLegTotal = rightTotal,
TotalBalances = totalBalances, // تعادل واقعی بعد از اعمال سقف روی هر دست
// باقیمانده برای هفته بعد (مازاد سقف هر دست)
LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder,
// فیلدهای قدیمی (deprecated) - برای سازگاری با کدهای قبلی
#pragma warning disable CS0618
LeftLegBalances = leftTotal,
RightLegBalances = rightTotal,
#pragma warning restore CS0618
WeeklyPoolContribution = weeklyPoolContribution,
CalculatedAt = calculatedAt,
IsExpired = false
};
balancesList.Add(balance);
}
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return balancesList.Count;
}
/// <summary>
/// شماره هفته قبل را محاسبه می‌کند
/// </summary>
private string GetPreviousWeekNumber(string currentWeekNumber)
{
// مثال: "2025-W48" -> "2025-W47"
var parts = currentWeekNumber.Split('-');
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
week--;
if (week < 1)
{
year--;
week = 52; // یا 53 بسته به سال
}
return $"{year}-W{week:D2}";
}
/// <summary>
/// شمارش اعضای جدیدی که در این هفته به یک پا اضافه شدند
/// تا maxLevel لول پایین‌تر شمارش می‌شود
/// </summary>
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, string weekNumber, int maxLevel, CancellationToken cancellationToken)
{
// تبدیل WeekNumber به بازه تاریخی
var (startDate, endDate) = GetWeekDateRange(weekNumber);
// شمارش تمام اعضای زیرمجموعه که در این هفته فعال شدند (تا maxLevel لول)
var count = await CountNewMembersRecursive(userId, leg, startDate, endDate, 0, maxLevel, cancellationToken);
return count;
}
/// <summary>
/// شمارش بازگشتی اعضای جدید در یک پا
/// محدودیت عمق: تا maxLevel لول پایین‌تر شمارش می‌شود
/// </summary>
private async Task<int> CountNewMembersRecursive(
long userId,
NetworkLeg leg,
DateTime startDate,
DateTime endDate,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
// ⭐ محدودیت عمق: اگر به حداکثر لول رسیدیم، توقف
if (currentLevel >= maxLevel)
{
return 0;
}
// پیدا کردن فرزند مستقیم در پای مورد نظر
var child = await _context.Users
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg, cancellationToken);
if (child == null)
{
return 0;
}
var count = 0;
// اگر فرزند در این هفته فعال شده، 1 امتیاز
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
{
count = 1;
}
// جمع کردن اعضای جدید از پای چپ و راست فرزند (با افزایش لول)
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate, currentLevel + 1, maxLevel, cancellationToken);
return count + childLeft + childRight;
}
/// <summary>
/// تبدیل شماره هفته به بازه تاریخی
/// </summary>
private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
{
// مثال: "2025-W48"
var parts = weekNumber.Split('-');
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
// محاسبه اولین روز هفته (شنبه)
var jan1 = new DateTime(year, 1, 1);
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek;
var firstSaturday = jan1.AddDays(daysOffset);
var weekStart = firstSaturday.AddDays((week - 1) * 7);
var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
return (weekStart, weekEnd);
}
}

View File

@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
public class CalculateWeeklyBalancesCommandValidator : AbstractValidator<CalculateWeeklyBalancesCommand>
{
public CalculateWeeklyBalancesCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد (مثل 2025-W01)");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<CalculateWeeklyBalancesCommand>.CreateWithOptions(
(CalculateWeeklyBalancesCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
/// <summary>
/// Command برای محاسبه استخر کمیسیون هفتگی
/// </summary>
public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
{
/// <summary>
/// شماره هفته (فرمت: YYYY-Www)
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
/// <summary>
/// آیا محاسبه مجدد انجام شود؟
/// </summary>
public bool ForceRecalculate { get; init; }
}

View File

@@ -0,0 +1,78 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<CalculateWeeklyCommissionPoolCommand, long>
{
private readonly IApplicationDbContext _context;
public CalculateWeeklyCommissionPoolCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(CalculateWeeklyCommissionPoolCommand request, CancellationToken cancellationToken)
{
// بررسی وجود استخر قبلی
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken);
if (existingPool != null && existingPool.IsCalculated && !request.ForceRecalculate)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} قبلاً محاسبه شده است");
}
// بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.ToListAsync(cancellationToken);
if (!weeklyBalances.Any())
{
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
}
// محاسبه مجموع مشارکت‌ها در استخر
var totalPoolAmount = weeklyBalances.Sum(x => x.WeeklyPoolContribution);
// محاسبه مجموع Balances
var totalBalances = weeklyBalances.Sum(x => x.TotalBalances);
// محاسبه ارزش هر Balance (تقسیم صحیح برای ریال)
long valuePerBalance = 0;
if (totalBalances > 0)
{
valuePerBalance = totalPoolAmount / totalBalances;
}
if (existingPool != null)
{
// به‌روزرسانی
existingPool.TotalPoolAmount = totalPoolAmount;
existingPool.TotalBalances = totalBalances;
existingPool.ValuePerBalance = valuePerBalance;
existingPool.IsCalculated = true;
existingPool.CalculatedAt = DateTime.UtcNow;
_context.WeeklyCommissionPools.Update(existingPool);
}
else
{
// ایجاد جدید
var pool = new WeeklyCommissionPool
{
WeekNumber = request.WeekNumber,
TotalPoolAmount = totalPoolAmount,
TotalBalances = totalBalances,
ValuePerBalance = valuePerBalance,
IsCalculated = true,
CalculatedAt = DateTime.UtcNow
};
await _context.WeeklyCommissionPools.AddAsync(pool, cancellationToken);
existingPool = pool;
}
await _context.SaveChangesAsync(cancellationToken);
return existingPool.Id;
}
}

View File

@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
public class CalculateWeeklyCommissionPoolCommandValidator : AbstractValidator<CalculateWeeklyCommissionPoolCommand>
{
public CalculateWeeklyCommissionPoolCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<CalculateWeeklyCommissionPoolCommand>.CreateWithOptions(
(CalculateWeeklyCommissionPoolCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
/// <summary>
/// Command برای پردازش و توزیع کمیسیون به کاربران
/// </summary>
public record ProcessUserPayoutsCommand : IRequest<int>
{
/// <summary>
/// شماره هفته
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
/// <summary>
/// آیا پرداخت مجدد انجام شود؟
/// </summary>
public bool ForceReprocess { get; init; }
}

View File

@@ -0,0 +1,238 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
{
private readonly IApplicationDbContext _context;
public ProcessUserPayoutsCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(ProcessUserPayoutsCommand request, CancellationToken cancellationToken)
{
// بررسی وجود استخر
var pool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken);
if (pool == null || !pool.IsCalculated)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} هنوز محاسبه نشده است");
}
// بررسی پرداخت قبلی
var existingPayouts = await _context.UserCommissionPayouts
.Where(x => x.WeekNumber == request.WeekNumber)
.ToListAsync(cancellationToken);
if (existingPayouts.Any() && !request.ForceReprocess)
{
throw new InvalidOperationException($"پرداخت‌های هفته {request.WeekNumber} قبلاً انجام شده است");
}
// حذف پرداخت‌های قبلی در صورت ForceReprocess
if (existingPayouts.Any())
{
_context.UserCommissionPayouts.RemoveRange(existingPayouts);
await _context.SaveChangesAsync(cancellationToken);
}
// ⭐ خواندن MaxNetworkLevel از Config
var maxNetworkLevelConfig = await _context.SystemConfigurations
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
.Select(x => x.Value)
.FirstOrDefaultAsync(cancellationToken);
var maxNetworkLevel = int.Parse(maxNetworkLevelConfig ?? "15");
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.ToDictionaryAsync(x => x.UserId, cancellationToken);
// دریافت کاربرانی که تعادل > 0 دارند (یا زیرمجموعه‌شان دارد)
var usersWithBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber && x.TotalBalances > 0)
.Select(x => x.UserId)
.ToListAsync(cancellationToken);
// پیدا کردن تمام کاربرانی که باید کمیسیون بگیرند (شامل والدین)
var usersToProcess = new HashSet<long>(usersWithBalances);
// اضافه کردن والدین تا 15 لول بالاتر
foreach (var userId in usersWithBalances)
{
var ancestors = await GetAncestors(userId, maxNetworkLevel, cancellationToken);
foreach (var ancestorId in ancestors)
{
usersToProcess.Add(ancestorId);
}
}
var payoutsList = new List<UserCommissionPayout>();
foreach (var userId in usersToProcess)
{
// ⭐ محاسبه تعادل شخصی
var personalBalances = 0;
if (allWeeklyBalances.ContainsKey(userId))
{
personalBalances = allWeeklyBalances[userId].TotalBalances;
}
// ⭐ محاسبه مجموع تعادل‌های زیرمجموعه تا maxNetworkLevel لول
var subordinateBalances = await CalculateSubordinateBalancesAsync(
userId,
request.WeekNumber,
allWeeklyBalances,
maxNetworkLevel,
cancellationToken
);
// ⭐ مجموع تعادل = شخصی + زیرمجموعه
var totalBalancesWithSubordinates = personalBalances + subordinateBalances;
// اگر مجموع تعادل صفر است، نیازی به ثبت نیست
if (totalBalancesWithSubordinates <= 0)
{
continue;
}
// محاسبه مبلغ کمیسیون
var totalAmount = (long)(totalBalancesWithSubordinates * pool.ValuePerBalance);
var payout = new UserCommissionPayout
{
UserId = userId,
WeekNumber = request.WeekNumber,
WeeklyPoolId = pool.Id,
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
ValuePerBalance = pool.ValuePerBalance,
TotalAmount = totalAmount,
Status = CommissionPayoutStatus.Pending,
PaidAt = null,
WithdrawalMethod = null,
IbanNumber = null,
WithdrawnAt = null
};
payoutsList.Add(payout);
}
await _context.UserCommissionPayouts.AddRangeAsync(payoutsList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه برای هر پرداخت
var historyList = new List<CommissionPayoutHistory>();
foreach (var payout in payoutsList)
{
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = request.WeekNumber,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
NewStatus = CommissionPayoutStatus.Pending,
Action = CommissionPayoutAction.Created,
PerformedBy = "System",
Reason = "پردازش خودکار کمیسیون هفتگی"
};
historyList.Add(history);
}
await _context.CommissionPayoutHistories.AddRangeAsync(historyList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return payoutsList.Count;
}
/// <summary>
/// پیدا کردن والدین یک کاربر تا N لول بالاتر
/// </summary>
private async Task<List<long>> GetAncestors(long userId, int maxLevels, CancellationToken cancellationToken)
{
var ancestors = new List<long>();
var currentUserId = userId;
for (int level = 0; level < maxLevels; level++)
{
var user = await _context.Users
.Where(x => x.Id == currentUserId)
.Select(x => x.NetworkParentId)
.FirstOrDefaultAsync(cancellationToken);
if (user == null || !user.HasValue)
{
break;
}
ancestors.Add(user.Value);
currentUserId = user.Value;
}
return ancestors;
}
/// <summary>
/// محاسبه مجموع تعادل‌های زیرمجموعه یک کاربر تا N لول پایین‌تر
/// </summary>
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
string weekNumber,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
{
// پیدا کردن همه زیرمجموعه‌ها تا maxLevel لول
var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel, cancellationToken);
// جمع تعادل‌های آنها
var totalSubordinateBalances = 0;
foreach (var subordinateId in subordinates)
{
if (allBalances.ContainsKey(subordinateId))
{
totalSubordinateBalances += allBalances[subordinateId].TotalBalances;
}
}
return totalSubordinateBalances;
}
/// <summary>
/// پیدا کردن بازگشتی زیرمجموعه‌ها تا N لول
/// </summary>
private async Task<List<long>> GetSubordinatesRecursive(
long userId,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
// محدودیت عمق
if (currentLevel > maxLevel)
{
return new List<long>();
}
var result = new List<long>();
// پیدا کردن فرزندان مستقیم
var children = await _context.Users
.Where(x => x.NetworkParentId == userId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
result.AddRange(children);
// بازگشت برای هر فرزند
foreach (var childId in children)
{
var grandChildren = await GetSubordinatesRecursive(childId, currentLevel + 1, maxLevel, cancellationToken);
result.AddRange(grandChildren);
}
return result;
}
}

View File

@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
public class ProcessUserPayoutsCommandValidator : AbstractValidator<ProcessUserPayoutsCommand>
{
public ProcessUserPayoutsCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<ProcessUserPayoutsCommand>.CreateWithOptions(
(ProcessUserPayoutsCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
/// <summary>
/// Command برای پردازش برداشت (توسط Admin)
/// </summary>
public record ProcessWithdrawalCommand : IRequest<Unit>
{
/// <summary>
/// شناسه پرداخت کمیسیون
/// </summary>
public long PayoutId { get; init; }
/// <summary>
/// آیا تایید شده است؟
/// </summary>
public bool IsApproved { get; init; }
/// <summary>
/// دلیل (در صورت رد)
/// </summary>
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawalCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ILogger<ProcessWithdrawalCommandHandler> _logger;
public ProcessWithdrawalCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
IPaymentGatewayService paymentGateway,
ILogger<ProcessWithdrawalCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_paymentGateway = paymentGateway;
_logger = logger;
}
public async Task<Unit> Handle(ProcessWithdrawalCommand request, CancellationToken cancellationToken)
{
var payout = await _context.UserCommissionPayouts
.Include(x => x.User)
.FirstOrDefaultAsync(x => x.Id == request.PayoutId, cancellationToken);
if (payout == null)
{
throw new NotFoundException(nameof(UserCommissionPayout), request.PayoutId);
}
// بررسی وضعیت
if (payout.Status != CommissionPayoutStatus.WithdrawRequested)
{
throw new InvalidOperationException($"فقط درخواست‌های با وضعیت WithdrawRequested قابل پردازش هستند. وضعیت فعلی: {payout.Status}");
}
var oldStatus = payout.Status;
var now = DateTime.UtcNow;
if (request.IsApproved)
{
// تایید برداشت
if (payout.WithdrawalMethod == WithdrawalMethod.Diamond)
{
// روش Diamond: شارژ کیف پول تخفیف
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(x => x.UserId == payout.UserId, cancellationToken);
if (wallet != null)
{
wallet.DiscountBalance += payout.TotalAmount;
_context.UserWallets.Update(wallet);
}
payout.Status = CommissionPayoutStatus.Withdrawn;
payout.WithdrawnAt = now;
}
else if (payout.WithdrawalMethod == WithdrawalMethod.Cash)
{
// روش انتقال بانکی: فراخوانی Payment Gateway
try
{
_logger.LogInformation("Processing bank transfer for Payout {PayoutId}, User {UserId}, Amount {Amount}",
payout.Id, payout.UserId, payout.TotalAmount);
var payoutRequest = new PayoutRequest
{
Amount = payout.TotalAmount,
UserId = payout.UserId,
Iban = payout.IbanNumber ?? throw new InvalidOperationException("شماره شبا یافت نشد"),
AccountHolderName = $"{payout.User.FirstName} {payout.User.LastName}",
Description = $"برداشت کمیسیون هفته {payout.WeekNumber}",
InternalRefId = $"PAYOUT-{payout.Id}"
};
var payoutResult = await _paymentGateway.ProcessPayoutAsync(payoutRequest, cancellationToken);
if (payoutResult.IsSuccess)
{
payout.Status = CommissionPayoutStatus.Withdrawn;
payout.WithdrawnAt = now;
payout.BankReferenceId = payoutResult.BankRefId;
payout.BankTrackingCode = payoutResult.TrackingCode;
_logger.LogInformation("Bank transfer successful: Payout {PayoutId}, BankRef {BankRef}",
payout.Id, payoutResult.BankRefId);
}
else
{
// خطا در واریز
payout.Status = CommissionPayoutStatus.PaymentFailed;
payout.PaymentFailureReason = payoutResult.Message;
_logger.LogError("Bank transfer failed: Payout {PayoutId}, Reason: {Reason}",
payout.Id, payoutResult.Message);
throw new InvalidOperationException($"خطا در واریز: {payoutResult.Message}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during bank transfer for Payout {PayoutId}", payout.Id);
payout.Status = CommissionPayoutStatus.PaymentFailed;
payout.PaymentFailureReason = ex.Message;
throw;
}
}
_context.UserCommissionPayouts.Update(payout);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
NewStatus = payout.Status,
Action = CommissionPayoutAction.Withdrawn,
PerformedBy = _currentUser.UserId ?? "Admin",
Reason = $"تایید برداشت به روش {payout.WithdrawalMethod}"
};
await _context.CommissionPayoutHistories.AddAsync(history, cancellationToken);
}
else
{
// رد برداشت - برگشت به وضعیت Paid
payout.Status = CommissionPayoutStatus.Paid;
payout.WithdrawalMethod = null;
payout.IbanNumber = null;
_context.UserCommissionPayouts.Update(payout);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
NewStatus = CommissionPayoutStatus.Paid,
Action = CommissionPayoutAction.Cancelled,
PerformedBy = _currentUser.UserId ?? "Admin",
Reason = request.Reason ?? "درخواست برداشت رد شد"
};
await _context.CommissionPayoutHistories.AddAsync(history, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,31 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
public class ProcessWithdrawalCommandValidator : AbstractValidator<ProcessWithdrawalCommand>
{
public ProcessWithdrawalCommandValidator()
{
RuleFor(x => x.PayoutId)
.GreaterThan(0)
.WithMessage("شناسه پرداخت معتبر نیست");
RuleFor(x => x.Reason)
.NotEmpty()
.WithMessage("دلیل رد الزامی است")
.MaximumLength(500)
.WithMessage("طول دلیل نباید بیشتر از 500 کاراکتر باشد")
.When(x => !x.IsApproved);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<ProcessWithdrawalCommand>.CreateWithOptions(
(ProcessWithdrawalCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,7 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.RejectWithdrawal;
public class RejectWithdrawalCommand : IRequest<Unit>
{
public long PayoutId { get; set; }
public string Reason { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,61 @@
using CMSMicroservice.Domain.Enums;
using CMSMicroservice.Application.Common.Exceptions;
namespace CMSMicroservice.Application.CommissionCQ.Commands.RejectWithdrawal;
public class RejectWithdrawalCommandHandler : IRequestHandler<RejectWithdrawalCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public RejectWithdrawalCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(RejectWithdrawalCommand request, CancellationToken cancellationToken)
{
var payout = await _context.UserCommissionPayouts
.FirstOrDefaultAsync(x => x.Id == request.PayoutId, cancellationToken);
if (payout == null)
{
throw new NotFoundException($"Payout با شناسه {request.PayoutId} یافت نشد");
}
if (payout.Status != CommissionPayoutStatus.WithdrawRequested)
{
throw new BadRequestException($"فقط درخواست‌های در وضعیت WithdrawRequested قابل رد هستند");
}
// Update status to Cancelled (rejected)
payout.Status = CommissionPayoutStatus.Cancelled;
payout.ProcessedBy = _currentUser.GetPerformedBy();
payout.ProcessedAt = DateTime.UtcNow;
payout.RejectionReason = request.Reason;
payout.LastModified = DateTime.UtcNow;
// TODO: Add PayoutHistory record with rejection reason
// var history = new CommissionPayoutHistory
// {
// PayoutId = payout.Id,
// UserId = payout.UserId,
// WeekNumber = payout.WeekNumber,
// AmountBefore = payout.TotalAmount,
// AmountAfter = payout.TotalAmount,
// OldStatus = (int)CommissionPayoutStatus.Pending,
// NewStatus = (int)CommissionPayoutStatus.Rejected,
// Action = (int)CommissionPayoutAction.Rejected,
// PerformedBy = "Admin", // TODO: Get from authenticated user
// Reason = request.Reason,
// Created = DateTime.UtcNow
// };
// _context.CommissionPayoutHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
/// <summary>
/// Command برای درخواست برداشت کمیسیون
/// </summary>
public record RequestWithdrawalCommand : IRequest<Unit>
{
/// <summary>
/// شناسه پرداخت کمیسیون
/// </summary>
public long PayoutId { get; init; }
/// <summary>
/// روش برداشت (Cash یا Diamond)
/// </summary>
public WithdrawalMethod Method { get; init; }
/// <summary>
/// شماره شبا (برای Cash)
/// </summary>
public string? IbanNumber { get; init; }
}

View File

@@ -0,0 +1,66 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public RequestWithdrawalCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(RequestWithdrawalCommand request, CancellationToken cancellationToken)
{
var payout = await _context.UserCommissionPayouts
.FirstOrDefaultAsync(x => x.Id == request.PayoutId, cancellationToken);
if (payout == null)
{
throw new NotFoundException(nameof(UserCommissionPayout), request.PayoutId);
}
// بررسی وضعیت
if (payout.Status != CommissionPayoutStatus.Paid)
{
throw new InvalidOperationException($"فقط پرداخت‌های با وضعیت Paid قابل برداشت هستند. وضعیت فعلی: {payout.Status}");
}
var oldStatus = payout.Status;
// به‌روزرسانی وضعیت
payout.Status = CommissionPayoutStatus.WithdrawRequested;
payout.WithdrawalMethod = request.Method;
if (request.Method == WithdrawalMethod.Cash)
{
payout.IbanNumber = request.IbanNumber;
}
_context.UserCommissionPayouts.Update(payout);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
NewStatus = CommissionPayoutStatus.WithdrawRequested,
Action = CommissionPayoutAction.WithdrawRequested,
PerformedBy = "User", // TODO: باید از Current User گرفته شود
Reason = $"درخواست برداشت به روش {request.Method}"
};
await _context.CommissionPayoutHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,35 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
public class RequestWithdrawalCommandValidator : AbstractValidator<RequestWithdrawalCommand>
{
public RequestWithdrawalCommandValidator()
{
RuleFor(x => x.PayoutId)
.GreaterThan(0)
.WithMessage("شناسه پرداخت معتبر نیست");
RuleFor(x => x.Method)
.IsInEnum()
.WithMessage("روش برداشت باید Cash یا Diamond باشد");
RuleFor(x => x.IbanNumber)
.NotEmpty()
.WithMessage("شماره شبا الزامی است")
.Matches(@"^IR\d{24}$")
.WithMessage("فرمت شماره شبا معتبر نیست (IR + 24 رقم)")
.When(x => x.Method == WithdrawalMethod.Cash);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<RequestWithdrawalCommand>.CreateWithOptions(
(RequestWithdrawalCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculationResponseDto>
{
/// <summary>
/// شماره هفته (فرمت: "YYYY-Www")
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
/// <summary>
/// اگر true باشد، محاسبات قبلی را حذف و دوباره محاسبه می‌کند
/// </summary>
public bool ForceRecalculate { get; init; }
/// <summary>
/// Skip balance calculation
/// </summary>
public bool SkipBalances { get; init; }
/// <summary>
/// Skip pool calculation
/// </summary>
public bool SkipPool { get; init; }
/// <summary>
/// Skip payout processing
/// </summary>
public bool SkipPayouts { get; init; }
}

View File

@@ -0,0 +1,95 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
using CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWeeklyCalculationCommand, TriggerWeeklyCalculationResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IMediator _mediator;
public TriggerWeeklyCalculationCommandHandler(
IApplicationDbContext context,
IMediator mediator)
{
_context = context;
_mediator = mediator;
}
public async Task<TriggerWeeklyCalculationResponseDto> Handle(
TriggerWeeklyCalculationCommand request,
CancellationToken cancellationToken)
{
var executionId = Guid.NewGuid().ToString();
var startedAt = DateTime.UtcNow;
try
{
// Validate week number format
if (string.IsNullOrWhiteSpace(request.WeekNumber))
{
return new TriggerWeeklyCalculationResponseDto
{
Success = false,
Message = "شماره هفته نمی‌تواند خالی باشد",
ExecutionId = executionId,
StartedAt = startedAt
};
}
var steps = new List<string>();
// Step 1: Calculate Weekly Balances
if (!request.SkipBalances)
{
await _mediator.Send(new CalculateWeeklyBalancesCommand
{
WeekNumber = request.WeekNumber,
ForceRecalculate = request.ForceRecalculate
}, cancellationToken);
steps.Add("محاسبه امتیازات هفتگی");
}
// Step 2: Calculate Weekly Commission Pool
if (!request.SkipPool)
{
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand
{
WeekNumber = request.WeekNumber
}, cancellationToken);
steps.Add("محاسبه استخر کمیسیون");
}
// Step 3: Process User Payouts
if (!request.SkipPayouts)
{
await _mediator.Send(new ProcessUserPayoutsCommand
{
WeekNumber = request.WeekNumber,
ForceReprocess = request.ForceRecalculate
}, cancellationToken);
steps.Add("پردازش پرداخت‌های کاربران");
}
return new TriggerWeeklyCalculationResponseDto
{
Success = true,
Message = $"محاسبات هفته {request.WeekNumber} با موفقیت انجام شد. مراحل: {string.Join(", ", steps)}",
ExecutionId = executionId,
StartedAt = startedAt
};
}
catch (Exception ex)
{
return new TriggerWeeklyCalculationResponseDto
{
Success = false,
Message = $"خطا در اجرای محاسبات: {ex.Message}",
ExecutionId = executionId,
StartedAt = startedAt
};
}
}
}

View File

@@ -0,0 +1,9 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
public class TriggerWeeklyCalculationResponseDto
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string ExecutionId { get; set; } = string.Empty;
public DateTime StartedAt { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
/// <summary>
/// Query برای دریافت لیست تمام استخرهای کمیسیون هفتگی
/// </summary>
public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
{
/// <summary>
/// از هفته (فیلتر اختیاری)
/// </summary>
public string? FromWeek { get; init; }
/// <summary>
/// تا هفته (فیلتر اختیاری)
/// </summary>
public string? ToWeek { get; init; }
/// <summary>
/// فقط Pool های محاسبه شده
/// </summary>
public bool? OnlyCalculated { get; init; }
/// <summary>
/// شماره صفحه
/// </summary>
public int PageIndex { get; init; } = 1;
/// <summary>
/// تعداد در صفحه
/// </summary>
public int PageSize { get; init; } = 10;
}

View File

@@ -0,0 +1,67 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQuery, GetAllWeeklyPoolsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllWeeklyPoolsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllWeeklyPoolsResponseDto> Handle(GetAllWeeklyPoolsQuery request, CancellationToken cancellationToken)
{
var query = _context.WeeklyCommissionPools.AsNoTracking();
// Apply filters
if (!string.IsNullOrWhiteSpace(request.FromWeek))
{
query = query.Where(x => string.Compare(x.WeekNumber, request.FromWeek) >= 0);
}
if (!string.IsNullOrWhiteSpace(request.ToWeek))
{
query = query.Where(x => string.Compare(x.WeekNumber, request.ToWeek) <= 0);
}
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
{
query = query.Where(x => x.IsCalculated);
}
// Order by week number descending (newest first)
query = query.OrderByDescending(x => x.WeekNumber);
// Count total
var totalCount = await query.CountAsync(cancellationToken);
// Paginate
var pools = await query
.Skip((request.PageIndex - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new WeeklyCommissionPoolDto
{
Id = x.Id,
WeekNumber = x.WeekNumber,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetAllWeeklyPoolsResponseDto
{
MetaData = new MetaDataDto
{
TotalCount = totalCount,
PageSize = request.PageSize,
CurrentPage = request.PageIndex,
TotalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize)
},
Models = pools
};
}
}

View File

@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public record GetAllWeeklyPoolsResponseDto
{
public MetaDataDto MetaData { get; init; } = new();
public List<WeeklyCommissionPoolDto> Models { get; init; } = new();
}
public record WeeklyCommissionPoolDto
{
public long Id { get; init; }
public string WeekNumber { get; init; } = string.Empty;
public long TotalPoolAmount { get; init; }
public int TotalBalances { get; init; }
public long ValuePerBalance { get; init; }
public bool IsCalculated { get; init; }
public DateTime? CalculatedAt { get; init; }
public DateTime Created { get; init; }
}
public record MetaDataDto
{
public int TotalCount { get; init; }
public int PageSize { get; init; }
public int CurrentPage { get; init; }
public int TotalPages { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات کمیسیون
/// </summary>
public record GetCommissionPayoutHistoryQuery : IRequest<GetCommissionPayoutHistoryResponseDto>
{
/// <summary>
/// شناسه پرداخت (اختیاری)
/// </summary>
public long? PayoutId { get; init; }
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public string? WeekNumber { get; init; }
/// <summary>
/// مرتب‌سازی
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}

View File

@@ -0,0 +1,63 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory;
public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetCommissionPayoutHistoryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetCommissionPayoutHistoryResponseDto> Handle(GetCommissionPayoutHistoryQuery request, CancellationToken cancellationToken)
{
var query = _context.CommissionPayoutHistories
.AsNoTracking()
.AsQueryable();
// فیلترها
if (request.PayoutId.HasValue)
{
query = query.Where(x => x.UserCommissionPayoutId == request.PayoutId.Value);
}
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetCommissionPayoutHistoryResponseModel
{
Id = x.Id,
UserCommissionPayoutId = x.UserCommissionPayoutId,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
AmountBefore = x.AmountBefore,
AmountAfter = x.AmountAfter,
OldStatus = x.OldStatus,
NewStatus = x.NewStatus,
Action = x.Action,
PerformedBy = x.PerformedBy,
Reason = x.Reason,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetCommissionPayoutHistoryResponseDto
{
MetaData = meta,
Models = models
};
}
}

View File

@@ -0,0 +1,35 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory;
public class GetCommissionPayoutHistoryQueryValidator : AbstractValidator<GetCommissionPayoutHistoryQuery>
{
public GetCommissionPayoutHistoryQueryValidator()
{
RuleFor(x => x.PayoutId)
.GreaterThan(0)
.WithMessage("شناسه پرداخت معتبر نیست")
.When(x => x.PayoutId.HasValue);
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetCommissionPayoutHistoryQuery>.CreateWithOptions(
(GetCommissionPayoutHistoryQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,23 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHistory;
public class GetCommissionPayoutHistoryResponseDto
{
public MetaData MetaData { get; set; }
public List<GetCommissionPayoutHistoryResponseModel> Models { get; set; }
}
public class GetCommissionPayoutHistoryResponseModel
{
public long Id { get; set; }
public long UserCommissionPayoutId { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long AmountBefore { get; set; }
public long AmountAfter { get; set; }
public CommissionPayoutStatus? OldStatus { get; set; }
public CommissionPayoutStatus NewStatus { get; set; }
public CommissionPayoutAction Action { get; set; }
public string? PerformedBy { get; set; }
public string? Reason { get; set; }
public DateTimeOffset Created { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
/// <summary>
/// Query برای دریافت پرداخت‌های کمیسیون کاربر
/// </summary>
public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsResponseDto>
{
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// فیلتر وضعیت
/// </summary>
public CommissionPayoutStatus? Status { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public string? WeekNumber { get; init; }
/// <summary>
/// مرتب‌سازی
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}

View File

@@ -0,0 +1,64 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetUserCommissionPayoutsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
{
var query = _context.UserCommissionPayouts
.AsNoTracking()
.AsQueryable();
// فیلترها
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (request.Status.HasValue)
{
query = query.Where(x => x.Status == request.Status.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetUserCommissionPayoutsResponseModel
{
Id = x.Id,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
WeeklyPoolId = x.WeeklyPoolId,
BalancesEarned = x.BalancesEarned,
ValuePerBalance = x.ValuePerBalance,
TotalAmount = x.TotalAmount,
Status = x.Status,
PaidAt = x.PaidAt,
WithdrawalMethod = x.WithdrawalMethod,
IbanNumber = x.IbanNumber,
WithdrawnAt = x.WithdrawnAt,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetUserCommissionPayoutsResponseDto
{
MetaData = meta,
Models = models
};
}
}

View File

@@ -0,0 +1,35 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
public class GetUserCommissionPayoutsQueryValidator : AbstractValidator<GetUserCommissionPayoutsQuery>
{
public GetUserCommissionPayoutsQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x.Status)
.IsInEnum()
.WithMessage("وضعیت معتبر نیست")
.When(x => x.Status.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetUserCommissionPayoutsQuery>.CreateWithOptions(
(GetUserCommissionPayoutsQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayouts;
public class GetUserCommissionPayoutsResponseDto
{
public MetaData MetaData { get; set; }
public List<GetUserCommissionPayoutsResponseModel> Models { get; set; }
}
public class GetUserCommissionPayoutsResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long WeeklyPoolId { get; set; }
public long BalancesEarned { get; set; }
public decimal ValuePerBalance { get; set; }
public long TotalAmount { get; set; }
public CommissionPayoutStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
public WithdrawalMethod? WithdrawalMethod { get; set; }
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
public DateTimeOffset Created { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
/// <summary>
/// Query برای دریافت تعادل‌های هفتگی کاربر
/// </summary>
public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseDto>
{
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public string? WeekNumber { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
/// </summary>
public bool? OnlyActive { get; init; }
/// <summary>
/// مرتب‌سازی
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}

View File

@@ -0,0 +1,61 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto>
{
private readonly IApplicationDbContext _context;
public GetUserWeeklyBalancesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
{
var query = _context.NetworkWeeklyBalances
.AsNoTracking()
.AsQueryable();
// فیلترها
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
}
if (request.OnlyActive.HasValue && request.OnlyActive.Value)
{
query = query.Where(x => !x.IsExpired);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekNumber");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetUserWeeklyBalancesResponseModel
{
Id = x.Id,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
LeftLegBalances = x.LeftLegBalances,
RightLegBalances = x.RightLegBalances,
TotalBalances = x.TotalBalances,
WeeklyPoolContribution = x.WeeklyPoolContribution,
CalculatedAt = x.CalculatedAt,
IsExpired = x.IsExpired,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetUserWeeklyBalancesResponseDto
{
MetaData = meta,
Models = models
};
}
}

View File

@@ -0,0 +1,30 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
public class GetUserWeeklyBalancesQueryValidator : AbstractValidator<GetUserWeeklyBalancesQuery>
{
public GetUserWeeklyBalancesQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetUserWeeklyBalancesQuery>.CreateWithOptions(
(GetUserWeeklyBalancesQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,21 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
public class GetUserWeeklyBalancesResponseDto
{
public MetaData MetaData { get; set; }
public List<GetUserWeeklyBalancesResponseModel> Models { get; set; }
}
public class GetUserWeeklyBalancesResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public int LeftLegBalances { get; set; }
public int RightLegBalances { get; set; }
public int TotalBalances { get; set; }
public long WeeklyPoolContribution { get; set; }
public DateTime? CalculatedAt { get; set; }
public bool IsExpired { get; set; }
public DateTimeOffset Created { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPool;
/// <summary>
/// Query برای دریافت استخر کمیسیون هفتگی
/// </summary>
public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
{
/// <summary>
/// شماره هفته
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPool;
public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyCommissionPoolQuery, WeeklyCommissionPoolDto?>
{
private readonly IApplicationDbContext _context;
public GetWeeklyCommissionPoolQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{
var pool = await _context.WeeklyCommissionPools
.AsNoTracking()
.Where(x => x.WeekNumber == request.WeekNumber)
.Select(x => new WeeklyCommissionPoolDto
{
Id = x.Id,
WeekNumber = x.WeekNumber,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt,
Created = x.Created
})
.FirstOrDefaultAsync(cancellationToken);
return pool;
}
}

View File

@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPool;
public class GetWeeklyCommissionPoolQueryValidator : AbstractValidator<GetWeeklyCommissionPoolQuery>
{
public GetWeeklyCommissionPoolQueryValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetWeeklyCommissionPoolQuery>.CreateWithOptions(
(GetWeeklyCommissionPoolQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,16 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPool;
/// <summary>
/// DTO برای استخر کمیسیون هفتگی
/// </summary>
public class WeeklyCommissionPoolDto
{
public long Id { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long TotalPoolAmount { get; set; }
public long TotalBalances { get; set; }
public decimal ValuePerBalance { get; set; }
public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; }
public DateTimeOffset Created { get; set; }
}

View File

@@ -0,0 +1,172 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalReports;
/// <summary>
/// Query برای دریافت گزارش برداشت‌ها
/// </summary>
public record GetWithdrawalReportsQuery : IRequest<WithdrawalReportsDto>
{
/// <summary>
/// تاریخ شروع
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// تاریخ پایان
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// نوع بازه زمانی (روزانه، هفتگی، ماهانه)
/// </summary>
public ReportPeriodType PeriodType { get; init; } = ReportPeriodType.Daily;
/// <summary>
/// فیلتر بر اساس وضعیت
/// </summary>
public CommissionPayoutStatus? Status { get; init; }
/// <summary>
/// شناسه کاربر (برای فیلتر کردن بر اساس کاربر خاص)
/// </summary>
public long? UserId { get; init; }
}
/// <summary>
/// نوع بازه زمانی گزارش
/// </summary>
public enum ReportPeriodType
{
Daily = 1,
Weekly = 2,
Monthly = 3
}
/// <summary>
/// DTO گزارش برداشت‌ها
/// </summary>
public class WithdrawalReportsDto
{
/// <summary>
/// گزارش‌های بازه‌های زمانی
/// </summary>
public List<PeriodReportDto> PeriodReports { get; set; } = new();
/// <summary>
/// خلاصه کلی
/// </summary>
public WithdrawalSummaryDto Summary { get; set; } = new();
}
/// <summary>
/// گزارش یک بازه زمانی
/// </summary>
public class PeriodReportDto
{
/// <summary>
/// عنوان بازه (مثلاً "هفته 1" یا "دی ماه")
/// </summary>
public string PeriodLabel { get; set; } = string.Empty;
/// <summary>
/// تاریخ شروع بازه
/// </summary>
public DateTime StartDate { get; set; }
/// <summary>
/// تاریخ پایان بازه
/// </summary>
public DateTime EndDate { get; set; }
/// <summary>
/// تعداد کل درخواست‌ها
/// </summary>
public int TotalRequests { get; set; }
/// <summary>
/// تعداد درخواست‌های در انتظار
/// </summary>
public int PendingCount { get; set; }
/// <summary>
/// تعداد درخواست‌های تأیید شده
/// </summary>
public int ApprovedCount { get; set; }
/// <summary>
/// تعداد درخواست‌های رد شده
/// </summary>
public int RejectedCount { get; set; }
/// <summary>
/// تعداد درخواست‌های موفق
/// </summary>
public int CompletedCount { get; set; }
/// <summary>
/// تعداد درخواست‌های ناموفق
/// </summary>
public int FailedCount { get; set; }
/// <summary>
/// مجموع مبلغ درخواست‌ها
/// </summary>
public long TotalAmount { get; set; }
/// <summary>
/// مجموع مبلغ پرداخت شده
/// </summary>
public long PaidAmount { get; set; }
/// <summary>
/// مجموع مبلغ در انتظار
/// </summary>
public long PendingAmount { get; set; }
}
/// <summary>
/// خلاصه کلی برداشت‌ها
/// </summary>
public class WithdrawalSummaryDto
{
/// <summary>
/// تعداد کل درخواست‌ها
/// </summary>
public int TotalRequests { get; set; }
/// <summary>
/// مجموع کل مبالغ
/// </summary>
public long TotalAmount { get; set; }
/// <summary>
/// مجموع مبلغ پرداخت شده
/// </summary>
public long TotalPaid { get; set; }
/// <summary>
/// مجموع مبلغ در انتظار
/// </summary>
public long TotalPending { get; set; }
/// <summary>
/// مجموع مبلغ رد شده
/// </summary>
public long TotalRejected { get; set; }
/// <summary>
/// میانگین مبلغ هر درخواست
/// </summary>
public long AverageAmount { get; set; }
/// <summary>
/// تعداد کاربران منحصر به فرد
/// </summary>
public int UniqueUsers { get; set; }
/// <summary>
/// درصد موفقیت (Completed / Total)
/// </summary>
public decimal SuccessRate { get; set; }
}

View File

@@ -0,0 +1,213 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalReports;
/// <summary>
/// Handler برای دریافت گزارش برداشت‌ها
/// </summary>
public class GetWithdrawalReportsQueryHandler : IRequestHandler<GetWithdrawalReportsQuery, WithdrawalReportsDto>
{
private readonly IApplicationDbContext _context;
public GetWithdrawalReportsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<WithdrawalReportsDto> Handle(GetWithdrawalReportsQuery request, CancellationToken cancellationToken)
{
// تعیین بازه زمانی پیش‌فرض (30 روز گذشته)
var endDate = request.EndDate ?? DateTime.UtcNow;
var startDate = request.StartDate ?? endDate.AddDays(-30);
// Query پایه
var query = _context.UserCommissionPayouts
.Where(p => p.Created >= startDate && p.Created <= endDate);
// فیلتر بر اساس وضعیت
if (request.Status.HasValue)
{
query = query.Where(p => p.Status == request.Status.Value);
}
// فیلتر بر اساس کاربر
if (request.UserId.HasValue)
{
query = query.Where(p => p.UserId == request.UserId.Value);
}
var payouts = await query
.OrderBy(p => p.Created)
.ToListAsync(cancellationToken);
// گروه‌بندی بر اساس نوع بازه
var periodReports = request.PeriodType switch
{
ReportPeriodType.Daily => GroupByDay(payouts, startDate, endDate),
ReportPeriodType.Weekly => GroupByWeek(payouts, startDate, endDate),
ReportPeriodType.Monthly => GroupByMonth(payouts, startDate, endDate),
_ => GroupByDay(payouts, startDate, endDate)
};
// محاسبه خلاصه کلی
var summary = CalculateSummary(payouts);
return new WithdrawalReportsDto
{
PeriodReports = periodReports,
Summary = summary
};
}
private List<PeriodReportDto> GroupByDay(List<Domain.Entities.Commission.UserCommissionPayout> payouts, DateTime startDate, DateTime endDate)
{
var reports = new List<PeriodReportDto>();
var currentDate = startDate.Date;
while (currentDate <= endDate.Date)
{
var dayPayouts = payouts.Where(p => p.Created.Date == currentDate).ToList();
reports.Add(new PeriodReportDto
{
PeriodLabel = currentDate.ToString("yyyy-MM-dd"),
StartDate = currentDate,
EndDate = currentDate.AddDays(1).AddSeconds(-1),
TotalRequests = dayPayouts.Count,
PendingCount = dayPayouts.Count(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested),
ApprovedCount = 0, // تایید جداگانه نداریم
RejectedCount = dayPayouts.Count(p => p.Status == CommissionPayoutStatus.Cancelled),
CompletedCount = dayPayouts.Count(p => p.Status == CommissionPayoutStatus.Withdrawn),
FailedCount = dayPayouts.Count(p => p.Status == CommissionPayoutStatus.PaymentFailed),
TotalAmount = dayPayouts.Sum(p => p.TotalAmount),
PaidAmount = dayPayouts.Where(p => p.Status == CommissionPayoutStatus.Withdrawn).Sum(p => p.TotalAmount),
PendingAmount = dayPayouts.Where(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested).Sum(p => p.TotalAmount)
});
currentDate = currentDate.AddDays(1);
}
return reports;
}
private List<PeriodReportDto> GroupByWeek(List<Domain.Entities.Commission.UserCommissionPayout> payouts, DateTime startDate, DateTime endDate)
{
var reports = new List<PeriodReportDto>();
var currentWeekStart = startDate.Date;
int weekNumber = 1;
while (currentWeekStart <= endDate)
{
var weekEnd = currentWeekStart.AddDays(7).AddSeconds(-1);
if (weekEnd > endDate)
weekEnd = endDate;
var weekPayouts = payouts.Where(p => p.Created >= currentWeekStart && p.Created <= weekEnd).ToList();
reports.Add(new PeriodReportDto
{
PeriodLabel = $"هفته {weekNumber}",
StartDate = currentWeekStart,
EndDate = weekEnd,
TotalRequests = weekPayouts.Count,
PendingCount = weekPayouts.Count(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested),
ApprovedCount = 0, // تایید جداگانه نداریم
RejectedCount = weekPayouts.Count(p => p.Status == CommissionPayoutStatus.Cancelled),
CompletedCount = weekPayouts.Count(p => p.Status == CommissionPayoutStatus.Withdrawn),
FailedCount = weekPayouts.Count(p => p.Status == CommissionPayoutStatus.PaymentFailed),
TotalAmount = weekPayouts.Sum(p => p.TotalAmount),
PaidAmount = weekPayouts.Where(p => p.Status == CommissionPayoutStatus.Withdrawn).Sum(p => p.TotalAmount),
PendingAmount = weekPayouts.Where(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested).Sum(p => p.TotalAmount)
});
currentWeekStart = currentWeekStart.AddDays(7);
weekNumber++;
}
return reports;
}
private List<PeriodReportDto> GroupByMonth(List<Domain.Entities.Commission.UserCommissionPayout> payouts, DateTime startDate, DateTime endDate)
{
var reports = new List<PeriodReportDto>();
var currentMonthStart = new DateTime(startDate.Year, startDate.Month, 1);
while (currentMonthStart <= endDate)
{
var monthEnd = currentMonthStart.AddMonths(1).AddSeconds(-1);
if (monthEnd > endDate)
monthEnd = endDate;
var monthPayouts = payouts.Where(p => p.Created >= currentMonthStart && p.Created <= monthEnd).ToList();
var persianMonthName = GetPersianMonthName(currentMonthStart.Month);
reports.Add(new PeriodReportDto
{
PeriodLabel = $"{persianMonthName} {currentMonthStart.Year}",
StartDate = currentMonthStart,
EndDate = monthEnd,
TotalRequests = monthPayouts.Count,
PendingCount = monthPayouts.Count(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested),
ApprovedCount = 0, // تایید جداگانه نداریم
RejectedCount = monthPayouts.Count(p => p.Status == CommissionPayoutStatus.Cancelled),
CompletedCount = monthPayouts.Count(p => p.Status == CommissionPayoutStatus.Withdrawn),
FailedCount = monthPayouts.Count(p => p.Status == CommissionPayoutStatus.PaymentFailed),
TotalAmount = monthPayouts.Sum(p => p.TotalAmount),
PaidAmount = monthPayouts.Where(p => p.Status == CommissionPayoutStatus.Withdrawn).Sum(p => p.TotalAmount),
PendingAmount = monthPayouts.Where(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested).Sum(p => p.TotalAmount)
});
currentMonthStart = currentMonthStart.AddMonths(1);
}
return reports;
}
private WithdrawalSummaryDto CalculateSummary(List<Domain.Entities.Commission.UserCommissionPayout> payouts)
{
var totalRequests = payouts.Count;
var completedCount = payouts.Count(p => p.Status == CommissionPayoutStatus.Withdrawn);
return new WithdrawalSummaryDto
{
TotalRequests = totalRequests,
TotalAmount = payouts.Sum(p => p.TotalAmount),
TotalPaid = payouts.Where(p => p.Status == CommissionPayoutStatus.Withdrawn).Sum(p => p.TotalAmount),
TotalPending = payouts.Where(p => p.Status == CommissionPayoutStatus.Pending ||
p.Status == CommissionPayoutStatus.WithdrawRequested).Sum(p => p.TotalAmount),
TotalRejected = payouts.Where(p => p.Status == CommissionPayoutStatus.Cancelled).Sum(p => p.TotalAmount),
AverageAmount = totalRequests > 0 ? payouts.Sum(p => p.TotalAmount) / totalRequests : 0,
UniqueUsers = payouts.Select(p => p.UserId).Distinct().Count(),
SuccessRate = totalRequests > 0 ? (decimal)completedCount / totalRequests * 100 : 0
};
}
private string GetPersianMonthName(int month)
{
return month switch
{
1 => "فروردین",
2 => "اردیبهشت",
3 => "خرداد",
4 => "تیر",
5 => "مرداد",
6 => "شهریور",
7 => "مهر",
8 => "آبان",
9 => "آذر",
10 => "دی",
11 => "بهمن",
12 => "اسفند",
_ => month.ToString()
};
}
}

View File

@@ -0,0 +1,11 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests;
public class GetWithdrawalRequestsQuery : IRequest<GetWithdrawalRequestsResponseDto>
{
public int? Status { get; set; } // CommissionPayoutStatus enum
public long? UserId { get; set; }
public string? WeekNumber { get; set; }
public string? IbanNumber { get; set; }
public PaginationState? PaginationState { get; set; }
public string? SortBy { get; set; }
}

View File

@@ -0,0 +1,75 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests;
public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRequestsQuery, GetWithdrawalRequestsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetWithdrawalRequestsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetWithdrawalRequestsResponseDto> Handle(GetWithdrawalRequestsQuery request, CancellationToken cancellationToken)
{
var query = _context.UserCommissionPayouts
.AsNoTracking()
.Include(x => x.User)
.Where(x => x.WithdrawalMethod != null) // Only requests with withdrawal method
.AsQueryable();
// Filters
if (request.Status.HasValue)
{
query = query.Where(x => (int)x.Status == request.Status.Value);
}
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
}
if (!string.IsNullOrWhiteSpace(request.IbanNumber))
{
query = query.Where(x => x.IbanNumber != null && x.IbanNumber.Contains(request.IbanNumber));
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.ToListAsync(cancellationToken);
var result = models.Select(x => new WithdrawalRequestModel
{
Id = x.Id,
UserId = x.UserId,
UserName = x.User != null ? (x.User.FirstName + " " + x.User.LastName).Trim() : x.User?.Mobile ?? "N/A",
WeekNumber = x.WeekNumber,
Amount = x.TotalAmount,
Status = (int)x.Status,
WithdrawalMethod = x.WithdrawalMethod.HasValue ? (int)x.WithdrawalMethod.Value : 0,
IbanNumber = x.IbanNumber,
RequestedAt = x.WithdrawnAt ?? x.Created,
ProcessedAt = x.LastModified,
ProcessedBy = x.ProcessedBy,
Reason = x.RejectionReason,
BankReferenceId = x.BankReferenceId,
BankTrackingCode = x.BankTrackingCode,
PaymentFailureReason = x.PaymentFailureReason,
Created = x.Created
}).ToList();
return new GetWithdrawalRequestsResponseDto
{
MetaData = meta,
Models = result
};
}
}

View File

@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests;
public class GetWithdrawalRequestsResponseDto
{
public MetaData? MetaData { get; set; }
public List<WithdrawalRequestModel> Models { get; set; } = new();
}
public class WithdrawalRequestModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty;
public long Amount { get; set; }
public int Status { get; set; } // CommissionPayoutStatus enum
public int? WithdrawalMethod { get; set; }
public string? IbanNumber { get; set; }
public DateTime? RequestedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
public string? ProcessedBy { get; set; }
public string? Reason { get; set; }
public string? BankReferenceId { get; set; }
public string? BankTrackingCode { get; set; }
public string? PaymentFailureReason { get; set; }
public DateTime Created { get; set; }
}

View File

@@ -0,0 +1,13 @@
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLogs;
public record GetWorkerExecutionLogsQuery : IRequest<GetWorkerExecutionLogsResponseDto>
{
public string? WeekNumber { get; init; }
public string? ExecutionId { get; init; }
public bool? SuccessOnly { get; init; }
public bool? FailedOnly { get; init; }
public string? SortBy { get; init; }
public PaginationState? PaginationState { get; init; }
}

View File

@@ -0,0 +1,79 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLogs;
public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecutionLogsQuery, GetWorkerExecutionLogsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetWorkerExecutionLogsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetWorkerExecutionLogsResponseDto> Handle(
GetWorkerExecutionLogsQuery request,
CancellationToken cancellationToken)
{
// Query from database
var query = _context.WorkerExecutionLogs.AsQueryable();
// Apply filters
if (!string.IsNullOrEmpty(request.WeekNumber))
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
}
if (request.SuccessOnly == true)
{
query = query.Where(x => x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings);
}
if (request.FailedOnly == true)
{
query = query.Where(x => x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Failed);
}
// Order by most recent first
query = query.OrderByDescending(x => x.StartedAt);
var totalCount = await query.CountAsync(cancellationToken);
var pageSize = request.PaginationState?.PageSize ?? 10;
var pageNumber = request.PaginationState?.PageNumber ?? 1;
var logs = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(x => new WorkerExecutionLogModel
{
ExecutionId = x.ExecutionId.ToString(),
WeekNumber = x.WeekNumber,
Step = "Full", // We only have full execution now
Success = x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings,
ErrorMessage = x.ErrorMessage,
StartedAt = x.StartedAt,
CompletedAt = x.CompletedAt ?? x.StartedAt,
DurationMs = x.DurationMs ?? 0,
RecordsProcessed = x.ProcessedCount,
Details = x.Details ?? $"Worker execution: {x.Status}"
})
.ToListAsync(cancellationToken);
return new GetWorkerExecutionLogsResponseDto
{
MetaData = new MetaData
{
CurrentPage = pageNumber,
TotalPage = (int)Math.Ceiling(totalCount / (double)pageSize),
PageSize = pageSize,
TotalCount = totalCount,
HasPrevious = pageNumber > 1,
HasNext = pageNumber < (int)Math.Ceiling(totalCount / (double)pageSize)
},
Models = logs
};
}
}

View File

@@ -0,0 +1,23 @@
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLogs;
public class GetWorkerExecutionLogsResponseDto
{
public MetaData? MetaData { get; set; }
public List<WorkerExecutionLogModel> Models { get; set; } = new();
}
public class WorkerExecutionLogModel
{
public string ExecutionId { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty;
public string Step { get; set; } = string.Empty; // "Balances" | "Pool" | "Payouts" | "Full"
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public long DurationMs { get; set; }
public int RecordsProcessed { get; set; }
public string? Details { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
public record GetWorkerStatusQuery : IRequest<GetWorkerStatusResponseDto>
{
// Empty - returns current worker status
}

View File

@@ -0,0 +1,37 @@
using CMSMicroservice.Application.Common.Interfaces;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery, GetWorkerStatusResponseDto>
{
private readonly IApplicationDbContext _context;
public GetWorkerStatusQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetWorkerStatusResponseDto> Handle(
GetWorkerStatusQuery request,
CancellationToken cancellationToken)
{
// TODO: این باید از یک service یا cache واقعی worker status را بگیرد
// فعلاً mock data برمی‌گرداند
await Task.CompletedTask;
return new GetWorkerStatusResponseDto
{
IsRunning = false,
IsEnabled = true,
CurrentExecutionId = null,
CurrentWeekNumber = null,
CurrentStep = "Idle",
LastRunAt = DateTime.UtcNow.AddHours(-24),
NextScheduledRun = DateTime.UtcNow.AddDays(7),
TotalExecutions = 48,
SuccessfulExecutions = 47,
FailedExecutions = 1
};
}
}

View File

@@ -0,0 +1,15 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
public class GetWorkerStatusResponseDto
{
public bool IsRunning { get; set; }
public bool IsEnabled { get; set; }
public string? CurrentExecutionId { get; set; }
public string? CurrentWeekNumber { get; set; }
public string? CurrentStep { get; set; } // "Balances" | "Pool" | "Payouts" | "Idle"
public DateTime? LastRunAt { get; set; }
public DateTime? NextScheduledRun { get; set; }
public int TotalExecutions { get; set; }
public int SuccessfulExecutions { get; set; }
public int FailedExecutions { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.Common.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException()
: base()
{
}
public BadRequestException(string message)
: base(message)
{
}
public BadRequestException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,54 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس ارسال Alert و Notification
/// برای ارسال اعلان‌های مختلف از طریق کانال‌های مختلف (Email, SMS, Slack, etc.)
/// </summary>
public interface IAlertService
{
/// <summary>
/// ارسال Alert برای خطاهای Critical
/// </summary>
Task SendCriticalAlertAsync(string title, string message, Exception? exception = null, CancellationToken cancellationToken = default);
/// <summary>
/// ارسال Alert برای Warning
/// </summary>
Task SendWarningAlertAsync(string title, string message, CancellationToken cancellationToken = default);
/// <summary>
/// ارسال اعلان موفقیت
/// </summary>
Task SendSuccessNotificationAsync(string title, string message, CancellationToken cancellationToken = default);
}
/// <summary>
/// سرویس ارسال Notification به کاربران
/// برای ارسال پیامک، ایمیل و پوش به کاربران سیستم
/// </summary>
public interface IUserNotificationService
{
/// <summary>
/// ارسال اعلان دریافت کمیسیون به کاربر
/// </summary>
Task SendCommissionReceivedNotificationAsync(
long userId,
decimal amount,
int weekNumber,
CancellationToken cancellationToken = default);
/// <summary>
/// ارسال اعلان فعال‌سازی عضویت باشگاه
/// </summary>
Task SendClubActivationNotificationAsync(
long userId,
CancellationToken cancellationToken = default);
/// <summary>
/// ارسال اعلان خطا در پرداخت
/// </summary>
Task SendPayoutErrorNotificationAsync(
long userId,
string errorMessage,
CancellationToken cancellationToken = default);
}

View File

@@ -1,27 +1,57 @@
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
namespace CMSMicroservice.Application.Common.Interfaces;
public interface IApplicationDbContext
{
DbSet<UserAddress> UserAddresss { get; }
DbSet<UserAddress> UserAddresses { get; }
DbSet<Package> Packages { get; }
DbSet<Role> Roles { get; }
DbSet<Category> Categorys { get; }
DbSet<Category> Categories { get; }
DbSet<UserRole> UserRoles { get; }
DbSet<UserCarts> UserCartss { get; }
DbSet<ProductGallerys> ProductGalleryss { get; }
DbSet<FactorDetails> FactorDetailss { get; }
DbSet<Products> Productss { get; }
DbSet<ProductImages> ProductImagess { get; }
DbSet<UserCart> UserCarts { get; }
DbSet<ProductGallery> ProductGalleries { get; }
DbSet<FactorDetails> FactorDetails { get; }
DbSet<Product> Products { get; }
DbSet<ProductImage> ProductImages { get; }
DbSet<User> Users { get; }
DbSet<OtpToken> OtpTokens { get; }
DbSet<Contract> Contracts { get; }
DbSet<UserContract> UserContracts { get; }
DbSet<Tag> Tags { get; }
DbSet<PruductCategory> PruductCategorys { get; }
DbSet<PruductTag> PruductTags { get; }
DbSet<Transactions> Transactionss { get; }
DbSet<ProductCategory> ProductCategories { get; }
DbSet<ProductTag> ProductTags { get; }
DbSet<Transaction> Transactions { get; }
DbSet<UserOrder> UserOrders { get; }
DbSet<OrderVAT> OrderVATs { get; }
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<SystemConfiguration> SystemConfigurations { get; }
DbSet<SystemConfigurationHistory> SystemConfigurationHistories { get; }
DbSet<ManualPayment> ManualPayments { get; }
DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; }
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
DbSet<ClubFeature> ClubFeatures { get; }
DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
DbSet<NetworkMembershipHistory> NetworkMembershipHistories { get; }
DbSet<WeeklyCommissionPool> WeeklyCommissionPools { get; }
DbSet<UserCommissionPayout> UserCommissionPayouts { get; }
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
DbSet<DayaLoanContract> DayaLoanContracts { get; }
// ============= Discount Shop =============
DbSet<DiscountProduct> DiscountProducts { get; }
DbSet<DiscountCategory> DiscountCategories { get; }
DbSet<DiscountProductCategory> DiscountProductCategories { get; }
DbSet<DiscountShoppingCart> DiscountShoppingCarts { get; }
DbSet<DiscountOrder> DiscountOrders { get; }
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,27 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس دریافت اطلاعات کاربر فعلی از Authentication Context
/// </summary>
public interface ICurrentUserService
{
/// <summary>
/// شناسه کاربر فعلی (از JWT Claims)
/// </summary>
string? UserId { get; }
/// <summary>
/// نام کاربری (Username یا Email)
/// </summary>
string? Username { get; }
/// <summary>
/// آیا کاربر Authenticated است؟
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// دریافت string برای PerformedBy (UserId:Username یا "System")
/// </summary>
string GetPerformedBy();
}

View File

@@ -0,0 +1,39 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس محاسبه موقعیت در Binary Tree
/// این سرویس مشخص می‌کند که کاربر جدید باید در کدام Leg (Left/Right) قرار بگیرد
/// </summary>
public interface INetworkPlacementService
{
/// <summary>
/// محاسبه LegPosition برای کاربر جدید
/// </summary>
/// <param name="parentId">شناسه Parent در Network</param>
/// <param name="cancellationToken"></param>
/// <returns>
/// - Left: اگر Parent فرزند چپ ندارد
/// - Right: اگر Parent فرزند راست ندارد
/// - null: اگر Parent هر دو Leg را دارد (Binary Tree پر است!)
/// </returns>
Task<NetworkLeg?> CalculateLegPositionAsync(long parentId, CancellationToken cancellationToken = default);
/// <summary>
/// بررسی اینکه آیا Parent می‌تواند فرزند جدید بپذیرد
/// </summary>
/// <param name="parentId"></param>
/// <param name="cancellationToken"></param>
/// <returns>true اگر Parent کمتر از 2 فرزند دارد</returns>
Task<bool> CanAcceptChildAsync(long parentId, CancellationToken cancellationToken = default);
/// <summary>
/// پیدا کردن اولین Parent در شبکه که می‌تواند فرزند جدید بپذیرد
/// (برای Auto-Placement در Binary Tree)
/// </summary>
/// <param name="rootParentId">شناسه Parent اصلی که از آن شروع می‌کنیم</param>
/// <param name="cancellationToken"></param>
/// <returns>شناسه Parent مناسب برای قرار گرفتن کاربر جدید</returns>
Task<long?> FindAvailableParentAsync(long rootParentId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,194 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Interface برای یکپارچه‌سازی با درگاه‌های پرداخت
/// </summary>
public interface IPaymentGatewayService
{
/// <summary>
/// شروع تراکنش پرداخت (ارسال به درگاه)
/// </summary>
/// <param name="request">اطلاعات تراکنش</param>
/// <param name="cancellationToken"></param>
/// <returns>URL درگاه برای هدایت کاربر + RefId تراکنش</returns>
Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// تأیید پرداخت (بعد از بازگشت از درگاه)
/// </summary>
/// <param name="refId">شماره مرجع تراکنش</param>
/// <param name="verificationToken">توکن تأیید از درگاه</param>
/// <param name="cancellationToken"></param>
/// <returns>وضعیت نهایی تراکنش</returns>
Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default);
/// <summary>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary>
/// <param name="request">اطلاعات واریز</param>
/// <param name="cancellationToken"></param>
/// <returns>وضعیت واریز</returns>
Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// درخواست شروع تراکنش پرداخت
/// </summary>
public class PaymentRequest
{
/// <summary>
/// مبلغ (تومان)
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// شماره موبایل
/// </summary>
public string Mobile { get; set; } = string.Empty;
/// <summary>
/// شرح تراکنش
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// URL بازگشت بعد از پرداخت
/// </summary>
public string CallbackUrl { get; set; } = string.Empty;
}
/// <summary>
/// نتیجه شروع تراکنش
/// </summary>
public class PaymentInitiateResult
{
/// <summary>
/// موفق بودن درخواست
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// شماره مرجع تراکنش (RefId)
/// </summary>
public string? RefId { get; set; }
/// <summary>
/// URL درگاه برای هدایت کاربر
/// </summary>
public string? GatewayUrl { get; set; }
/// <summary>
/// پیام خطا (در صورت ناموفق بودن)
/// </summary>
public string? ErrorMessage { get; set; }
}
/// <summary>
/// نتیجه تأیید تراکنش
/// </summary>
public class PaymentVerificationResult
{
/// <summary>
/// موفق بودن تراکنش
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// شماره مرجع تراکنش
/// </summary>
public string RefId { get; set; } = string.Empty;
/// <summary>
/// کد پیگیری بانک
/// </summary>
public string? TrackingCode { get; set; }
/// <summary>
/// مبلغ تراکنش
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// پیام
/// </summary>
public string? Message { get; set; }
}
/// <summary>
/// درخواست واریز
/// </summary>
public class PayoutRequest
{
/// <summary>
/// مبلغ (تومان)
/// </summary>
public decimal Amount { get; set; }
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// شماره شبا
/// </summary>
public string Iban { get; set; } = string.Empty;
/// <summary>
/// نام صاحب حساب
/// </summary>
public string AccountHolderName { get; set; } = string.Empty;
/// <summary>
/// شرح واریز
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// شماره مرجع داخلی
/// </summary>
public string InternalRefId { get; set; } = string.Empty;
}
/// <summary>
/// نتیجه واریز
/// </summary>
public class PayoutResult
{
/// <summary>
/// موفق بودن واریز
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// شماره مرجع تراکنش بانکی
/// </summary>
public string? BankRefId { get; set; }
/// <summary>
/// کد پیگیری
/// </summary>
public string? TrackingCode { get; set; }
/// <summary>
/// پیام
/// </summary>
public string? Message { get; set; }
/// <summary>
/// زمان پردازش
/// </summary>
public DateTime ProcessedAt { get; set; }
}

View File

@@ -4,7 +4,8 @@ public class TransactionsProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
//config.NewConfig<Source,Destination>()
// .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
// VerifyTransactionCommand → domain mapping handled in handler
// RefundTransactionCommand → domain mapping handled in handler
}
}

View File

@@ -6,7 +6,7 @@ public class UserCartsProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
config.NewConfig<UserCarts,GetAllUserCartsByFilterResponseModel>()
config.NewConfig<UserCart,GetAllUserCartsByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.ProductId, src => src.ProductId)

View File

@@ -1,10 +1,58 @@
using CMSMicroservice.Application.UserOrderCQ.Queries.GetAllUserOrderByFilter;
using CMSMicroservice.Application.UserOrderCQ.Queries.GetUserOrder;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserOrderProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
// config.NewConfig<Source,Destination>()
// .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
config.NewConfig<UserOrder,GetUserOrderResponseDto>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Amount, src => src.Amount)
.Map(dest => dest.PackageId, src => src.PackageId)
.Map(dest => dest.TransactionId, src => src.TransactionId)
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
;
config.NewConfig<UserOrder,GetAllUserOrderByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Amount, src => src.Amount)
.Map(dest => dest.PackageId, src => src.PackageId)
.Map(dest => dest.TransactionId, src => src.TransactionId)
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
;
config.NewConfig<FactorDetails,GetUserOrderResponseFactorDetail>()
.Map(dest => dest.ProductId, src => src.ProductId)
.Map(dest => dest.ProductTitle, src => src.Product.Title)
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
.Map(dest => dest.UnitPrice, src => src.Product.Price)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
;
config.NewConfig<FactorDetails,GetAllUserOrderByFilterResponseModelFactorDetail>()
.Map(dest => dest.ProductId, src => src.ProductId)
.Map(dest => dest.ProductTitle, src => src.Product.Title)
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
.Map(dest => dest.UnitPrice, src => src.Product.Price)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
;
}
}

View File

@@ -1,10 +1,16 @@
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetAllUserWalletChangeLogByFilter;
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetUserWalletChangeLog;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserWalletChangeLogProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
//config.NewConfig<Source,Destination>()
// .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
config.NewConfig<UserWalletChangeLog,GetAllUserWalletChangeLogByFilterResponseModel>()
.Map(dest => dest.CreatedAt, src => src.Created);
config.NewConfig<UserWalletChangeLog, GetUserWalletChangeLogResponseDto>()
.Map(dest => dest.CreatedAt, src => src.Created);
}
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
/// <summary>
/// Command برای غیرفعال کردن یک Configuration
/// </summary>
public record DeactivateConfigurationCommand : IRequest<Unit>
{
/// <summary>
/// شناسه Configuration
/// </summary>
public long ConfigurationId { get; init; }
/// <summary>
/// دلیل غیرفعال‌سازی
/// </summary>
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,51 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
public class DeactivateConfigurationCommandHandler : IRequestHandler<DeactivateConfigurationCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public DeactivateConfigurationCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<Unit> Handle(DeactivateConfigurationCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Id == request.ConfigurationId, cancellationToken)
?? throw new NotFoundException(nameof(SystemConfiguration), request.ConfigurationId);
// اگر از قبل غیرفعال است، خطا ندهیم
if (!entity.IsActive)
{
return Unit.Value;
}
var oldValue = entity.Value;
entity.IsActive = false;
_context.SystemConfigurations.Update(entity);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new SystemConfigurationHistory
{
ConfigurationId = entity.Id,
Scope = entity.Scope,
Key = entity.Key,
OldValue = oldValue,
NewValue = entity.Value,
Reason = request.Reason ?? "Configuration deactivated",
PerformedBy = _currentUser.GetPerformedBy()
};
await _context.SystemConfigurationHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.ConfigurationCQ.Commands.DeactivateConfiguration;
public class DeactivateConfigurationCommandValidator : AbstractValidator<DeactivateConfigurationCommand>
{
public DeactivateConfigurationCommandValidator()
{
RuleFor(x => x.ConfigurationId)
.GreaterThan(0)
.WithMessage("شناسه Configuration معتبر نیست");
RuleFor(x => x.Reason)
.MaximumLength(500)
.WithMessage("دلیل غیرفعال‌سازی نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Reason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<DeactivateConfigurationCommand>.CreateWithOptions(
(DeactivateConfigurationCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

Some files were not shown because too many files have changed in this diff Show More