update
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
# 📊 مثالهای عملی محاسبه تعادل - 5 لول عمقی
|
||||
|
||||
**تاریخ**: 2025-12-09
|
||||
**وضعیت**: مثالهای کامل و تایید شده
|
||||
**هدف**: نمایش محاسبات واقعی برای درخت باینری تا 5 لول
|
||||
|
||||
---
|
||||
|
||||
## 🌳 ساختار درخت نمونه
|
||||
|
||||
```
|
||||
User1 (Level 0)
|
||||
/ \
|
||||
User2 (L1-L) User3 (L1-R)
|
||||
/ \ / \
|
||||
User4(L2-LL) User5(L2-LR) User6(L2-RL) User7(L2-RR)
|
||||
/ \ / \ / \ / \
|
||||
U8(L3) U9(L3) U10(L3) U11(L3) U12(L3) U13(L3) U14(L3) U15(L3)
|
||||
/ \ / \ / \ / \ / \ / \ / \ / \
|
||||
U16-U31 (Level 4 - 16 users)
|
||||
/\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\
|
||||
U32-U63 (Level 5 - 32 users)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 دادههای ورودی
|
||||
|
||||
### فرضیات:
|
||||
- **هفته فعلی**: 2025-W50
|
||||
- **سقف امتیاز**: 300
|
||||
- **تعداد کل کاربران**: 63 نفر (6 لول: 1+2+4+8+16+32)
|
||||
- **وضعیت**: همه کاربران فعال هستند (عضو باشگاه)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 5 (پایینترین سطح)
|
||||
|
||||
### User 32-63 (32 کاربر Leaf):
|
||||
```
|
||||
هیچ زیرمجموعهای ندارند
|
||||
چپ = 0، راست = 0
|
||||
تعادل = MIN(0, 0) = 0
|
||||
امتیاز = 0
|
||||
باقیمانده چپ = 0
|
||||
باقیمانده راست = 0
|
||||
فلش = 0
|
||||
```
|
||||
|
||||
**خلاصه Level 5**: تمام 32 کاربر → 0 امتیاز
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 4 (User 16-31)
|
||||
|
||||
### User 16:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 32 (1 نفر)
|
||||
- راست: User 33 (1 نفر)
|
||||
|
||||
**محاسبات**:
|
||||
```
|
||||
چپ = 1، راست = 1
|
||||
تعادل اولیه = MIN(1, 1) = 1
|
||||
باقیمانده چپ = 1 - 1 = 0
|
||||
باقیمانده راست = 1 - 1 = 0
|
||||
امتیاز نهایی = MIN(1, 300) = 1 ✅
|
||||
فلش = 0
|
||||
```
|
||||
|
||||
### User 17:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 34 (1 نفر)
|
||||
- راست: User 35 (1 نفر)
|
||||
|
||||
**محاسبات**: مشابه User 16
|
||||
```
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 18-31 (14 کاربر دیگه):
|
||||
همه مشابه User 16 → هر کدام 1 امتیاز
|
||||
|
||||
**خلاصه Level 4**: تمام 16 کاربر → هر کدام 1 امتیاز = **16 امتیاز**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 3 (User 8-15)
|
||||
|
||||
### User 8:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 16 (1 نفر)
|
||||
- راست: User 17 (1 نفر)
|
||||
|
||||
**محاسبات**:
|
||||
```
|
||||
چپ = 1، راست = 1
|
||||
تعادل = MIN(1, 1) = 1
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 9:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 18 (1 نفر)
|
||||
- راست: User 19 (1 نفر)
|
||||
|
||||
**محاسبات**: مشابه User 8
|
||||
```
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 10-15 (6 کاربر دیگه):
|
||||
همه مشابه → هر کدام 1 امتیاز
|
||||
|
||||
**خلاصه Level 3**: تمام 8 کاربر → هر کدام 1 امتیاز = **8 امتیاز**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 2 (User 4-7)
|
||||
|
||||
### User 4:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 8 (1 نفر)
|
||||
- راست: User 9 (1 نفر)
|
||||
|
||||
**محاسبات**:
|
||||
```
|
||||
چپ = 1، راست = 1
|
||||
تعادل = MIN(1, 1) = 1
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 5:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 10 (1 نفر)
|
||||
- راست: User 11 (1 نفر)
|
||||
|
||||
**محاسبات**: مشابه User 4
|
||||
```
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 6, 7:
|
||||
همه مشابه → هر کدام 1 امتیاز
|
||||
|
||||
**خلاصه Level 2**: تمام 4 کاربر → هر کدام 1 امتیاز = **4 امتیاز**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 1 (User 2-3)
|
||||
|
||||
### User 2:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 4 (1 نفر)
|
||||
- راست: User 5 (1 نفر)
|
||||
|
||||
**محاسبات**:
|
||||
```
|
||||
چپ = 1، راست = 1
|
||||
تعادل = MIN(1, 1) = 1
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
### User 3:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 6 (1 نفر)
|
||||
- راست: User 7 (1 نفر)
|
||||
|
||||
**محاسبات**: مشابه User 2
|
||||
```
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
**خلاصه Level 1**: تمام 2 کاربر → هر کدام 1 امتیاز = **2 امتیاز**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 محاسبات Level 0 (User 1 - Root)
|
||||
|
||||
### User 1:
|
||||
**زیرمجموعه**:
|
||||
- چپ: User 2 (1 نفر)
|
||||
- راست: User 3 (1 نفر)
|
||||
|
||||
**محاسبات**:
|
||||
```
|
||||
چپ = 1، راست = 1
|
||||
تعادل = MIN(1, 1) = 1
|
||||
امتیاز = 1 ✅
|
||||
```
|
||||
|
||||
**خلاصه Level 0**: User 1 → **1 امتیاز**
|
||||
|
||||
---
|
||||
|
||||
## 📊 جمع کل سیستم
|
||||
|
||||
| Level | تعداد کاربران | امتیاز هر کاربر | جمع امتیازهای Level |
|
||||
|-------|---------------|-----------------|---------------------|
|
||||
| 5 | 32 | 0 | 0 |
|
||||
| 4 | 16 | 1 | 16 |
|
||||
| 3 | 8 | 1 | 8 |
|
||||
| 2 | 4 | 1 | 4 |
|
||||
| 1 | 2 | 1 | 2 |
|
||||
| 0 | 1 | 1 | 1 |
|
||||
| **جمع** | **63** | - | **31 امتیاز** |
|
||||
|
||||
---
|
||||
|
||||
## 💰 محاسبه صندوق
|
||||
|
||||
### دادههای ورودی:
|
||||
```
|
||||
تعداد کاربران فعال شده این هفته: 63 نفر
|
||||
هزینه فعالسازی هر نفر: 25,000,000 ریال
|
||||
درصد سهم استخر: 20%
|
||||
|
||||
جمع ورودی استخر = 63 × 25,000,000 × 20%
|
||||
= 63 × 5,000,000
|
||||
= 315,000,000 ریال
|
||||
```
|
||||
|
||||
### محاسبه ارزش هر امتیاز:
|
||||
```
|
||||
مجموع امتیازهای سیستم = 31
|
||||
جمع استخر = 315,000,000 ریال
|
||||
|
||||
ارزش هر امتیاز = 315,000,000 ÷ 31
|
||||
= 10,161,290 ریال (تقریباً)
|
||||
```
|
||||
|
||||
### توزیع کمیسیون:
|
||||
```
|
||||
User 1: 1 × 10,161,290 = 10,161,290 ریال
|
||||
User 2: 1 × 10,161,290 = 10,161,290 ریال
|
||||
User 3: 1 × 10,161,290 = 10,161,290 ریال
|
||||
User 4-7: 4 × 10,161,290 = 40,645,160 ریال
|
||||
User 8-15: 8 × 10,161,290 = 81,290,320 ریال
|
||||
User 16-31: 16 × 10,161,290 = 162,580,640 ریال
|
||||
User 32-63: 0 ریال (امتیازی ندارند)
|
||||
|
||||
جمع کل پرداختی = 315,000,000 ریال ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔥 مثال پیچیدهتر: سناریو نامتعادل
|
||||
|
||||
### تغییر ساختار:
|
||||
```
|
||||
User 1:
|
||||
چپ: 500 نفر (عمق زیاد)
|
||||
راست: 600 نفر (عمق بیشتر)
|
||||
```
|
||||
|
||||
### محاسبات User 1:
|
||||
```
|
||||
مرحله 1️⃣: تعادل اولیه
|
||||
چپ = 500، راست = 600
|
||||
تعادل = MIN(500, 600) = 500
|
||||
|
||||
مرحله 2️⃣: باقیمانده
|
||||
باقی چپ = 500 - 500 = 0
|
||||
باقی راست = 600 - 500 = 100 → هفته بعد
|
||||
|
||||
مرحله 3️⃣: اعمال سقف
|
||||
امتیاز = MIN(500, 300) = 300 ✅
|
||||
|
||||
مرحله 4️⃣: فلش
|
||||
فلش از چپ = 500 - 300 = 200
|
||||
فلش از راست = 500 - 300 = 200
|
||||
جمع فلش = 400 (از بین میرود)
|
||||
```
|
||||
|
||||
### نتیجه:
|
||||
```
|
||||
✅ امتیاز User 1: 300
|
||||
✅ باقیمانده راست: 100 (میرود هفته بعد)
|
||||
✅ باقیمانده چپ: 0
|
||||
✅ فلش شده: 400 (از بین رفته)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 مثال با Carryover (هفته بعد)
|
||||
|
||||
### فرض: User 1 در هفته 2025-W51:
|
||||
```
|
||||
باقیمانده هفته قبل:
|
||||
چپ: 0
|
||||
راست: 100
|
||||
|
||||
جدیدهای این هفته:
|
||||
چپ: 250
|
||||
راست: 150
|
||||
```
|
||||
|
||||
### محاسبات:
|
||||
```
|
||||
مرحله 1️⃣: جمع با هفته قبل
|
||||
چپ کل = 0 + 250 = 250
|
||||
راست کل = 100 + 150 = 250
|
||||
|
||||
مرحله 2️⃣: تعادل
|
||||
تعادل = MIN(250, 250) = 250
|
||||
|
||||
مرحله 3️⃣: باقیمانده
|
||||
باقی چپ = 250 - 250 = 0
|
||||
باقی راست = 250 - 250 = 0
|
||||
|
||||
مرحله 4️⃣: امتیاز
|
||||
امتیاز = MIN(250, 300) = 250 ✅
|
||||
|
||||
مرحله 5️⃣: فلش
|
||||
فلش = 0 (چون 250 < 300)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 مثال سقف: User با شبکه بزرگ
|
||||
|
||||
### User A:
|
||||
```
|
||||
چپ: 800 نفر
|
||||
راست: 900 نفر
|
||||
```
|
||||
|
||||
### محاسبات:
|
||||
```
|
||||
تعادل = MIN(800, 900) = 800
|
||||
باقی چپ = 800 - 800 = 0
|
||||
باقی راست = 900 - 800 = 100
|
||||
|
||||
امتیاز = MIN(800, 300) = 300 ✅
|
||||
|
||||
فلش:
|
||||
از چپ: 800 - 300 = 500
|
||||
از راست: 800 - 300 = 500
|
||||
جمع: 1000 (از بین میرود)
|
||||
```
|
||||
|
||||
**نتیجه**: حتی با 800 تعادل، فقط **300 امتیاز** میگیرد!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 جمعبندی قوانین
|
||||
|
||||
### ✅ قوانین کلیدی:
|
||||
1. **تعادل** = MIN(چپ، راست)
|
||||
2. **باقیمانده** = طرفی که بیشتر است (قبل از سقف)
|
||||
3. **امتیاز** = MIN(تعادل، 300)
|
||||
4. **فلش** = (تعادل - 300) از هر دو طرف (اگر > 300)
|
||||
5. **محاسبه مستقل** = هر کاربر جداگانه
|
||||
6. **جمع صندوق** = مجموع امتیازهای همه
|
||||
|
||||
### ✅ نکات مهم:
|
||||
- باقیمانده **جداگانه** ذخیره میشود (چپ و راست)
|
||||
- فلش از **هر دو طرف** اتفاق میافتد
|
||||
- سقف 300 روی **امتیاز نهایی** اعمال میشود
|
||||
- هر کاربر مستقل از دیگران محاسبه میشود
|
||||
|
||||
---
|
||||
|
||||
## 📊 جدول مقایسه سناریوها
|
||||
|
||||
| سناریو | چپ | راست | تعادل | امتیاز | باقی چپ | باقی راست | فلش کل |
|
||||
|--------|-----|-------|--------|--------|---------|-----------|---------|
|
||||
| متعادل کوچک | 50 | 50 | 50 | 50 | 0 | 0 | 0 |
|
||||
| متعادل متوسط | 200 | 200 | 200 | 200 | 0 | 0 | 0 |
|
||||
| نامتعادل کوچک | 100 | 150 | 100 | 100 | 0 | 50 | 0 |
|
||||
| نامتعادل متوسط | 250 | 350 | 250 | 250 | 0 | 100 | 0 |
|
||||
| **سقف ساده** | **350** | **350** | **350** | **300** | **0** | **0** | **100** |
|
||||
| **سقف نامتعادل** | **500** | **600** | **500** | **300** | **0** | **100** | **400** |
|
||||
| سقف بزرگ | 800 | 900 | 800 | 300 | 0 | 100 | 1000 |
|
||||
|
||||
---
|
||||
|
||||
**پایان مثالهای عملی**
|
||||
|
||||
این مستند تمام حالات ممکن محاسبه تعادل را با مثالهای عددی واقعی نشان میدهد.
|
||||
@@ -0,0 +1,546 @@
|
||||
# Balance Calculation with Carryover Logic - Complete Guide
|
||||
|
||||
**Date**: 2025-12-01
|
||||
**Last Updated**: 2025-12-09 (✅ اصلاح نهایی: محاسبات تعادل و فلش)
|
||||
**Status**: ✅ Fully Implemented & Verified
|
||||
**Migration**: `UpdateNetworkWeeklyBalanceWithCarryover`
|
||||
|
||||
---
|
||||
|
||||
## ✅ آخرین بهروزرسانی (2025-12-09)
|
||||
|
||||
### تغییرات اعمال شده:
|
||||
کد محاسبه تعادل با توضیحات دقیق بیزینس تطبیق داده شد:
|
||||
|
||||
1. ✅ **ترتیب محاسبات اصلاح شد**:
|
||||
- اول تعادل اولیه محاسبه میشود
|
||||
- بعد باقیمانده (برای هفته بعد)
|
||||
- سپس سقف 300 اعمال میشود
|
||||
- در نهایت فلش محاسبه میشود
|
||||
|
||||
2. ✅ **فلش از هر دو طرف**:
|
||||
- اگر تعادل > 300 باشد
|
||||
- از چپ: (تعادل - 300) فلش میشود
|
||||
- از راست: (تعادل - 300) فلش میشود
|
||||
- جمع فلش = (تعادل - 300) × 2
|
||||
|
||||
3. ✅ **باقیمانده جداگانه ذخیره میشود**:
|
||||
- `LeftLegRemainder`: باقیمانده دست چپ
|
||||
- `RightLegRemainder`: باقیمانده دست راست
|
||||
|
||||
---
|
||||
|
||||
## 📋 قوانین اصلی بیزینس
|
||||
|
||||
| توضیح | منطق فعلی (اشتباه) | منطق صحیح |
|
||||
|-------|---------------------|-----------|
|
||||
| سقف | 300 کل | 300 برای هر دست |
|
||||
| حداکثر تعادل | 300 | MIN(300, 300) = 300 |
|
||||
| حداکثر کل | 300 | 300 + 300 = 600 (مجموع دو دست) |
|
||||
|
||||
### تفاوت در محاسبه:
|
||||
|
||||
**منطق فعلی (اشتباه):**
|
||||
```csharp
|
||||
totalBalances = MIN(leftTotal, rightTotal)
|
||||
cappedBalances = MIN(totalBalances, 300) // ← سقف روی کل
|
||||
```
|
||||
|
||||
**منطق صحیح:**
|
||||
```csharp
|
||||
cappedLeftTotal = MIN(leftTotal, 300) // ← سقف روی هر دست
|
||||
cappedRightTotal = MIN(rightTotal, 300)
|
||||
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)
|
||||
```
|
||||
|
||||
### مثال عملی:
|
||||
|
||||
| سناریو | چپ | راست | منطق فعلی | منطق صحیح |
|
||||
|--------|-----|-------|-----------|-----------|
|
||||
| 1 | 200 | 250 | 200 | 200 |
|
||||
| 2 | 350 | 400 | **300** ❌ | **300** ✅ |
|
||||
| 3 | 500 | 600 | **300** ❌ | **300** ✅ |
|
||||
|
||||
**توجه:** در مثالهای بالا نتیجه یکسان است چون حداکثر یک تعادل همیشه MIN(300,300)=300 است. تفاوت در **باقیمانده** است:
|
||||
|
||||
**مثال با چپ=500، راست=600:**
|
||||
|
||||
| روش | تعادل | باقیمانده چپ | باقیمانده راست |
|
||||
|-----|--------|--------------|----------------|
|
||||
| فعلی | 300 | 500 - 150 = 350 | 600 - 150 = 450 |
|
||||
| صحیح | 300 | **200** (500-300) | **300** (600-300) |
|
||||
|
||||
### تغییرات Configuration:
|
||||
|
||||
```csharp
|
||||
// ✅ تغییر نام و مقدار:
|
||||
// قدیمی:
|
||||
Key = "Commission.MaxWeeklyBalancesPerUser", Value = "300"
|
||||
|
||||
// جدید:
|
||||
Key = "Commission.MaxWeeklyBalancesPerLeg", Value = "300"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Configuration-Based Calculation
|
||||
|
||||
### **System Configurations Used:**
|
||||
|
||||
```csharp
|
||||
// تمام مقادیر از جدول SystemConfigurations خوانده میشوند
|
||||
Club.ActivationFee = 25,000,000 ریال (هزینه فعالسازی)
|
||||
Commission.WeeklyPoolContributionPercent = 20% (سهم استخر)
|
||||
Commission.MaxWeeklyBalancesPerLeg = 300 (✅ سقف امتیاز نهایی)
|
||||
```
|
||||
|
||||
**نکته مهم**: سقف 300 روی **امتیاز نهایی** اعمال میشود، نه روی تعادل اولیه!
|
||||
|
||||
### **Pool Contribution Calculation:**
|
||||
|
||||
```csharp
|
||||
totalNewMembers = leftNewMembers + rightNewMembers
|
||||
weeklyPoolContribution = totalNewMembers × activationFee × poolPercent
|
||||
= totalNewMembers × 25,000,000 × 20%
|
||||
= totalNewMembers × 5,000,000
|
||||
```
|
||||
|
||||
**مثال:**
|
||||
اگر 10 نفر جدید جذب شوند: `10 × 5,000,000 = 50,000,000` ریال به استخر اضافه میشود.
|
||||
|
||||
---
|
||||
|
||||
## 🚫 MaxWeeklyBalances Cap (محدودیت سقف 300)
|
||||
|
||||
### **Logic صحیح (بهروز شده 2025-12-09):**
|
||||
|
||||
```csharp
|
||||
// ✅ مرحله 1: محاسبه تعادل اولیه (بدون سقف)
|
||||
totalBalances = MIN(leftTotal, rightTotal)
|
||||
|
||||
// ✅ مرحله 2: محاسبه باقیمانده برای هفته بعد
|
||||
leftRemainder = leftTotal - totalBalances
|
||||
rightRemainder = rightTotal - totalBalances
|
||||
|
||||
// ✅ مرحله 3: اعمال سقف 300 (برای امتیاز نهایی)
|
||||
cappedBalances = MIN(totalBalances, 300)
|
||||
|
||||
// ✅ مرحله 4: محاسبه فلش (از هر دو طرف)
|
||||
flushedPerSide = totalBalances - cappedBalances
|
||||
totalFlushed = flushedPerSide × 2
|
||||
```
|
||||
|
||||
### **Example (مثال کامل):**
|
||||
|
||||
```
|
||||
leftTotal = 500, rightTotal = 600
|
||||
|
||||
مرحله 1️⃣: تعادل اولیه
|
||||
totalBalances = MIN(500, 600) = 500 ✅
|
||||
|
||||
مرحله 2️⃣: باقیمانده برای هفته بعد
|
||||
leftRemainder = 500 - 500 = 0 ✅
|
||||
rightRemainder = 600 - 500 = 100 ✅
|
||||
|
||||
مرحله 3️⃣: اعمال سقف
|
||||
cappedBalances = MIN(500, 300) = 300 ✅
|
||||
|
||||
مرحله 4️⃣: محاسبه فلش
|
||||
flushedPerSide = 500 - 300 = 200
|
||||
از چپ: 200 فلش میشود
|
||||
از راست: 200 فلش میشود
|
||||
totalFlushed = 200 × 2 = 400 ✅
|
||||
|
||||
نتیجه نهایی:
|
||||
✅ امتیاز این هفته: 300
|
||||
✅ باقیمانده چپ: 0
|
||||
✅ باقیمانده راست: 100
|
||||
✅ جمع فلش: 400 (از بین میرود)
|
||||
```
|
||||
|
||||
### **مقایسه منطق قدیم vs جدید:**
|
||||
|
||||
```
|
||||
// ❌ منطق قدیم (اشتباه):
|
||||
cappedBalances = MIN(totalBalances, 300) // سقف روی کل
|
||||
balancesConsumedPerSide = cappedBalances / 2
|
||||
leftRemainder = leftTotal - balancesConsumedPerSide
|
||||
|
||||
// ✅ منطق جدید (صحیح):
|
||||
cappedLeftTotal = MIN(leftTotal, 300) // سقف روی هر دست
|
||||
cappedRightTotal = MIN(rightTotal, 300)
|
||||
totalBalances = MIN(cappedLeftTotal, cappedRightTotal)
|
||||
leftRemainder = leftTotal - cappedLeftTotal // باقیمانده از سقف هر دست
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Problem Statement
|
||||
|
||||
### ❌ **Previous (Incorrect) Logic:**
|
||||
|
||||
```csharp
|
||||
// محاسبه تعداد کل اعضا در هر پا
|
||||
## ✅ **Current (Correct) Logic - Updated 2025-12-09:**
|
||||
|
||||
### **Formula (4 مرحله):**
|
||||
```
|
||||
// مرحله 1: جمع با هفته قبل
|
||||
leftTotal = leftNewMembers + leftCarryover
|
||||
rightTotal = rightNewMembers + rightCarryover
|
||||
|
||||
// مرحله 2: محاسبه تعادل اولیه
|
||||
totalBalances = MIN(leftTotal, rightTotal)
|
||||
|
||||
// مرحله 3: محاسبه باقیمانده برای هفته بعد
|
||||
leftRemainder = leftTotal - totalBalances
|
||||
rightRemainder = rightTotal - totalBalances
|
||||
|
||||
// مرحله 4: اعمال سقف 300
|
||||
cappedBalances = MIN(totalBalances, 300)
|
||||
flushedPerSide = totalBalances - cappedBalances
|
||||
totalFlushed = flushedPerSide × 2
|
||||
```
|
||||
|
||||
### **Key Principles:**
|
||||
1. **Only count NEW members** activated in current week
|
||||
2. **Add carryover** from previous week (جداگانه چپ و راست)
|
||||
3. **Calculate remainder** for next week (قبل از سقف)
|
||||
4. **Apply cap 300** on final score (بعد از تعادل)
|
||||
5. **Flush from both sides** if balance > 300
|
||||
6. **Recursive counting** through entire tree structure
|
||||
leftTotal = leftNewMembers + leftCarryover
|
||||
rightTotal = rightNewMembers + rightCarryover
|
||||
|
||||
TotalBalances = MIN(leftTotal, rightTotal)
|
||||
|
||||
leftRemainder = leftTotal - TotalBalances
|
||||
rightRemainder = rightTotal - TotalBalances
|
||||
```
|
||||
|
||||
### **Key Principles:**
|
||||
1. **Only count NEW members** activated in current week
|
||||
2. **Add carryover** from previous week
|
||||
3. **Calculate remainder** for next week
|
||||
4. **Recursive counting** through entire tree structure
|
||||
|
||||
---
|
||||
|
||||
## 🔢 Example Calculations
|
||||
|
||||
### **Week 1 (2025-W48):**
|
||||
|
||||
**Tree Structure:**
|
||||
```
|
||||
User A (Activated this week - 25M to pool)
|
||||
├─ Left: User B (Activated this week - 25M)
|
||||
└─ Right: User C (Activated this week - 25M)
|
||||
```
|
||||
|
||||
**Calculations:**
|
||||
```
|
||||
User A:
|
||||
leftNewMembers = 1 (User B activated)
|
||||
rightNewMembers = 1 (User C activated)
|
||||
leftCarryover = 0 (first week)
|
||||
rightCarryover = 0 (first week)
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 1 + 0 = 1
|
||||
|
||||
TotalBalances = MIN(1, 1) = 1
|
||||
|
||||
leftRemainder = 1 - 1 = 0
|
||||
rightRemainder = 1 - 1 = 0
|
||||
|
||||
User B: TotalBalances = 0 (no children)
|
||||
User C: TotalBalances = 0 (no children)
|
||||
```
|
||||
|
||||
**Pool Calculation:**
|
||||
```
|
||||
Total Pool = 75M (3 activations × 25M)
|
||||
Total Balances = 1 (only User A)
|
||||
Value Per Balance = 75M ÷ 1 = 75M
|
||||
|
||||
Commission:
|
||||
User A = 1 × 75M = 75M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Week 2 (2025-W49):**
|
||||
|
||||
**Tree Structure:**
|
||||
```
|
||||
User A
|
||||
├─ Left: User B
|
||||
│ ├─ Left: User D (NEW - activated this week - 25M)
|
||||
│ └─ Right: User E (NEW - activated this week - 25M)
|
||||
└─ Right: User C
|
||||
├─ Left: User F (NEW - activated this week - 25M)
|
||||
└─ Right: User G (NEW - activated this week - 25M)
|
||||
```
|
||||
|
||||
**Calculations:**
|
||||
```
|
||||
User B:
|
||||
leftNewMembers = 1 (User D)
|
||||
rightNewMembers = 1 (User E)
|
||||
leftCarryover = 0
|
||||
rightCarryover = 0
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 1 + 0 = 1
|
||||
TotalBalances = MIN(1, 1) = 1
|
||||
|
||||
User C:
|
||||
leftNewMembers = 1 (User F)
|
||||
rightNewMembers = 1 (User G)
|
||||
leftCarryover = 0
|
||||
rightCarryover = 0
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 1 + 0 = 1
|
||||
TotalBalances = MIN(1, 1) = 1
|
||||
|
||||
User A:
|
||||
leftNewMembers = 2 (D & E through B)
|
||||
rightNewMembers = 2 (F & G through C)
|
||||
leftCarryover = 0 (from week 1)
|
||||
rightCarryover = 0 (from week 1)
|
||||
|
||||
leftTotal = 2 + 0 = 2
|
||||
rightTotal = 2 + 0 = 2
|
||||
TotalBalances = MIN(2, 2) = 2 ✅
|
||||
|
||||
leftRemainder = 2 - 2 = 0
|
||||
rightRemainder = 2 - 2 = 0
|
||||
```
|
||||
|
||||
**Pool Calculation:**
|
||||
```
|
||||
Total Pool = 100M (4 new activations × 25M)
|
||||
Total Balances = 4 (A=2, B=1, C=1)
|
||||
Value Per Balance = 100M ÷ 4 = 25M
|
||||
|
||||
Commission:
|
||||
User A = 2 × 25M = 50M ✅ (not 33.33M!)
|
||||
User B = 1 × 25M = 25M
|
||||
User C = 1 × 25M = 25M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Week 3 (2025-W50) - With Carryover:**
|
||||
|
||||
**Tree Structure:**
|
||||
```
|
||||
User A
|
||||
├─ Left: User B
|
||||
│ ├─ Left: User D
|
||||
│ │ └─ Left: User H (NEW - 25M)
|
||||
│ └─ Right: User E
|
||||
└─ Right: User C
|
||||
├─ Left: User F
|
||||
└─ Right: User G
|
||||
```
|
||||
|
||||
**Calculations:**
|
||||
```
|
||||
User D:
|
||||
leftNewMembers = 1 (User H)
|
||||
rightNewMembers = 0
|
||||
leftCarryover = 0
|
||||
rightCarryover = 0
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 0 + 0 = 0
|
||||
TotalBalances = MIN(1, 0) = 0
|
||||
|
||||
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
|
||||
rightRemainder = 0 - 0 = 0
|
||||
|
||||
User B:
|
||||
leftNewMembers = 1 (H through D)
|
||||
rightNewMembers = 0
|
||||
leftCarryover = 0 (from week 2)
|
||||
rightCarryover = 0
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 0 + 0 = 0
|
||||
TotalBalances = MIN(1, 0) = 0
|
||||
|
||||
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
|
||||
rightRemainder = 0 - 0 = 0
|
||||
|
||||
User A:
|
||||
leftNewMembers = 1 (H through B→D)
|
||||
rightNewMembers = 0
|
||||
leftCarryover = 0 (from week 2)
|
||||
rightCarryover = 0
|
||||
|
||||
leftTotal = 1 + 0 = 1
|
||||
rightTotal = 0 + 0 = 0
|
||||
TotalBalances = MIN(1, 0) = 0
|
||||
|
||||
leftRemainder = 1 - 0 = 1 ⚠️ (saved for next week)
|
||||
rightRemainder = 0 - 0 = 0
|
||||
```
|
||||
|
||||
**Pool Calculation:**
|
||||
```
|
||||
Total Pool = 25M (1 new activation)
|
||||
Total Balances = 0 (no balanced pairs)
|
||||
Value Per Balance = N/A
|
||||
|
||||
Commission: None this week
|
||||
Carryover: User A, B, D each have 1 leftRemainder for week 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Database Schema
|
||||
|
||||
### **NetworkWeeklyBalance Table:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE NetworkWeeklyBalances ADD:
|
||||
-- New members this week
|
||||
LeftLegNewMembers INT NOT NULL DEFAULT 0,
|
||||
RightLegNewMembers INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Carryover from previous week
|
||||
LeftLegCarryover INT NOT NULL DEFAULT 0,
|
||||
RightLegCarryover INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Totals (new + carryover)
|
||||
LeftLegTotal INT NOT NULL DEFAULT 0,
|
||||
RightLegTotal INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Remainder for next week
|
||||
LeftLegRemainder INT NOT NULL DEFAULT 0,
|
||||
RightLegRemainder INT NOT NULL DEFAULT 0
|
||||
```
|
||||
|
||||
**Deprecated Fields:**
|
||||
- `LeftLegBalances` (still exists for backward compatibility)
|
||||
- `RightLegBalances` (still exists for backward compatibility)
|
||||
|
||||
---
|
||||
|
||||
## 💻 Implementation
|
||||
|
||||
### **Handler: CalculateWeeklyBalancesCommandHandler.cs**
|
||||
|
||||
```csharp
|
||||
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Load previous week's carryover
|
||||
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
|
||||
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
|
||||
.Where(x => x.WeekNumber == previousWeekNumber)
|
||||
.ToDictionaryAsync(x => x.UserId, x => new { x.LeftLegRemainder, x.RightLegRemainder });
|
||||
|
||||
// 2. For each user in network
|
||||
foreach (var user in usersInNetwork)
|
||||
{
|
||||
// Get carryover
|
||||
var leftCarryover = previousWeekCarryovers.ContainsKey(user.Id)
|
||||
? previousWeekCarryovers[user.Id].LeftLegRemainder : 0;
|
||||
var rightCarryover = previousWeekCarryovers.ContainsKey(user.Id)
|
||||
? previousWeekCarryovers[user.Id].RightLegRemainder : 0;
|
||||
|
||||
// Count NEW members (activated in this week)
|
||||
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber);
|
||||
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber);
|
||||
|
||||
// Calculate totals
|
||||
var leftTotal = leftNewMembers + leftCarryover;
|
||||
var rightTotal = rightNewMembers + rightCarryover;
|
||||
|
||||
// Calculate balance (min)
|
||||
var totalBalances = Math.Min(leftTotal, rightTotal);
|
||||
|
||||
// Calculate remainder
|
||||
var leftRemainder = leftTotal - totalBalances;
|
||||
var rightRemainder = rightTotal - totalBalances;
|
||||
|
||||
// Save to database
|
||||
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,
|
||||
// ...
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> CountNewMembersRecursive(long userId, NetworkLeg leg, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var child = await _context.Users
|
||||
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == leg);
|
||||
|
||||
if (child == null) return 0;
|
||||
|
||||
var count = 0;
|
||||
|
||||
// Check if activated in this week
|
||||
var membership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive);
|
||||
|
||||
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
|
||||
{
|
||||
count = 1;
|
||||
}
|
||||
|
||||
// Recursively count children
|
||||
var childLeft = await CountNewMembersRecursive(child.Id, NetworkLeg.Left, startDate, endDate);
|
||||
var childRight = await CountNewMembersRecursive(child.Id, NetworkLeg.Right, startDate, endDate);
|
||||
|
||||
return count + childLeft + childRight;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Key Points
|
||||
|
||||
1. ✅ **Only NEW activations count** - filtered by `ActivatedAt` date
|
||||
2. ✅ **Carryover persists** - unused balances roll over to next week
|
||||
3. ✅ **Recursive counting** - includes entire subtree under each leg
|
||||
4. ✅ **Week date ranges** - ISO 8601 week format (Saturday to Friday)
|
||||
5. ✅ **Idempotent** - can recalculate with `ForceRecalculate` flag
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Benefits
|
||||
|
||||
1. **Fair commission distribution** - rewards balanced growth
|
||||
2. **No lost balances** - carryover ensures nothing is wasted
|
||||
3. **Accurate tracking** - distinguishes new vs existing members
|
||||
4. **Scalable** - works for large networks with recursive algorithm
|
||||
5. **Auditable** - full history of calculations in database
|
||||
|
||||
---
|
||||
|
||||
## 📞 Reference
|
||||
|
||||
- **Source Code**: `CMSMicroservice.Application/CommissionCQ/Commands/CalculateWeeklyBalances/`
|
||||
- **Migration**: `20251201144400_UpdateNetworkWeeklyBalanceWithCarryover`
|
||||
- **Entity**: `CMSMicroservice.Domain/Entities/Network/NetworkWeeklyBalance.cs`
|
||||
- **Discussion**: Telegram chat with Dr. Seif (2025-12-01)
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Production Ready
|
||||
**Last Updated**: 2025-12-01
|
||||
@@ -0,0 +1,656 @@
|
||||
# Base Package Payment System - سیستم پرداخت پکیج پایه
|
||||
|
||||
**تاریخ ایجاد:** 2024-12-16
|
||||
**تاریخ آخرین بهروزرسانی:** 2024-12-16
|
||||
**وضعیت:** ✅ پیادهسازی شده
|
||||
**اولویت:** 🔴 بسیار بالا
|
||||
|
||||
---
|
||||
|
||||
## 📋 فهرست
|
||||
|
||||
1. [خلاصه سیستم](#خلاصه-سیستم)
|
||||
2. [Business Requirements](#business-requirements)
|
||||
3. [معماری سیستم](#معماری-سیستم)
|
||||
4. [Implementation Details](#implementation-details)
|
||||
5. [Club Membership Contract System](#club-membership-contract-system)
|
||||
6. [API Endpoints](#api-endpoints)
|
||||
7. [Flow Diagram](#flow-diagram)
|
||||
8. [نکات مهم](#نکات-مهم)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 خلاصه سیستم
|
||||
|
||||
سیستم پرداخت پکیج پایه امکان پرداخت **56 میلیون تومان** را برای کاربران فراهم میکند تا بتوانند:
|
||||
1. کیف پول خود را شارژ کنند (Balance + DiscountBalance)
|
||||
2. **امضای قرارداد باشگاه مشتریان** (گام الزامی بعد از پرداخت)
|
||||
3. **فعالسازی لینک دعوت** (Referral Link) - تنها بعد از امضای قرارداد
|
||||
4. دسترسی کامل به امکانات باشگاه مشتریان
|
||||
|
||||
### دو روش پرداخت:
|
||||
1. **پرداخت مستقیم (Direct Payment)** - از طریق درگاه بانکی (زرینپال)
|
||||
2. **اعتبار الماسی دایا (Daya Loan)** - از طریق سایت دایا
|
||||
|
||||
---
|
||||
|
||||
## 📊 Business Requirements
|
||||
|
||||
### شرایط نمایش لینک دعوت:
|
||||
```
|
||||
CanShowReferralLink = HasPurchasedPackage && IsClubMemberActive
|
||||
```
|
||||
|
||||
- **HasPurchasedPackage**: کاربر پکیج پایه را خریداری کرده (PackagePurchaseMethod != None)
|
||||
- **IsClubMemberActive**: قرارداد باشگاه مشتریان امضا شده (ClubMembership.IsActive = true)
|
||||
|
||||
⚠️ **نکته مهم**: پرداخت پکیج به تنهایی کافی نیست! کاربر باید قرارداد باشگاه مشتریان را نیز امضا کند.
|
||||
|
||||
### مقدار پکیج:
|
||||
- **مبلغ**: 56,000,000 تومان
|
||||
- **شارژ Balance**: 56,000,000 تومان
|
||||
- **شارژ DiscountBalance**: 56,000,000 تومان
|
||||
|
||||
### PackagePurchaseMethod Enum:
|
||||
```csharp
|
||||
public enum PackagePurchaseMethod
|
||||
{
|
||||
None = 0, // هنوز خرید نکرده
|
||||
DirectPurchase = 1, // پرداخت مستقیم
|
||||
DayaLoan = 2 // اعتبار دایا
|
||||
}
|
||||
```
|
||||
|
||||
### ContractType Enum:
|
||||
```csharp
|
||||
public enum ContractType
|
||||
{
|
||||
Main = 0, // قرارداد ثبتنام اولیه
|
||||
ClubMembership = 1, // قرارداد باشگاه مشتریان
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ معماری سیستم
|
||||
|
||||
### Architecture Pattern:
|
||||
```
|
||||
Frontend (Blazor)
|
||||
↓
|
||||
BFF (Backend For Frontend)
|
||||
↓ ↘
|
||||
CMS PYMS (Payment Gateway)
|
||||
```
|
||||
|
||||
### Layer Responsibilities:
|
||||
|
||||
#### 1️⃣ Frontend (Blazor)
|
||||
- نمایش UI برای انتخاب روش پرداخت
|
||||
- فراخوانی BFF برای شروع پرداخت
|
||||
- مدیریت Callback از درگاه
|
||||
- نمایش نتیجه پرداخت
|
||||
- **Modal غیرقابل بسته شدن برای امضای قرارداد باشگاه** (جدید ✨)
|
||||
|
||||
#### 2️⃣ BFF (Middle Layer)
|
||||
- **InitiateBasePackagePayment**: هماهنگی بین CMS و PYMS
|
||||
- فراخوانی CMS برای ثبت Transaction + Order
|
||||
- فراخوانی PYMS برای دریافت URL درگاه
|
||||
- برگرداندن URL به Frontend
|
||||
|
||||
- **VerifyBasePackagePayment**: تأیید پرداخت
|
||||
- فراخوانی PYMS برای Verify
|
||||
- فراخوانی CMS برای شارژ یا Reject
|
||||
|
||||
- **RequestClubContractOtp**: ارسال OTP برای امضای قرارداد (جدید ✨)
|
||||
- **AcceptClubMembershipContract**: امضای قرارداد و فعالسازی باشگاه (جدید ✨)
|
||||
|
||||
#### 3️⃣ CMS (Core Business)
|
||||
- **InitiateBasePackagePayment**: ثبت Transaction + Order با Pending
|
||||
- **VerifyBasePackagePayment**: شارژ کیف پول یا Reject بر اساس نتیجه
|
||||
- **AcceptClubMembershipContract**: ثبت UserContract و فعالسازی ClubMembership (جدید ✨)
|
||||
|
||||
#### 4️⃣ PYMS (Payment Gateway Service)
|
||||
- **PaymentRequest**: دریافت URL درگاه زرینپال
|
||||
- **PaymentVerification**: تأیید پرداخت از بانک
|
||||
|
||||
---
|
||||
|
||||
## 💻 Implementation Details
|
||||
|
||||
### CMS Layer
|
||||
|
||||
#### Commands:
|
||||
1. **InitiateBasePackagePaymentCommand**
|
||||
```csharp
|
||||
// Input
|
||||
public record InitiateBasePackagePaymentCommand
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
}
|
||||
|
||||
// Output
|
||||
public class InitiateBasePackagePaymentResponseDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public long OrderId { get; set; }
|
||||
public long TransactionId { get; set; }
|
||||
public long Amount { get; set; } // 56,000,000
|
||||
}
|
||||
```
|
||||
|
||||
**Handler Logic:**
|
||||
- بررسی عدم خرید قبلی: `user.PackagePurchaseMethod == None`
|
||||
- بررسی عدم Order Pending قبلی
|
||||
- ایجاد Transaction با PaymentStatus.Pending
|
||||
- ایجاد UserOrder با PackageId=4, PaymentStatus.Pending
|
||||
- Return OrderId + TransactionId
|
||||
|
||||
2. **VerifyBasePackagePaymentCommand**
|
||||
```csharp
|
||||
// Input
|
||||
public record VerifyBasePackagePaymentCommand
|
||||
{
|
||||
public long OrderId { get; init; }
|
||||
public long TransactionId { get; init; }
|
||||
public bool PaymentSuccess { get; init; } // از BFF میآید
|
||||
public string? RefId { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
// Output
|
||||
public class VerifyBasePackagePaymentResponseDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public long OrderId { get; set; }
|
||||
public long TransactionId { get; set; }
|
||||
public string? ReferenceCode { get; set; }
|
||||
public long WalletBalance { get; set; }
|
||||
public long DiscountBalance { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Handler Logic (Success):**
|
||||
- شارژ `wallet.Balance += 56,000,000`
|
||||
- شارژ `wallet.DiscountBalance += 56,000,000`
|
||||
- ثبت Transaction با PaymentStatus.Success
|
||||
- ثبت UserWalletChangeLog (Balance + Discount)
|
||||
- Update Order: PaymentStatus.Success, PaymentMethod.IPG
|
||||
- Update User: PackagePurchaseMethod.DirectPurchase
|
||||
|
||||
**Handler Logic (Failed):**
|
||||
- Update Transaction: PaymentStatus.Reject
|
||||
- Update Order: PaymentStatus.Reject
|
||||
|
||||
#### Proto Definition:
|
||||
```protobuf
|
||||
// package.proto
|
||||
service PackageContract {
|
||||
rpc InitiateBasePackagePayment(InitiateBasePackagePaymentRequest)
|
||||
returns (InitiateBasePackagePaymentResponse);
|
||||
|
||||
rpc VerifyBasePackagePayment(VerifyBasePackagePaymentRequest)
|
||||
returns (VerifyBasePackagePaymentResponse);
|
||||
}
|
||||
|
||||
message InitiateBasePackagePaymentRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message InitiateBasePackagePaymentResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 order_id = 3;
|
||||
int64 transaction_id = 4;
|
||||
int64 amount = 5;
|
||||
}
|
||||
|
||||
message VerifyBasePackagePaymentRequest {
|
||||
int64 order_id = 1;
|
||||
int64 transaction_id = 2;
|
||||
bool payment_success = 3;
|
||||
google.protobuf.StringValue ref_id = 4;
|
||||
google.protobuf.StringValue message = 5;
|
||||
}
|
||||
|
||||
message VerifyBasePackagePaymentResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 order_id = 3;
|
||||
int64 transaction_id = 4;
|
||||
google.protobuf.StringValue reference_code = 5;
|
||||
int64 wallet_balance = 6;
|
||||
int64 discount_balance = 7;
|
||||
}
|
||||
```
|
||||
|
||||
#### Files Created/Modified:
|
||||
```
|
||||
CMS/src/CMSMicroservice.Application/PackageCQ/Commands/
|
||||
├── InitiateBasePackagePayment/
|
||||
│ ├── InitiateBasePackagePaymentCommand.cs
|
||||
│ ├── InitiateBasePackagePaymentCommandValidator.cs
|
||||
│ └── InitiateBasePackagePaymentCommandHandler.cs
|
||||
└── VerifyBasePackagePayment/
|
||||
├── VerifyBasePackagePaymentCommand.cs
|
||||
├── VerifyBasePackagePaymentCommandValidator.cs
|
||||
└── VerifyBasePackagePaymentCommandHandler.cs
|
||||
|
||||
CMS/src/CMSMicroservice.Protobuf/Protos/
|
||||
└── package.proto (updated)
|
||||
|
||||
CMS/src/CMSMicroservice.WebApi/
|
||||
├── Services/PackageService.cs (updated)
|
||||
└── Common/Mappings/PackageProfile.cs (updated)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BFF Layer
|
||||
|
||||
#### Commands:
|
||||
1. **InitiateBasePackagePaymentCommand**
|
||||
```csharp
|
||||
// Input (UserId از CurrentUserService گرفته میشود)
|
||||
public record InitiateBasePackagePaymentCommand
|
||||
{
|
||||
public string CallbackUrl { get; init; }
|
||||
}
|
||||
|
||||
// Output
|
||||
public class InitiateBasePackagePaymentResponseDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public long OrderId { get; set; }
|
||||
public long TransactionId { get; set; }
|
||||
public long Amount { get; set; }
|
||||
public string PaymentGatewayUrl { get; set; }
|
||||
public string Authority { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Handler Logic:**
|
||||
```csharp
|
||||
// 1. فراخوانی CMS
|
||||
var cmsResponse = await _context.Package.InitiateBasePackagePaymentAsync(
|
||||
new InitiateBasePackagePaymentRequest {
|
||||
UserId = _currentUserService.UserId.Value
|
||||
});
|
||||
|
||||
// 2. فراخوانی PYMS
|
||||
var paymentResponse = await _context.ZarinTransactions.PaymentRequestAsync(
|
||||
new PaymentRequestRequest {
|
||||
MerchantId = "...",
|
||||
Amount = cmsResponse.Amount * 10, // تبدیل به ریال
|
||||
CallbackUrl = $"{request.CallbackUrl}?orderId={...}&transactionId={...}",
|
||||
Description = "پرداخت پکیج پایه",
|
||||
Currency = CurrencyEnum.Irr,
|
||||
Type = TransactionTypeEnum.Real
|
||||
});
|
||||
|
||||
// 3. Return URL + Authority
|
||||
return new InitiateBasePackagePaymentResponseDto {
|
||||
PaymentGatewayUrl = paymentResponse.PaymentGWUrl,
|
||||
Authority = ExtractAuthorityFromUrl(paymentResponse.PaymentGWUrl),
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
2. **VerifyBasePackagePaymentCommand**
|
||||
```csharp
|
||||
// Input
|
||||
public record VerifyBasePackagePaymentCommand
|
||||
{
|
||||
public long OrderId { get; init; }
|
||||
public long TransactionId { get; init; }
|
||||
public string Authority { get; init; }
|
||||
public string Status { get; init; } // OK یا NOK
|
||||
}
|
||||
```
|
||||
|
||||
**Handler Logic:**
|
||||
```csharp
|
||||
// 1. بررسی Status
|
||||
if (request.Status != "OK") {
|
||||
await NotifyCmsPaymentFailed(...);
|
||||
return Failed;
|
||||
}
|
||||
|
||||
// 2. Verify از PYMS
|
||||
var verifyResponse = await _context.ZarinTransactions
|
||||
.PaymentVerificationAsync(...);
|
||||
|
||||
// 3. فراخوانی CMS
|
||||
if (verifyResponse.PaymentStatus) {
|
||||
var cmsResponse = await _context.Package.VerifyBasePackagePaymentAsync(
|
||||
new VerifyBasePackagePaymentRequest {
|
||||
OrderId = request.OrderId,
|
||||
TransactionId = request.TransactionId,
|
||||
PaymentSuccess = true,
|
||||
RefId = verifyResponse.RefId,
|
||||
Message = verifyResponse.Message
|
||||
});
|
||||
return Success;
|
||||
} else {
|
||||
await NotifyCmsPaymentFailed(...);
|
||||
return Failed;
|
||||
}
|
||||
```
|
||||
|
||||
#### Proto Definition:
|
||||
```protobuf
|
||||
// package.proto
|
||||
service PackageContract {
|
||||
rpc InitiateBasePackagePayment(InitiateBasePackagePaymentRequest)
|
||||
returns (InitiateBasePackagePaymentResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/InitiateBasePackagePayment"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
|
||||
rpc VerifyBasePackagePayment(VerifyBasePackagePaymentRequest)
|
||||
returns (VerifyBasePackagePaymentResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/VerifyBasePackagePayment"
|
||||
body: "*"
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
message InitiateBasePackagePaymentRequest {
|
||||
string callback_url = 1;
|
||||
// UserId از JWT token گرفته میشود
|
||||
}
|
||||
|
||||
message InitiateBasePackagePaymentResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 order_id = 3;
|
||||
int64 transaction_id = 4;
|
||||
int64 amount = 5;
|
||||
string payment_gateway_url = 6;
|
||||
string authority = 7;
|
||||
}
|
||||
|
||||
message VerifyBasePackagePaymentRequest {
|
||||
int64 order_id = 1;
|
||||
int64 transaction_id = 2;
|
||||
string authority = 3;
|
||||
string status = 4;
|
||||
}
|
||||
|
||||
message VerifyBasePackagePaymentResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 order_id = 3;
|
||||
int64 transaction_id = 4;
|
||||
google.protobuf.StringValue ref_id = 5;
|
||||
int64 wallet_balance = 6;
|
||||
int64 discount_balance = 7;
|
||||
}
|
||||
```
|
||||
|
||||
#### Files Created/Modified:
|
||||
```
|
||||
FrontOffice.BFF/src/FrontOffice.BFF.Application/PackageCQ/Commands/
|
||||
├── InitiateBasePackagePayment/
|
||||
│ ├── InitiateBasePackagePaymentCommand.cs
|
||||
│ ├── InitiateBasePackagePaymentCommandValidator.cs
|
||||
│ └── InitiateBasePackagePaymentCommandHandler.cs
|
||||
└── VerifyBasePackagePayment/
|
||||
├── VerifyBasePackagePaymentCommand.cs
|
||||
├── VerifyBasePackagePaymentCommandValidator.cs
|
||||
└── VerifyBasePackagePaymentCommandHandler.cs
|
||||
|
||||
FrontOffice.BFF/src/Protobufs/FrontOffice.BFF.Package.Protobuf/Protos/
|
||||
└── package.proto (updated)
|
||||
|
||||
FrontOffice.BFF/src/FrontOffice.BFF.WebApi/
|
||||
├── Services/PackageService.cs (updated)
|
||||
└── Common/Mappings/PackageProfile.cs (updated)
|
||||
|
||||
FrontOffice.BFF/src/FrontOffice.BFF.Domain/
|
||||
└── FrontOffice.BFF.Domain.csproj (updated - added CMS Proto reference)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Frontend Layer
|
||||
|
||||
#### Pages:
|
||||
1. **Profile/Index.razor.cs**
|
||||
- نمایش دکمه "خرید پکیج پایه"
|
||||
- Bottom Sheet با دو گزینه: پرداخت مستقیم / اعتبار الماسی
|
||||
- فراخوانی BFF.InitiateBasePackagePayment
|
||||
|
||||
```csharp
|
||||
private async Task DirectPayment()
|
||||
{
|
||||
var callbackUrl = $"{Navigation.BaseUri}profile/payment-callback";
|
||||
|
||||
var response = await PackageContract.InitiateBasePackagePaymentAsync(
|
||||
new InitiateBasePackagePaymentRequest {
|
||||
CallbackUrl = callbackUrl
|
||||
});
|
||||
|
||||
if (response.Success) {
|
||||
Navigation.NavigateTo(response.PaymentGatewayUrl, forceLoad: true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Profile/PaymentCallback.razor**
|
||||
- دریافت Query Parameters: orderId, transactionId, Authority, Status
|
||||
- فراخوانی BFF.VerifyBasePackagePayment
|
||||
- نمایش نتیجه (موفق/ناموفق)
|
||||
|
||||
```csharp
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender) {
|
||||
var response = await PackageContract.VerifyBasePackagePaymentAsync(
|
||||
new VerifyBasePackagePaymentRequest {
|
||||
OrderId = OrderId,
|
||||
TransactionId = TransactionId,
|
||||
Authority = Authority,
|
||||
Status = Status
|
||||
});
|
||||
|
||||
// نمایش نتیجه
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Files Created/Modified:
|
||||
```
|
||||
FrontOffice/src/FrontOffice.Main/Pages/Profile/
|
||||
├── Index.razor.cs (updated)
|
||||
└── PaymentCallback.razor (new)
|
||||
|
||||
FrontOffice/src/FrontOffice.Main/Utilities/
|
||||
├── UserAuthInfo.cs (updated - added UserId)
|
||||
└── AuthService.cs (updated - extract UserId from JWT)
|
||||
|
||||
FrontOffice/src/FrontOffice.Main/
|
||||
└── FrontOffice.Main.csproj (updated - added BFF Package Proto reference)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### BFF Endpoints (gRPC-Web + HTTP):
|
||||
|
||||
```
|
||||
POST /InitiateBasePackagePayment
|
||||
Body: {
|
||||
"callback_url": "https://example.com/profile/payment-callback"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"success": true,
|
||||
"message": "...",
|
||||
"order_id": 123,
|
||||
"transaction_id": 456,
|
||||
"amount": 56000000,
|
||||
"payment_gateway_url": "https://www.zarinpal.com/pg/StartPay/...",
|
||||
"authority": "A00000000000000000000000000123456"
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
POST /VerifyBasePackagePayment
|
||||
Body: {
|
||||
"order_id": 123,
|
||||
"transaction_id": 456,
|
||||
"authority": "A00000000000000000000000000123456",
|
||||
"status": "OK"
|
||||
}
|
||||
|
||||
Response: {
|
||||
"success": true,
|
||||
"message": "پرداخت با موفقیت تایید شد",
|
||||
"order_id": 123,
|
||||
"transaction_id": 456,
|
||||
"ref_id": "789",
|
||||
"wallet_balance": 56000000,
|
||||
"discount_balance": 56000000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Flow Diagram
|
||||
|
||||
### Complete Payment Flow:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as کاربر
|
||||
participant FE as Frontend
|
||||
participant BFF as BFF
|
||||
participant CMS as CMS
|
||||
participant PYMS as PYMS
|
||||
participant Bank as درگاه بانک
|
||||
|
||||
User->>FE: کلیک "پرداخت مستقیم"
|
||||
FE->>BFF: InitiateBasePackagePayment(CallbackUrl)
|
||||
BFF->>BFF: استخراج UserId از JWT
|
||||
BFF->>CMS: InitiateBasePackagePayment(UserId)
|
||||
CMS->>CMS: ثبت Transaction (Pending)
|
||||
CMS->>CMS: ثبت Order (Pending)
|
||||
CMS-->>BFF: OrderId, TransactionId, Amount
|
||||
|
||||
BFF->>PYMS: PaymentRequest(Amount, Callback)
|
||||
PYMS-->>BFF: PaymentGWUrl, Authority
|
||||
BFF-->>FE: PaymentGWUrl, OrderId, TransactionId
|
||||
|
||||
FE->>Bank: Redirect to PaymentGWUrl
|
||||
User->>Bank: پرداخت
|
||||
Bank-->>FE: Redirect to Callback?Authority=...&Status=OK
|
||||
|
||||
FE->>BFF: VerifyBasePackagePayment(OrderId, TransactionId, Authority, Status)
|
||||
BFF->>PYMS: PaymentVerification(Authority)
|
||||
PYMS-->>BFF: PaymentStatus, RefId
|
||||
|
||||
alt پرداخت موفق
|
||||
BFF->>CMS: VerifyBasePackagePayment(PaymentSuccess=true, RefId)
|
||||
CMS->>CMS: شارژ Balance (56M)
|
||||
CMS->>CMS: شارژ DiscountBalance (56M)
|
||||
CMS->>CMS: ثبت Transaction (Success)
|
||||
CMS->>CMS: ثبت WalletChangeLog
|
||||
CMS->>CMS: Update Order (Success)
|
||||
CMS->>CMS: Update User.PackagePurchaseMethod
|
||||
CMS-->>BFF: Success, WalletBalance, DiscountBalance
|
||||
BFF-->>FE: Success
|
||||
FE-->>User: نمایش پیام موفقیت + موجودی
|
||||
else پرداخت ناموفق
|
||||
BFF->>CMS: VerifyBasePackagePayment(PaymentSuccess=false)
|
||||
CMS->>CMS: Update Transaction (Reject)
|
||||
CMS->>CMS: Update Order (Reject)
|
||||
CMS-->>BFF: Failed
|
||||
BFF-->>FE: Failed
|
||||
FE-->>User: نمایش پیام خطا
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ نکات مهم
|
||||
|
||||
### Security:
|
||||
1. **UserId از JWT گرفته میشود** نه از Request - امنیت بالاتر
|
||||
2. **Validation در هر لایه** انجام میشود
|
||||
3. **Transaction Idempotency** - چک میشود که Order Pending قبلی وجود نداشته باشد
|
||||
|
||||
### Business Logic:
|
||||
1. کاربر **فقط یک بار** میتواند پکیج پایه بخرد
|
||||
2. **شارژ همزمان** Balance و DiscountBalance انجام میشود
|
||||
3. **PackagePurchaseMethod** بعد از پرداخت موفق به `DirectPurchase` تغییر میکند
|
||||
4. برای فعالسازی لینک دعوت، باید **هم پکیج خریداری شود هم باشگاه فعال شود**
|
||||
|
||||
### Error Handling:
|
||||
1. اگر CMS خطا برگرداند، به درگاه نمیرویم
|
||||
2. اگر PYMS URL ندهد، Transaction در CMS باقی میماند (Pending)
|
||||
3. اگر Callback با Status=NOK بیاید، مستقیماً Reject میشود
|
||||
4. اگر Verification ناموفق باشد، Transaction و Order به Reject تغییر میکند
|
||||
|
||||
### Project References:
|
||||
برای development، از Project Reference استفاده میشود:
|
||||
- BFF → CMS.Protobuf (Project Reference)
|
||||
- Frontend → BFF.Package.Protobuf (Project Reference)
|
||||
|
||||
برای production، باید به NuGet Package تبدیل شوند.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist پیادهسازی
|
||||
|
||||
### CMS:
|
||||
- [x] InitiateBasePackagePaymentCommand
|
||||
- [x] InitiateBasePackagePaymentCommandValidator
|
||||
- [x] InitiateBasePackagePaymentCommandHandler
|
||||
- [x] VerifyBasePackagePaymentCommand
|
||||
- [x] VerifyBasePackagePaymentCommandValidator
|
||||
- [x] VerifyBasePackagePaymentCommandHandler
|
||||
- [x] Proto messages و RPCs
|
||||
- [x] PackageService implementation
|
||||
- [x] Mapster mappings
|
||||
|
||||
### BFF:
|
||||
- [x] InitiateBasePackagePaymentCommand
|
||||
- [x] InitiateBasePackagePaymentCommandValidator
|
||||
- [x] InitiateBasePackagePaymentCommandHandler
|
||||
- [x] VerifyBasePackagePaymentCommand
|
||||
- [x] VerifyBasePackagePaymentCommandValidator
|
||||
- [x] VerifyBasePackagePaymentCommandHandler
|
||||
- [x] Proto messages و RPCs
|
||||
- [x] PackageService implementation
|
||||
- [x] Mapster mappings
|
||||
- [x] CurrentUserService integration
|
||||
|
||||
### Frontend:
|
||||
- [x] Bottom Sheet UI برای انتخاب روش پرداخت
|
||||
- [x] DirectPayment method
|
||||
- [x] PaymentCallback page
|
||||
- [x] UserAuthInfo.UserId
|
||||
- [x] AuthService extract UserId
|
||||
- [x] Navigation to payment gateway
|
||||
- [x] Display payment result
|
||||
|
||||
### Testing:
|
||||
- [ ] Test پرداخت موفق
|
||||
- [ ] Test پرداخت ناموفق
|
||||
- [ ] Test لغو پرداخت توسط کاربر
|
||||
- [ ] Test خرید مجدد (باید خطا دهد)
|
||||
- [ ] Test شارژ کیف پول
|
||||
- [ ] Test فعالسازی لینک دعوت
|
||||
|
||||
---
|
||||
|
||||
**تاریخ آخرین بهروزرسانی:** 2024-12-16
|
||||
**نگارنده:** Development Team
|
||||
@@ -0,0 +1,546 @@
|
||||
# محاسبات پلن باینری (Binary Plan Calculations)
|
||||
|
||||
## مستندات فرمولهای محاسبه کمیسیون باینری
|
||||
|
||||
این سند فرمولهای محاسباتی سیستم کمیسیون باینری را که از فایل اکسل استخراج شده، توضیح میدهد.
|
||||
|
||||
---
|
||||
|
||||
## متغیرها و تعاریف
|
||||
|
||||
### ورودیهای هفته قبل (Last Week Remainders)
|
||||
|
||||
| نام فارسی | نماد | توضیحات |
|
||||
|-----------|------|---------|
|
||||
| **باقیمانده هفته قبل چپ** | `LL` (Last Left) | باقیماندهای که از هفته قبل در پای چپ باقی مانده |
|
||||
| **باقیمانده هفته قبل راست** | `LR` (Last Right) | باقیماندهای که از هفته قبل در پای راست باقی مانده |
|
||||
|
||||
**مثال از اکسل:**
|
||||
- `LL = 200` (میلیون ریال)
|
||||
- `LR = 0`
|
||||
|
||||
---
|
||||
|
||||
### ورودیهای هفته جدید (New Week Values)
|
||||
|
||||
| نام فارسی | نماد | توضیحات |
|
||||
|-----------|------|---------|
|
||||
| **هفته جدید چپ** | `NL` (New Left) | مجموع فروش/شارژ پای چپ در هفته جاری |
|
||||
| **هفته جدید راست** | `NR` (New Right) | مجموع فروش/شارژ پای راست در هفته جاری |
|
||||
|
||||
**مثال از اکسل:**
|
||||
- `NL = 400` (میلیون ریال)
|
||||
- `NR = 500` (میلیون ریال)
|
||||
|
||||
---
|
||||
|
||||
### پارامتر سیستم (System Parameter)
|
||||
|
||||
| نام فارسی | نماد | توضیحات |
|
||||
|-----------|------|---------|
|
||||
| **ماکسیمم تعادل** | `MX` (Maximum Balance) | حداکثر مقداری که در یک هفته میتواند به عنوان تعادل (کمیسیون) محاسبه شود |
|
||||
|
||||
**مثال از اکسل:**
|
||||
- `MX = 300` (میلیون ریال)
|
||||
|
||||
**نکته مهم:** این مقدار معمولاً بر اساس سطح کاربر یا پکیج خریداری شده تعیین میشود.
|
||||
|
||||
---
|
||||
|
||||
## فرمولهای محاسباتی
|
||||
|
||||
### 1️⃣ محاسبه مجموع پا چپ (Sum Left Total)
|
||||
|
||||
```
|
||||
SLT = LL + NL
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `SLT` (Sum Left Total) = مجموع کل پای چپ
|
||||
- باقیمانده هفته قبل + فروش هفته جدید
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
SLT = 200 + 400 = 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ محاسبه مجموع پا راست (Sum Right Total)
|
||||
|
||||
```
|
||||
SRT = LR + NR
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `SRT` (Sum Right Total) = مجموع کل پای راست
|
||||
- باقیمانده هفته قبل + فروش هفته جدید
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
SRT = 0 + 500 = 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ محاسبه کمترین کل (Minimum Total)
|
||||
|
||||
```
|
||||
MinT = MIN(SLT, SRT)
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `MinT` = کوچکترین مقدار بین دو پا
|
||||
- این مقدار نشاندهنده حداکثر تعادل بالقوه است
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
MinT = MIN(600, 500) = 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ محاسبه باقیمانده هفته بعد چپ (Remainder Next Week Left)
|
||||
|
||||
```
|
||||
RNWL = SLT - MinT
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `RNWL` (Remainder Next Week Left) = باقیماندهای که به هفته بعد منتقل میشود
|
||||
- مازاد پای چپ که برای تعادل استفاده نشد
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
RNWL = 600 - 500 = 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ محاسبه باقیمانده هفته بعد راست (Remainder Next Week Right)
|
||||
|
||||
```
|
||||
RNWR = SRT - MinT
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `RNWR` (Remainder Next Week Right) = باقیماندهای که به هفته بعد منتقل میشود
|
||||
- مازاد پای راست که برای تعادل استفاده نشد
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
RNWR = 500 - 500 = 0
|
||||
```
|
||||
|
||||
**نکته:** یکی از دو باقیمانده همیشه صفر است (چون MinT کوچکترین است).
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ محاسبه فلش چپ (Flush Left)
|
||||
|
||||
```
|
||||
FL = SLT - MX - RNWL
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `FL` (Flush Left) = مقداری که از ماکسیمم هم بیشتر بود و باید دور ریخته شود
|
||||
- این مقدار نشاندهنده سرریز (overflow) است که نمیتواند به هفته بعد منتقل شود
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
FL = 600 - 300 - 100 = 200
|
||||
```
|
||||
|
||||
**معنی:** از 600 میلیون پای چپ:
|
||||
- 300 به عنوان کمیسیون استفاده شد (تا حد MX)
|
||||
- 100 به هفته بعد منتقل شد
|
||||
- **200 فلش شد (از دست رفت)** ❌
|
||||
|
||||
---
|
||||
|
||||
### 7️⃣ محاسبه فلش راست (Flush Right)
|
||||
|
||||
```
|
||||
FR = SRT - MX - RNWR
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `FR` (Flush Right) = مقداری که از پای راست دور ریخته میشود
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
FR = 500 - 300 - 0 = 200
|
||||
```
|
||||
|
||||
**معنی:** از 500 میلیون پای راست:
|
||||
- 300 به عنوان کمیسیون استفاده شد
|
||||
- 0 به هفته بعد منتقل شد
|
||||
- **200 فلش شد (از دست رفت)** ❌
|
||||
|
||||
---
|
||||
|
||||
### 8️⃣ محاسبه کل تعادل (Total Balance / Commission)
|
||||
|
||||
```
|
||||
TB = IF(MinT > MX, MX, MinT)
|
||||
```
|
||||
|
||||
یا به زبان سادهتر:
|
||||
```
|
||||
TB = MIN(MinT, MX)
|
||||
```
|
||||
|
||||
**توضیح:**
|
||||
- `TB` (Total Balance) = مقدار واقعی کمیسیونی که به کاربر تعلق میگیرد
|
||||
- نمیتواند از ماکسیمم تعادل (`MX`) بیشتر شود
|
||||
|
||||
**مثال:**
|
||||
```
|
||||
TB = MIN(500, 300) = 300
|
||||
```
|
||||
|
||||
**معنی:** هرچند تعادل واقعی 500 بود، اما به دلیل محدودیت `MX`، فقط 300 به عنوان کمیسیون پرداخت میشود.
|
||||
|
||||
---
|
||||
|
||||
## خلاصه جریان محاسبات
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ورودیها │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LL = 200 باقیمانده هفته قبل چپ │
|
||||
│ LR = 0 باقیمانده هفته قبل راست │
|
||||
│ NL = 400 هفته جدید چپ │
|
||||
│ NR = 500 هفته جدید راست │
|
||||
│ MX = 300 ماکسیمم تعادل │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ گام 1: محاسبه مجموع دو پا │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ SLT = LL + NL = 200 + 400 = 600 │
|
||||
│ SRT = LR + NR = 0 + 500 = 500 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ گام 2: محاسبه کمترین کل │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ MinT = MIN(SLT, SRT) = MIN(600, 500) = 500 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ گام 3: محاسبه کمیسیون واقعی (با اعمال Cap) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ TB = MIN(MinT, MX) = MIN(500, 300) = 300 ✅ کمیسیون │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ گام 4: محاسبه باقیمانده هفته بعد │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ RNWL = SLT - MinT = 600 - 500 = 100 → هفته بعد │
|
||||
│ RNWR = SRT - MinT = 500 - 500 = 0 → هفته بعد │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ گام 5: محاسبه فلش (از دست رفته) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ FL = SLT - MX - RNWL = 600 - 300 - 100 = 200 ❌ فلش │
|
||||
│ FR = SRT - MX - RNWR = 500 - 300 - 0 = 200 ❌ فلش │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## تحلیل نتایج
|
||||
|
||||
### 📊 خروجیهای نهایی
|
||||
|
||||
| مقدار | توضیح | وضعیت |
|
||||
|-------|-------|-------|
|
||||
| **TB = 300** | کمیسیون پرداختی این هفته | ✅ پرداخت میشود |
|
||||
| **RNWL = 100** | باقیمانده پای چپ برای هفته بعد | ⏭️ منتقل میشود |
|
||||
| **RNWR = 0** | باقیمانده پای راست برای هفته بعد | ⏭️ منتقل میشود |
|
||||
| **FL = 200** | فلش پای چپ | ❌ از دست میرود |
|
||||
| **FR = 200** | فلش پای راست | ❌ از دست میرود |
|
||||
|
||||
---
|
||||
|
||||
### 🔍 تفسیر کسبوکار
|
||||
|
||||
#### کمیسیون محاسبه شده
|
||||
```
|
||||
کمیسیون = 300 میلیون ریال
|
||||
```
|
||||
- به دلیل محدودیت `MX = 300`، از تعادل بالقوه 500، فقط 300 قابل برداشت است
|
||||
- این یک مکانیزم کنترل هزینه است
|
||||
|
||||
#### باقیمانده به هفته بعد
|
||||
```
|
||||
هفته بعد LL = 100 (از پای چپ)
|
||||
هفته بعد LR = 0 (از پای راست)
|
||||
```
|
||||
- 100 میلیون از پای چپ به هفته بعد منتقل میشود
|
||||
- این باقیمانده در محاسبات هفته آینده دوباره استفاده خواهد شد
|
||||
|
||||
#### فلش (Flush) - نکته مهم ⚠️
|
||||
```
|
||||
فلش کل = 400 میلیون ریال (200 چپ + 200 راست)
|
||||
```
|
||||
|
||||
**چرا فلش رخ میدهد؟**
|
||||
1. مجموع دو پا = 1100 میلیون (600 + 500)
|
||||
2. کمیسیون محاسبه شده = 300 میلیون
|
||||
3. باقیمانده منتقل شده = 100 میلیون
|
||||
4. فلش = 1100 - 300 - 100 = 700 میلیون ❌
|
||||
|
||||
**توضیح:**
|
||||
- فلش نشاندهنده مقداری است که به دلیل **عدم تعادل** و **محدودیت Cap** از دست میرود
|
||||
- این یک ضرر برای کاربر است که میتواند با متعادل کردن دو پا کاهش یابد
|
||||
|
||||
---
|
||||
|
||||
## پیادهسازی در C#
|
||||
|
||||
### کلاس مدل
|
||||
|
||||
```csharp
|
||||
public class BinaryPlanCalculationInput
|
||||
{
|
||||
// ورودیهای هفته قبل
|
||||
public decimal LastLeftRemainder { get; set; } // LL
|
||||
public decimal LastRightRemainder { get; set; } // LR
|
||||
|
||||
// ورودیهای هفته جاری
|
||||
public decimal NewLeftVolume { get; set; } // NL
|
||||
public decimal NewRightVolume { get; set; } // NR
|
||||
|
||||
// تنظیمات سیستم
|
||||
public decimal MaximumBalance { get; set; } // MX
|
||||
}
|
||||
|
||||
public class BinaryPlanCalculationResult
|
||||
{
|
||||
// محاسبات واسط
|
||||
public decimal SumLeftTotal { get; set; } // SLT
|
||||
public decimal SumRightTotal { get; set; } // SRT
|
||||
public decimal MinimumTotal { get; set; } // MinT
|
||||
|
||||
// باقیماندهها
|
||||
public decimal RemainderNextWeekLeft { get; set; } // RNWL
|
||||
public decimal RemainderNextWeekRight { get; set; } // RNWR
|
||||
|
||||
// فلش
|
||||
public decimal FlushLeft { get; set; } // FL
|
||||
public decimal FlushRight { get; set; } // FR
|
||||
|
||||
// نتیجه نهایی
|
||||
public decimal TotalBalance { get; set; } // TB - کمیسیون واقعی
|
||||
public decimal TotalFlush { get; set; } // مجموع فلش
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### متد محاسبه
|
||||
|
||||
```csharp
|
||||
public static BinaryPlanCalculationResult Calculate(BinaryPlanCalculationInput input)
|
||||
{
|
||||
var result = new BinaryPlanCalculationResult();
|
||||
|
||||
// گام 1: محاسبه مجموع دو پا
|
||||
result.SumLeftTotal = input.LastLeftRemainder + input.NewLeftVolume;
|
||||
result.SumRightTotal = input.LastRightRemainder + input.NewRightVolume;
|
||||
|
||||
// گام 2: محاسبه کمترین کل
|
||||
result.MinimumTotal = Math.Min(result.SumLeftTotal, result.SumRightTotal);
|
||||
|
||||
// گام 3: محاسبه کمیسیون واقعی (با اعمال Cap)
|
||||
result.TotalBalance = Math.Min(result.MinimumTotal, input.MaximumBalance);
|
||||
|
||||
// گام 4: محاسبه باقیمانده هفته بعد
|
||||
result.RemainderNextWeekLeft = result.SumLeftTotal - result.MinimumTotal;
|
||||
result.RemainderNextWeekRight = result.SumRightTotal - result.MinimumTotal;
|
||||
|
||||
// گام 5: محاسبه فلش
|
||||
result.FlushLeft = result.SumLeftTotal - input.MaximumBalance - result.RemainderNextWeekLeft;
|
||||
result.FlushRight = result.SumRightTotal - input.MaximumBalance - result.RemainderNextWeekRight;
|
||||
|
||||
// محاسبه مجموع فلش
|
||||
result.TotalFlush = result.FlushLeft + result.FlushRight;
|
||||
|
||||
// اطمینان از عدم منفی شدن فلش
|
||||
result.FlushLeft = Math.Max(0, result.FlushLeft);
|
||||
result.FlushRight = Math.Max(0, result.FlushRight);
|
||||
result.TotalFlush = Math.Max(0, result.TotalFlush);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مثال استفاده
|
||||
|
||||
```csharp
|
||||
var input = new BinaryPlanCalculationInput
|
||||
{
|
||||
LastLeftRemainder = 200_000_000, // 200 میلیون
|
||||
LastRightRemainder = 0,
|
||||
NewLeftVolume = 400_000_000, // 400 میلیون
|
||||
NewRightVolume = 500_000_000, // 500 میلیون
|
||||
MaximumBalance = 300_000_000 // 300 میلیون
|
||||
};
|
||||
|
||||
var result = Calculate(input);
|
||||
|
||||
Console.WriteLine($"کمیسیون قابل پرداخت: {result.TotalBalance:N0} ریال");
|
||||
// Output: کمیسیون قابل پرداخت: 300,000,000 ریال
|
||||
|
||||
Console.WriteLine($"باقیمانده چپ هفته بعد: {result.RemainderNextWeekLeft:N0} ریال");
|
||||
// Output: باقیمانده چپ هفته بعد: 100,000,000 ریال
|
||||
|
||||
Console.WriteLine($"باقیمانده راست هفته بعد: {result.RemainderNextWeekRight:N0} ریال");
|
||||
// Output: باقیمانده راست هفته بعد: 0 ریال
|
||||
|
||||
Console.WriteLine($"فلش کل: {result.TotalFlush:N0} ریال");
|
||||
// Output: فلش کل: 400,000,000 ریال
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم برای پیادهسازی
|
||||
|
||||
### 1️⃣ ذخیره باقیماندهها
|
||||
```csharp
|
||||
// باید در دیتابیس ذخیره شود
|
||||
await SaveWeeklyRemainders(userId, weekId, new WeeklyRemainders
|
||||
{
|
||||
LeftRemainder = result.RemainderNextWeekLeft,
|
||||
RightRemainder = result.RemainderNextWeekRight
|
||||
});
|
||||
```
|
||||
|
||||
### 2️⃣ لاگ فلش برای تحلیل
|
||||
```csharp
|
||||
if (result.TotalFlush > 0)
|
||||
{
|
||||
await LogFlush(userId, weekId, new FlushLog
|
||||
{
|
||||
FlushLeft = result.FlushLeft,
|
||||
FlushRight = result.FlushRight,
|
||||
Reason = "Cap limitation and imbalance"
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3️⃣ تعیین MaximumBalance
|
||||
```csharp
|
||||
// بر اساس سطح کاربر
|
||||
decimal GetMaximumBalance(User user)
|
||||
{
|
||||
return user.MembershipLevel switch
|
||||
{
|
||||
MembershipLevel.Bronze => 100_000_000,
|
||||
MembershipLevel.Silver => 300_000_000,
|
||||
MembershipLevel.Gold => 500_000_000,
|
||||
MembershipLevel.Platinum => 1_000_000_000,
|
||||
_ => 50_000_000
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4️⃣ واحد پول
|
||||
```csharp
|
||||
// همه مقادیر باید در واحد ریال ذخیره شوند
|
||||
// برای نمایش میتوان به میلیون یا تومان تبدیل کرد
|
||||
decimal DisplayInMillions(decimal rials) => rials / 1_000_000;
|
||||
decimal DisplayInTomans(decimal rials) => rials / 10;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## سناریوهای مختلف
|
||||
|
||||
### سناریو 1: تعادل کامل
|
||||
```
|
||||
LL = 0, LR = 0, NL = 300, NR = 300, MX = 500
|
||||
→ TB = 300, RNWL = 0, RNWR = 0, FL = 0, FR = 0
|
||||
```
|
||||
**نتیجه:** کمیسیون کامل بدون فلش ✅
|
||||
|
||||
---
|
||||
|
||||
### سناریو 2: یک پا خیلی بیشتر
|
||||
```
|
||||
LL = 0, LR = 0, NL = 1000, NR = 100, MX = 500
|
||||
→ TB = 100, RNWL = 900, RNWR = 0, FL = 400, FR = 0
|
||||
```
|
||||
**نتیجه:** کمیسیون کم + فلش زیاد ❌
|
||||
|
||||
---
|
||||
|
||||
### سناریو 3: باقیمانده قبلی موثر
|
||||
```
|
||||
LL = 400, LR = 0, NL = 100, NR = 400, MX = 300
|
||||
→ SLT = 500, SRT = 400
|
||||
→ TB = 300, RNWL = 100, RNWR = 0, FL = 100, FR = 100
|
||||
```
|
||||
**نتیجه:** باقیمانده قبلی در محاسبه کمیسیون موثر است ✅
|
||||
|
||||
---
|
||||
|
||||
## تفاوت با کد فعلی
|
||||
|
||||
### در کد فعلی (`CalculateWeeklyBalancesCommandHandler.cs`):
|
||||
|
||||
```csharp
|
||||
// 1. ابتدا Cap اعمال میشود
|
||||
var cappedLeft = Math.Min(leftLegTotal, maxBalance);
|
||||
var cappedRight = Math.Min(rightLegTotal, maxBalance);
|
||||
|
||||
// 2. سپس تعادل محاسبه میشود
|
||||
var balance = Math.Min(cappedLeft, cappedRight);
|
||||
|
||||
// 3. باقیماندهها محاسبه میشوند
|
||||
var leftRemainder = leftLegTotal - balance;
|
||||
var rightRemainder = rightLegTotal - balance;
|
||||
```
|
||||
|
||||
### در فرمول اکسل:
|
||||
```csharp
|
||||
// 1. ابتدا تعادل کامل محاسبه میشود
|
||||
var minTotal = Math.Min(leftLegTotal, rightLegTotal);
|
||||
|
||||
// 2. سپس Cap اعمال میشود
|
||||
var balance = Math.Min(minTotal, maxBalance);
|
||||
|
||||
// 3. باقیماندهها بر اساس minTotal محاسبه میشوند
|
||||
var leftRemainder = leftLegTotal - minTotal;
|
||||
var rightRemainder = rightLegTotal - minTotal;
|
||||
|
||||
// 4. فلش محاسبه میشود
|
||||
var flushLeft = leftLegTotal - maxBalance - leftRemainder;
|
||||
var flushRight = rightLegTotal - maxBalance - rightRemainder;
|
||||
```
|
||||
|
||||
**تفاوت کلیدی:**
|
||||
- کد فعلی Cap را ابتدا اعمال میکند (میتواند باقیماندههای بیشتری ایجاد کند)
|
||||
- فرمول اکسل ابتدا تعادل را محاسبه میکند، سپس Cap اعمال میشود (فلش دقیقتر محاسبه میشود)
|
||||
|
||||
---
|
||||
|
||||
## نتیجهگیری
|
||||
|
||||
این فرمولها نشان میدهند که:
|
||||
|
||||
1. ✅ **تعادل اهمیت دارد** - هرچه دو پا متعادلتر باشند، فلش کمتر است
|
||||
2. ✅ **Cap محدودیت ایجاد میکند** - حتی با تعادل کامل، بیش از MX کمیسیون داده نمیشود
|
||||
3. ✅ **باقیماندهها منتقل میشوند** - برای هفته بعد ذخیره میشوند
|
||||
4. ❌ **فلش ضرر است** - مقداری که به دلیل عدم تعادل یا Cap از دست میرود
|
||||
|
||||
**توصیه:** برای افزایش کمیسیون، کاربران باید:
|
||||
- دو پای خود را متعادل نگه دارند
|
||||
- سطح عضویت خود را ارتقا دهند (برای افزایش MX)
|
||||
- از باقیماندهها در هفتههای بعد استفاده کنند
|
||||
@@ -0,0 +1,281 @@
|
||||
# 🌳 Binary Tree Network Registration Guide
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
از این پس، هر کاربر جدید که در سیستم ثبت میشود، **همزمان** در دو ساختار قرار میگیرد:
|
||||
|
||||
1. **Old System**: `User.ParentId` (برای Backward Compatibility)
|
||||
2. **New Binary Tree System**: `User.NetworkParentId` + `User.LegPosition` (Left/Right)
|
||||
|
||||
این تغییر تضمین میکند که:
|
||||
- ✅ کاربران جدید بلافاصله در محاسبات Commission شرکت میکنند
|
||||
- ✅ نیازی به Migration اضافی نیست
|
||||
- ✅ Binary Tree Constraint رعایت میشود (حداکثر 2 فرزند)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Changes in Registration Flow
|
||||
|
||||
### قبل از تغییر:
|
||||
|
||||
```csharp
|
||||
var entity = request.Adapt<User>();
|
||||
entity.ReferralCode = UtilExtensions.Generate(digits: 10);
|
||||
await _context.Users.AddAsync(entity, cancellationToken);
|
||||
```
|
||||
|
||||
**مشکل**: فقط `ParentId` Set میشد، `NetworkParentId` و `LegPosition` خالی میماند.
|
||||
|
||||
---
|
||||
|
||||
### بعد از تغییر:
|
||||
|
||||
```csharp
|
||||
var entity = request.Adapt<User>();
|
||||
entity.ReferralCode = UtilExtensions.Generate(digits: 10);
|
||||
|
||||
// === محاسبه موقعیت در Binary Tree ===
|
||||
if (request.ParentId.HasValue)
|
||||
{
|
||||
var legPosition = await _networkPlacementService.CalculateLegPositionAsync(
|
||||
request.ParentId.Value, cancellationToken);
|
||||
|
||||
if (legPosition.HasValue)
|
||||
{
|
||||
entity.NetworkParentId = request.ParentId.Value;
|
||||
entity.LegPosition = legPosition.Value; // Left یا Right
|
||||
}
|
||||
else
|
||||
{
|
||||
// Parent پر است! Auto-Placement یا Error
|
||||
var availableParent = await _networkPlacementService.FindAvailableParentAsync(
|
||||
request.ParentId.Value, cancellationToken);
|
||||
|
||||
// ... Set کردن NetworkParentId و LegPosition با Parent جدید
|
||||
}
|
||||
}
|
||||
|
||||
await _context.Users.AddAsync(entity, cancellationToken);
|
||||
```
|
||||
|
||||
**مزایا**:
|
||||
- ✅ `NetworkParentId` و `LegPosition` به صورت خودکار محاسبه میشود
|
||||
- ✅ Binary Tree Constraint چک میشود
|
||||
- ✅ اگر Parent پر باشد، Auto-Placement انجام میشود
|
||||
|
||||
---
|
||||
|
||||
## 📐 Binary Tree Logic
|
||||
|
||||
### قوانین:
|
||||
1. هر Parent فقط **2 فرزند** میتواند داشته باشد (Left & Right)
|
||||
2. فرزند اول: `LegPosition = Left`
|
||||
3. فرزند دوم: `LegPosition = Right`
|
||||
4. اگر Parent پر باشد، سیستم به صورت BFS دنبال Parent خالی میگردد
|
||||
|
||||
### مثال:
|
||||
|
||||
```
|
||||
User1 (Root)
|
||||
/ \
|
||||
User2 (L) User3 (R)
|
||||
/ \
|
||||
User4(L) User5(R)
|
||||
```
|
||||
|
||||
- User2 → Parent=User1, Leg=Left
|
||||
- User3 → Parent=User1, Leg=Right
|
||||
- User4 → Parent=User2, Leg=Left
|
||||
- User5 → Parent=User2, Leg=Right
|
||||
|
||||
اگر کاربر جدید با `ParentId=User1` بیاید:
|
||||
- User1 پر است! (دو فرزند دارد)
|
||||
- سیستم به User2 میرود (BFS)
|
||||
- User2 هم پر است!
|
||||
- به User3 میرود → User3 خالی است
|
||||
- کاربر جدید → Parent=User3, Leg=Left
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ NetworkPlacementService API
|
||||
|
||||
### 1. CalculateLegPositionAsync
|
||||
محاسبه موقعیت (Left/Right) برای کاربر جدید زیر یک Parent مشخص.
|
||||
|
||||
```csharp
|
||||
var legPosition = await _networkPlacementService.CalculateLegPositionAsync(parentId);
|
||||
```
|
||||
|
||||
**Return Values**:
|
||||
- `NetworkLeg.Left`: اگر Parent فرزند چپ ندارد
|
||||
- `NetworkLeg.Right`: اگر Parent فرزند راست ندارد
|
||||
- `null`: اگر Parent پر است (دو فرزند دارد)
|
||||
|
||||
---
|
||||
|
||||
### 2. CanAcceptChildAsync
|
||||
بررسی اینکه آیا Parent میتواند فرزند جدید بپذیرد.
|
||||
|
||||
```csharp
|
||||
bool canAccept = await _networkPlacementService.CanAcceptChildAsync(parentId);
|
||||
```
|
||||
|
||||
**Return Values**:
|
||||
- `true`: اگر Parent کمتر از 2 فرزند دارد
|
||||
- `false`: اگر Parent پر است
|
||||
|
||||
---
|
||||
|
||||
### 3. FindAvailableParentAsync (Auto-Placement)
|
||||
پیدا کردن اولین Parent خالی در Binary Tree با استفاده از BFS.
|
||||
|
||||
```csharp
|
||||
long? availableParentId = await _networkPlacementService.FindAvailableParentAsync(rootParentId);
|
||||
```
|
||||
|
||||
**Use Case**:
|
||||
- زمانی که Parent مورد نظر پر است
|
||||
- سیستم به صورت خودکار Parent جایگزین پیدا میکند
|
||||
- از BFS استفاده میکند (Level-by-Level)
|
||||
|
||||
**Return Values**:
|
||||
- `long`: شناسه Parent مناسب
|
||||
- `null`: اگر هیچ Parent خالی پیدا نشد (تمام Binary Tree پر است!)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Error Handling
|
||||
|
||||
### Scenario 1: Parent پر است و Auto-Placement موفق
|
||||
|
||||
```csharp
|
||||
// Parent اصلی پر است
|
||||
// سیستم Parent جدید پیدا میکند
|
||||
_logger.LogWarning("Parent {ParentId} is full. Auto-placing under {NewParentId}");
|
||||
```
|
||||
|
||||
**نتیجه**: کاربر با موفقیت در جای دیگری قرار میگیرد.
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: کل Binary Tree پر است
|
||||
|
||||
```csharp
|
||||
throw new InvalidOperationException(
|
||||
$"شبکه Parent با شناسه {parentId} پر است و نمیتواند کاربر جدید بپذیرد.");
|
||||
```
|
||||
|
||||
**نتیجه**: Exception پرتاب میشود، ثبت کاربر انجام نمیشود.
|
||||
|
||||
**راه حل**:
|
||||
- افزایش سطح Binary Tree
|
||||
- یا تخصیص دستی Parent
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Parent وجود ندارد
|
||||
|
||||
```csharp
|
||||
var parentExists = await _context.Users.AnyAsync(u => u.Id == parentId);
|
||||
if (!parentExists)
|
||||
{
|
||||
return null; // Parent نامعتبر
|
||||
}
|
||||
```
|
||||
|
||||
**نتیجه**: `null` برگردانده میشود، Exception پرتاب میشود.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Logging & Monitoring
|
||||
|
||||
سیستم Log های زیر را مینویسد:
|
||||
|
||||
### Success:
|
||||
```
|
||||
User 123 placed in Binary Tree: Parent=45, Leg=Left
|
||||
```
|
||||
|
||||
### Warning (Auto-Placement):
|
||||
```
|
||||
Parent 45 has no available leg! Finding alternative parent...
|
||||
User 123 auto-placed under alternative Parent=67, Leg=Right
|
||||
```
|
||||
|
||||
### Error (Binary Tree Full):
|
||||
```
|
||||
No available parent found in network for ParentId=45
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Scenarios
|
||||
|
||||
### Test 1: کاربر اول (Root)
|
||||
```csharp
|
||||
var command = new CreateNewUserCommand { Mobile = "09121234567" }; // No ParentId
|
||||
// Result: ParentId=null, NetworkParentId=null, LegPosition=null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test 2: فرزند اول
|
||||
```csharp
|
||||
var command = new CreateNewUserCommand { Mobile = "09121234568", ParentId = 1 };
|
||||
// Result: ParentId=1, NetworkParentId=1, LegPosition=Left
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test 3: فرزند دوم
|
||||
```csharp
|
||||
var command = new CreateNewUserCommand { Mobile = "09121234569", ParentId = 1 };
|
||||
// Result: ParentId=1, NetworkParentId=1, LegPosition=Right
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test 4: فرزند سوم (Parent پر است)
|
||||
```csharp
|
||||
var command = new CreateNewUserCommand { Mobile = "09121234570", ParentId = 1 };
|
||||
// Result: Auto-Placement → ParentId=1, NetworkParentId=2 (یا 3), LegPosition=Left
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
- **Service Interface**: `CMSMicroservice.Application/Common/Interfaces/INetworkPlacementService.cs`
|
||||
- **Service Implementation**: `CMSMicroservice.Infrastructure/Services/NetworkPlacementService.cs`
|
||||
- **Handler**: `CMSMicroservice.Application/UserCQ/Commands/CreateNewUser/CreateNewUserCommandHandler.cs`
|
||||
- **DI Registration**: `CMSMicroservice.Infrastructure/ConfigureServices.cs` (خط 23)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [x] `INetworkPlacementService` اضافه شد
|
||||
- [x] `NetworkPlacementService` پیادهسازی شد
|
||||
- [x] DI Container تنظیم شد
|
||||
- [x] `CreateNewUserCommandHandler` اصلاح شد
|
||||
- [ ] Unit Tests نوشته شود
|
||||
- [ ] Integration Tests انجام شود
|
||||
- [ ] Manual Testing با Postman/gRPC Client
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Test کردن**: ثبت چند کاربر با Parent مشابه و بررسی LegPosition
|
||||
2. **Load Testing**: بررسی Performance با 10,000 کاربر
|
||||
3. **Edge Cases**: تست Binary Tree Full scenario
|
||||
4. **Documentation**: Update کردن API Docs
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
اگر مشکلی پیش آمد:
|
||||
- Log های `NetworkPlacementService` را بررسی کنید
|
||||
- چک کنید که DI به درستی تنظیم شده باشد
|
||||
- از `CanAcceptChildAsync` برای Pre-Validation استفاده کنید
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
# ✅ اصلاح محاسبه کمیسیون هفتگی - تحلیل و پیادهسازی
|
||||
|
||||
**تاریخ شروع**: ۱۴ آذر ۱۴۰۴ (2025-12-04)
|
||||
**تاریخ تکمیل**: ۱۴ آذر ۱۴۰۴
|
||||
**وضعیت**: ✅ تکمیل شد
|
||||
**اولویت**: 🔴 بحرانی - تأثیر مستقیم بر بیزینس
|
||||
|
||||
---
|
||||
|
||||
## 📊 خلاصه مشکلات
|
||||
|
||||
### مشکل ۱: محدودیت لول (Max Network Level) پیادهسازی نشده
|
||||
- **مشکل**: شمارش اعضا بدون محدودیت عمق انجام میشود
|
||||
- **انتظار**: فقط تا ۱۵ لول پایینتر باید شمارش شود
|
||||
- **راهحل**: اضافه کردن پارامتر `maxLevel` به متد بازگشتی و خواندن از Config
|
||||
|
||||
### مشکل ۲: تعادل شخص vs تعادل شبکه (بحرانی)
|
||||
- **مشکل**: کمیسیون بر اساس تعادل شخصی محاسبه میشود (نه مجموع زیرمجموعه)
|
||||
- **انتظار**: کمیسیون = (تعادل شخص + تعادل زیرمجموعه تا ۱۵ لول) × ارزش هر تعادل
|
||||
- **راهحل**: محاسبه تعادلهای زیرمجموعه در ProcessUserPayouts
|
||||
|
||||
---
|
||||
|
||||
## 🎯 قانون صحیح کمیسیون (بیزینس)
|
||||
|
||||
### فرمول محاسبه کمیسیون هفتگی:
|
||||
|
||||
```
|
||||
1️⃣ محاسبه تعادل هر شخص:
|
||||
- تعادل_شخص = MIN(چپ، راست)
|
||||
- سقف هر دست = 300
|
||||
- حداکثر تعادل شخصی = 300
|
||||
|
||||
2️⃣ محاسبه کل تعادلهای شبکه:
|
||||
- کل_تعادل_شبکه = SUM(تعادل_شخصی همه اعضا)
|
||||
|
||||
3️⃣ محاسبه صندوق:
|
||||
- صندوق_هفتگی = SUM(سهم_استخر همه اعضا)
|
||||
- سهم_استخر هر عضو = تعداد_زیرمجموعه_جدید × هزینه_فعالسازی × ۲۰%
|
||||
|
||||
4️⃣ ارزش هر تعادل:
|
||||
- ارزش_هر_تعادل = صندوق_هفتگی ÷ کل_تعادل_شبکه
|
||||
|
||||
5️⃣ کمیسیون هر شخص:
|
||||
- مجموع_تعادل = تعادل_شخص + SUM(تعادل_زیرمجموعه تا 15 لول)
|
||||
- کمیسیون = مجموع_تعادل × ارزش_هر_تعادل
|
||||
```
|
||||
|
||||
### مثال عملی:
|
||||
|
||||
```
|
||||
شبکه:
|
||||
User A
|
||||
├─ Left: User B (تعادل: 5)
|
||||
│ ├─ Left: User D (تعادل: 2)
|
||||
│ └─ Right: User E (تعادل: 1)
|
||||
└─ Right: User C (تعادل: 3)
|
||||
└─ Left: User F (تعادل: 1)
|
||||
|
||||
فرض: تعادل شخصی User A = 10
|
||||
|
||||
محاسبه مجموع تعادل User A (تا 15 لول):
|
||||
= 10 + 5 + 2 + 1 + 3 + 1 = 22 تعادل
|
||||
|
||||
اگر ارزش هر تعادل = 1,000,000 ریال:
|
||||
کمیسیون User A = 22 × 1,000,000 = 22,000,000 ریال
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 تحلیل کد فعلی
|
||||
|
||||
### فایلهای تأثیرپذیر:
|
||||
|
||||
| # | فایل | وضعیت فعلی | نیاز به تغییر |
|
||||
|---|------|------------|---------------|
|
||||
| 1 | `ApplicationDbContextInitialiser.cs` | ندارد `MaxNetworkLevel` | ✅ اضافه Config |
|
||||
| 2 | `CalculateWeeklyBalancesCommandHandler.cs` | بدون محدودیت لول | ✅ اضافه maxLevel |
|
||||
| 3 | `ProcessUserPayoutsCommandHandler.cs` | فقط تعادل شخص | ✅ جمع زیرمجموعه |
|
||||
| 4 | `NetworkWeeklyBalance.cs` | Entity | ⚪ نیاز ندارد |
|
||||
| 5 | `UserCommissionPayout.cs` | Entity | 🟡 شاید فیلد جدید |
|
||||
|
||||
### کد فعلی `ProcessUserPayoutsCommandHandler`:
|
||||
|
||||
```csharp
|
||||
// ❌ مشکل: فقط تعادل شخصی
|
||||
foreach (var balance in weeklyBalances)
|
||||
{
|
||||
var totalAmount = (long)(balance.TotalBalances * pool.ValuePerBalance);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### کد صحیح باید باشد:
|
||||
|
||||
```csharp
|
||||
// ✅ صحیح: تعادل شخصی + زیرمجموعه تا 15 لول
|
||||
foreach (var balance in weeklyBalances)
|
||||
{
|
||||
// محاسبه مجموع تعادلهای زیرمجموعه
|
||||
var subordinateBalances = await CalculateSubordinateBalances(
|
||||
balance.UserId,
|
||||
request.WeekNumber,
|
||||
maxNetworkLevel, // از Config
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalBalancesWithSubordinates = balance.TotalBalances + subordinateBalances;
|
||||
var totalAmount = (long)(totalBalancesWithSubordinates * pool.ValuePerBalance);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 تسکهای اجرایی
|
||||
|
||||
### فاز ۱: Configuration (نیم روز)
|
||||
|
||||
#### تسک ۱.۱: اضافه کردن MaxNetworkLevel به Seed Data
|
||||
```csharp
|
||||
// ApplicationDbContextInitialiser.cs
|
||||
new SystemConfiguration
|
||||
{
|
||||
Key = "Commission.MaxNetworkLevel",
|
||||
Value = "15",
|
||||
Description = "حداکثر عمق شبکه برای محاسبه کمیسیون (تعداد لول)",
|
||||
Scope = ConfigurationScope.Commission,
|
||||
IsActive = true
|
||||
}
|
||||
```
|
||||
|
||||
#### تسک ۱.۲: Migration (در صورت نیاز)
|
||||
- اگر دیتابیس موجود دارید، یک SQL Script یا Migration
|
||||
|
||||
---
|
||||
|
||||
### فاز ۲: اصلاح CalculateWeeklyBalances (نیم روز)
|
||||
|
||||
#### تسک ۲.۱: خواندن MaxNetworkLevel از Config
|
||||
```csharp
|
||||
// در Handle method
|
||||
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15"));
|
||||
```
|
||||
|
||||
#### تسک ۲.۲: اضافه کردن محدودیت لول به متد بازگشتی
|
||||
```csharp
|
||||
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;
|
||||
|
||||
// ... محاسبه count ...
|
||||
|
||||
// ⬅️ افزایش سطح
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### فاز ۳: اصلاح ProcessUserPayouts (۱ روز)
|
||||
|
||||
#### تسک ۳.۱: اضافه کردن متد محاسبه تعادل زیرمجموعه
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// محاسبه مجموع تعادلهای زیرمجموعه یک کاربر تا N لول
|
||||
/// </summary>
|
||||
private async Task<int> CalculateSubordinateBalancesAsync(
|
||||
long userId,
|
||||
string weekNumber,
|
||||
int maxLevel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var totalSubordinateBalances = 0;
|
||||
|
||||
// پیدا کردن همه زیرمجموعهها تا maxLevel
|
||||
var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel, cancellationToken);
|
||||
|
||||
// جمع تعادلهای آنها
|
||||
foreach (var subordinateId in subordinates)
|
||||
{
|
||||
var balance = await _context.NetworkWeeklyBalances
|
||||
.Where(x => x.UserId == subordinateId && x.WeekNumber == weekNumber)
|
||||
.Select(x => x.TotalBalances)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
totalSubordinateBalances += balance;
|
||||
}
|
||||
|
||||
return totalSubordinateBalances;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// پیدا کردن بازگشتی زیرمجموعهها
|
||||
/// </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;
|
||||
}
|
||||
```
|
||||
|
||||
#### تسک ۳.۲: اصلاح Handle method
|
||||
```csharp
|
||||
public async Task<int> Handle(ProcessUserPayoutsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// ... کدهای موجود ...
|
||||
|
||||
// خواندن MaxNetworkLevel از Config
|
||||
var maxNetworkLevel = await _context.SystemConfigurations
|
||||
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var maxLevel = int.Parse(maxNetworkLevel ?? "15");
|
||||
|
||||
foreach (var balance in weeklyBalances)
|
||||
{
|
||||
// ✅ محاسبه تعادل شخص + زیرمجموعه
|
||||
var subordinateBalances = await CalculateSubordinateBalancesAsync(
|
||||
balance.UserId,
|
||||
request.WeekNumber,
|
||||
maxLevel,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var totalBalancesWithSubordinates = balance.TotalBalances + subordinateBalances;
|
||||
var totalAmount = (long)(totalBalancesWithSubordinates * pool.ValuePerBalance);
|
||||
|
||||
var payout = new UserCommissionPayout
|
||||
{
|
||||
UserId = balance.UserId,
|
||||
WeekNumber = request.WeekNumber,
|
||||
WeeklyPoolId = pool.Id,
|
||||
BalancesEarned = totalBalancesWithSubordinates, // ← شامل زیرمجموعه
|
||||
ValuePerBalance = pool.ValuePerBalance,
|
||||
TotalAmount = totalAmount,
|
||||
// ...
|
||||
};
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### تسک ۳.۳ (اختیاری): اضافه کردن فیلد به Entity
|
||||
```csharp
|
||||
// UserCommissionPayout.cs
|
||||
/// <summary>
|
||||
/// تعادل شخصی (بدون زیرمجموعه)
|
||||
/// </summary>
|
||||
public int PersonalBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعادل زیرمجموعهها
|
||||
/// </summary>
|
||||
public int SubordinateBalances { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مجموع (PersonalBalances + SubordinateBalances)
|
||||
/// </summary>
|
||||
public int BalancesEarned { get; set; } // ← قبلاً هم بود
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### فاز ۴: تست و Build (نیم روز)
|
||||
|
||||
#### تسک ۴.۱: Build و رفع خطاها
|
||||
```bash
|
||||
cd CMS/src && dotnet build
|
||||
```
|
||||
|
||||
#### تسک ۴.۲: تست با سناریوهای مختلف
|
||||
- کاربر بدون زیرمجموعه
|
||||
- کاربر با ۵ لول زیرمجموعه
|
||||
- کاربر با ۲۰ لول (باید ۱۵ تا بشمارد)
|
||||
- کاربر با سقف ۳۰۰ در هر دست
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ زمانبندی
|
||||
|
||||
| فاز | تسک | زمان | مجموع |
|
||||
|-----|-----|------|-------|
|
||||
| ۱ | Config + Seed | 0.5 روز | 0.5 روز |
|
||||
| ۲ | اصلاح CalculateWeeklyBalances | 0.5 روز | 1 روز |
|
||||
| ۳ | اصلاح ProcessUserPayouts | 1 روز | 2 روز |
|
||||
| ۴ | تست و Build | 0.5 روز | 2.5 روز |
|
||||
|
||||
**مجموع**: ۲.۵ روز کاری
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ نکات مهم
|
||||
|
||||
1. **تغییرات Breaking نیست**: ساختار Entity تغییر نمیکند (فقط مقادیر)
|
||||
2. **Backward Compatible**: فیلد `BalancesEarned` قبلاً هم بود
|
||||
3. **Idempotent**: با `ForceRecalculate` میتوان دوباره حساب کرد
|
||||
4. **Performance**: متد بازگشتی ممکن است کند باشد - بهینهسازی در فاز بعد
|
||||
5. **Migration**: فقط اگر فیلد جدید به Entity اضافه شود
|
||||
|
||||
---
|
||||
|
||||
## ✅ وضعیت پیادهسازی - تکمیل شده
|
||||
|
||||
**تاریخ تکمیل**: ۱۴ آذر ۱۴۰۴
|
||||
|
||||
| فاز | شرح | وضعیت |
|
||||
|-----|-----|------|
|
||||
| 1 | Config: `Commission.MaxNetworkLevel = 15` | ✅ تکمیل |
|
||||
| 2 | CalculateWeeklyBalances: محدودیت ۱۵ لول | ✅ تکمیل |
|
||||
| 3 | ProcessUserPayouts: جمع تعادل زیرمجموعه | ✅ تکمیل |
|
||||
| 4 | Build Test: 0 Errors | ✅ تکمیل |
|
||||
|
||||
### تغییرات انجام شده:
|
||||
|
||||
**Seed Data:**
|
||||
- ✅ `Commission.MaxNetworkLevel = 15`
|
||||
|
||||
**CalculateWeeklyBalancesCommandHandler:**
|
||||
- ✅ خواندن `maxNetworkLevel` از Config
|
||||
- ✅ پارامتر `maxLevel` به `CountNewMembersInLeg`
|
||||
- ✅ پارامتر `currentLevel` و `maxLevel` به `CountNewMembersRecursive`
|
||||
- ✅ شرط توقف در عمق ۱۵
|
||||
|
||||
**ProcessUserPayoutsCommandHandler:**
|
||||
- ✅ متد جدید `SumSubordinateBalancesAsync`
|
||||
- ✅ متد کمکی `GetChildUserIdAsync`
|
||||
- ✅ محاسبه `subordinateBalances` برای هر کاربر
|
||||
- ✅ کمیسیون = (شخص + زیرمجموعه) × ارزش هر تعادل
|
||||
|
||||
---
|
||||
|
||||
## 🎉 نتیجه نهایی
|
||||
|
||||
```
|
||||
✅ Build Succeeded - 0 Errors
|
||||
✅ همه فازها تکمیل شدند
|
||||
✅ منطق کمیسیون اصلاح شد
|
||||
```
|
||||
@@ -0,0 +1,317 @@
|
||||
# اصلاحات سیستم کمیسیون هفتگی
|
||||
|
||||
## 📋 خلاصه تغییرات
|
||||
|
||||
سیستم کمیسیون هفتگی از **3 مرحله به 2 مرحله** سادهسازی شد:
|
||||
|
||||
### ❌ قبل (3 مرحله):
|
||||
1. `CalculateWeeklyBalances` - محاسبه تعادلها
|
||||
2. `CalculateWeeklyCommissionPool` - محاسبه استخر
|
||||
3. `ProcessUserPayouts` - پردازش پرداختها (تکراری!)
|
||||
|
||||
### ✅ بعد (2 مرحله):
|
||||
1. `CalculateWeeklyBalances` - محاسبه تعادلها تا 15 لول
|
||||
2. `CalculateWeeklyCommissionPool` - محاسبه استخر + پردازش پرداختها
|
||||
|
||||
---
|
||||
|
||||
## 🔧 تغییرات جزئی
|
||||
|
||||
### 1️⃣ اضافه شدن فیلدها به `NetworkWeeklyBalance`
|
||||
|
||||
**فیلدهای جدید:**
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// مقدار فلش هر طرف (بعد از اعمال Cap)
|
||||
/// </summary>
|
||||
public int FlushedPerSide { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مجموع فلش از دو طرف (از دست رفته)
|
||||
/// </summary>
|
||||
public int TotalFlushed { get; set; }
|
||||
```
|
||||
|
||||
**Migration:** `AddFlushedFieldsToNetworkWeeklyBalance`
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ اصلاح `CalculateWeeklyBalances`
|
||||
|
||||
**تغییرات:**
|
||||
- ✅ فیلدهای `FlushedPerSide` و `TotalFlushed` ذخیره میشوند
|
||||
- ✅ `WeeklyPoolContribution = 0` (دیگر در این مرحله محاسبه نمیشه)
|
||||
- ✅ محدودیت 15 لول قبلاً موجود بود و درست کار میکند
|
||||
|
||||
**کد:**
|
||||
```csharp
|
||||
// محاسبه فلش
|
||||
var flushedPerSide = totalBalances - cappedBalances;
|
||||
var totalFlushed = flushedPerSide * 2;
|
||||
|
||||
// ذخیره
|
||||
balance.FlushedPerSide = flushedPerSide;
|
||||
balance.TotalFlushed = totalFlushed;
|
||||
balance.WeeklyPoolContribution = 0; // Pool در مرحله بعد محاسبه میشه
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ اصلاح کامل `CalculateWeeklyCommissionPool`
|
||||
|
||||
**منطق جدید Pool:**
|
||||
```csharp
|
||||
// 1. Pool از فعالسازیهای باشگاه این هفته میاد (نه از تعادلها)
|
||||
var newClubMembersCount = await _context.ClubMemberships
|
||||
.Where(c => c.ActivatedAt >= startDate && c.ActivatedAt <= endDate)
|
||||
.CountAsync();
|
||||
|
||||
var totalPoolAmount = newClubMembersCount * activationFee;
|
||||
|
||||
// 2. ارزش هر امتیاز
|
||||
var totalBalancesInNetwork = weeklyBalances.Sum(x => x.TotalBalances);
|
||||
var valuePerBalance = totalPoolAmount / totalBalancesInNetwork;
|
||||
```
|
||||
|
||||
**افزوده شدن محاسبه تعادل زیرمجموعه:**
|
||||
```csharp
|
||||
// برای هر کاربر:
|
||||
// 1. تعادل خودش
|
||||
var directBalances = balance.TotalBalances;
|
||||
|
||||
// 2. تعادل زیرمجموعه (تا 15 لول)
|
||||
var subordinateBalances = await CalculateSubordinateBalancesAsync(
|
||||
balance.UserId,
|
||||
request.WeekNumber,
|
||||
maxLevels: 15
|
||||
);
|
||||
|
||||
var totalBalancesForUser = directBalances + subordinateBalances;
|
||||
```
|
||||
|
||||
**ایجاد UserCommissionPayout:**
|
||||
```csharp
|
||||
var payout = new UserCommissionPayout
|
||||
{
|
||||
UserId = balance.UserId,
|
||||
WeekNumber = request.WeekNumber,
|
||||
WeeklyPoolId = existingPool.Id,
|
||||
BalancesEarned = totalBalancesForUser,
|
||||
ValuePerBalance = valuePerBalance,
|
||||
TotalAmount = totalBalancesForUser * valuePerBalance,
|
||||
Status = CommissionPayoutStatus.Pending,
|
||||
// ... subordinate fields
|
||||
};
|
||||
```
|
||||
|
||||
**ثبت تاریخچه:**
|
||||
```csharp
|
||||
var history = new CommissionPayoutHistory
|
||||
{
|
||||
UserId = payout.UserId,
|
||||
PayoutId = payout.Id,
|
||||
Amount = payout.TotalAmount,
|
||||
Status = CommissionPayoutStatus.Pending,
|
||||
ChangeReason = "محاسبه اولیه کمیسیون هفتگی"
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ سادهسازی `TriggerWeeklyCalculation`
|
||||
|
||||
**قبل:**
|
||||
```csharp
|
||||
// Step 1
|
||||
await _mediator.Send(new CalculateWeeklyBalancesCommand { ... });
|
||||
|
||||
// Step 2
|
||||
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand { ... });
|
||||
|
||||
// Step 3
|
||||
await _mediator.Send(new ProcessUserPayoutsCommand { ... });
|
||||
```
|
||||
|
||||
**بعد:**
|
||||
```csharp
|
||||
// Step 1: محاسبه تعادلها
|
||||
if (!request.SkipBalances)
|
||||
{
|
||||
await _mediator.Send(new CalculateWeeklyBalancesCommand { ... });
|
||||
}
|
||||
|
||||
// Step 2: محاسبه Pool و پرداختها
|
||||
if (!request.SkipPayouts)
|
||||
{
|
||||
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand { ... });
|
||||
}
|
||||
```
|
||||
|
||||
**حذف شد:**
|
||||
- ❌ `SkipPool` flag
|
||||
- ❌ Step 3 کاملاً حذف شد
|
||||
|
||||
---
|
||||
|
||||
## 🎯 فرآیند نهایی
|
||||
|
||||
### مرحله 1: محاسبه تعادلها
|
||||
```
|
||||
1. برای هر کاربر در شبکه
|
||||
2. تا 15 لول پایینتر شمارش کن
|
||||
3. محاسبه تعادل (MIN of left/right)
|
||||
4. محاسبه باقیمانده
|
||||
5. محاسبه فلش
|
||||
6. ذخیره در NetworkWeeklyBalance
|
||||
```
|
||||
|
||||
### مرحله 2: محاسبه Pool و توزیع
|
||||
```
|
||||
1. شمارش فعالسازیهای باشگاه این هفته
|
||||
2. Pool = تعداد × ActivationFee
|
||||
3. ارزش هر امتیاز = Pool ÷ مجموع تعادلها
|
||||
4. برای هر کاربر:
|
||||
a. تعادل خودش + تعادل زیرمجموعه (تا 15 لول)
|
||||
b. سهم = تعادل × ارزش
|
||||
c. ثبت در UserCommissionPayout
|
||||
d. ثبت تاریخچه
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 جداول درگیر
|
||||
|
||||
### `NetworkWeeklyBalance` (فیلدهای جدید)
|
||||
```sql
|
||||
ALTER TABLE [Network].[NetworkWeeklyBalances]
|
||||
ADD [FlushedPerSide] INT NOT NULL DEFAULT 0,
|
||||
[TotalFlushed] INT NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### `WeeklyCommissionPool`
|
||||
```
|
||||
- TotalPoolAmount: از فعالسازیهای باشگاه
|
||||
- TotalBalances: مجموع تعادلهای شبکه
|
||||
- ValuePerBalance: Pool ÷ TotalBalances
|
||||
```
|
||||
|
||||
### `UserCommissionPayout`
|
||||
```
|
||||
- BalancesEarned: تعادل خودش + زیرمجموعه
|
||||
- DirectBalances: فقط تعادل خودش
|
||||
- SubordinateBalances: فقط زیرمجموعه
|
||||
- TotalAmount: BalancesEarned × ValuePerBalance
|
||||
- Status: Pending
|
||||
```
|
||||
|
||||
### `CommissionPayoutHistory`
|
||||
```
|
||||
- PayoutId: شناسه UserCommissionPayout
|
||||
- Status: Pending (در این مرحله)
|
||||
- ChangeReason: "محاسبه اولیه کمیسیون هفتگی"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ مزایا
|
||||
|
||||
1. **سادهتر**: 2 مرحله به جای 3
|
||||
2. **بدون تکرار**: دیگر UserCommissionPayout دوبار ساخته نمیشه
|
||||
3. **واضحتر**: Pool از کجا میاد مشخصه
|
||||
4. **قابل نگهداری**: منطق مشابه یکجا هست
|
||||
5. **کامل**: تاریخچه + subordinate balances همه جا هست
|
||||
|
||||
---
|
||||
|
||||
## 🔄 مراحل بعدی (اختیاری)
|
||||
|
||||
### مرحله 3: پرداخت واقعی (جدا از محاسبه)
|
||||
|
||||
میتوان یک Command جدید داشت که:
|
||||
1. `UserCommissionPayout` با status=Pending رو بخونه
|
||||
2. به کیف پول واریز کنه
|
||||
3. Status رو به Paid تغییر بده
|
||||
4. تاریخچه اضافه کنه
|
||||
|
||||
این مرحله **جدا از محاسبات** است و میتواند:
|
||||
- دستی توسط ادمین اجرا شود
|
||||
- یا به صورت خودکار بعد از تایید
|
||||
|
||||
---
|
||||
|
||||
## 📝 نکات مهم
|
||||
|
||||
### Pool چطور پُر میشه؟
|
||||
```
|
||||
1. کاربر عضو Club میشه
|
||||
2. در ActivateClubMembership مبلغی کسر میشه
|
||||
3. این مبلغ به Pool اضافه **نمیشه** (فقط شمارش میشه)
|
||||
4. در محاسبه Pool: تعداد × ActivationFee
|
||||
```
|
||||
|
||||
### چرا subordinate balances؟
|
||||
```
|
||||
در سیستم باینری، کاربر از تعادل زیرمجموعههای خود
|
||||
(تا 15 لول پایینتر) هم کمیسیون میگیرد.
|
||||
```
|
||||
|
||||
### چرا 15 لول؟
|
||||
```
|
||||
محدودیت عمق برای جلوگیری از بارگذاری بیش از حد
|
||||
و تشویق به ایجاد شبکه متعادل
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 تست
|
||||
|
||||
### تست مرحله 1
|
||||
```csharp
|
||||
// 1. ایجاد کاربران در شبکه
|
||||
// 2. فعالسازی Club برای برخی
|
||||
// 3. اجرای CalculateWeeklyBalances
|
||||
// 4. بررسی NetworkWeeklyBalance
|
||||
// - TotalBalances
|
||||
// - FlushedPerSide
|
||||
// - TotalFlushed
|
||||
```
|
||||
|
||||
### تست مرحله 2
|
||||
```csharp
|
||||
// 1. اجرای مرحله 1
|
||||
// 2. اجرای CalculateWeeklyCommissionPool
|
||||
// 3. بررسی WeeklyCommissionPool
|
||||
// - TotalPoolAmount = تعداد فعالسازیها × ActivationFee
|
||||
// - ValuePerBalance صحیح باشد
|
||||
// 4. بررسی UserCommissionPayout
|
||||
// - برای هر کاربر ایجاد شده
|
||||
// - BalancesEarned شامل subordinate هم هست
|
||||
// - TotalAmount = BalancesEarned × ValuePerBalance
|
||||
// 5. بررسی CommissionPayoutHistory
|
||||
// - برای هر پرداخت ثبت شده
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 فایلهای تغییر یافته
|
||||
|
||||
1. ✅ `NetworkWeeklyBalance.cs` - اضافه شدن فیلدها
|
||||
2. ✅ `CalculateWeeklyBalancesCommandHandler.cs` - ذخیره فلش
|
||||
3. ✅ `CalculateWeeklyCommissionPoolCommandHandler.cs` - منطق کامل جدید
|
||||
4. ✅ `TriggerWeeklyCalculationCommandHandler.cs` - حذف مرحله 3
|
||||
5. ✅ `TriggerWeeklyCalculationCommand.cs` - حذف SkipPool flag
|
||||
6. ✅ Migration: `AddFlushedFieldsToNetworkWeeklyBalance`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 نتیجه
|
||||
|
||||
سیستم کمیسیون هفتگی حالا:
|
||||
- ✅ **سادهتر** و قابل فهمتر
|
||||
- ✅ **بدون تکرار** در کد
|
||||
- ✅ **Pool از منبع صحیح** (فعالسازیهای Club)
|
||||
- ✅ **تعادل زیرمجموعه** محاسبه میشه
|
||||
- ✅ **تاریخچه کامل** ثبت میشه
|
||||
- ✅ **فلش دقیق** ذخیره میشه
|
||||
|
||||
آماده برای استفاده در Production! 🚀
|
||||
@@ -0,0 +1,689 @@
|
||||
# Daya Loan Integration System (سیستم یکپارچهسازی وام دایا)
|
||||
|
||||
## 📌 Overview
|
||||
|
||||
سیستم یکپارچهسازی با سرویس وام دایا برای شارژ خودکار کیف پول کاربران که وام دایا دریافت کردهاند.
|
||||
|
||||
**مقادیر شارژ:**
|
||||
- **کیف پول اصلی (Balance)**: 56,000,000 تومان
|
||||
- **کیف پول شبکه/کارمزد (NetworkBalance)**: 56,000,000 تومان
|
||||
- **کیف پول تخفیف (DiscountBalance)**: 56,000,000 تومان
|
||||
- **مجموع**: 168,000,000 تومان
|
||||
|
||||
**نکته مهم:** کیف پول باشگاه (ClubWallet) باید توسط کاربر در فرانتآفیس به صورت دستی شارژ شود.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Architecture
|
||||
|
||||
### Domain Layer
|
||||
|
||||
#### **DayaLoanStatus Enum**
|
||||
```csharp
|
||||
public enum DayaLoanStatus
|
||||
{
|
||||
NotRequested = 0, // درخواست نشده
|
||||
PendingReceive = 1, // در انتظار دریافت وام (فعال شده)
|
||||
Received = 2, // وام دریافت شده
|
||||
Rejected = 3, // رد شده
|
||||
UnderReview = 4 // در حال بررسی
|
||||
}
|
||||
```
|
||||
|
||||
#### **DayaLoanContract Entity**
|
||||
```csharp
|
||||
public class DayaLoanContract : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public string NationalCode { get; set; }
|
||||
public string? ContractNumber { get; set; }
|
||||
public DayaLoanStatus Status { get; set; }
|
||||
public bool IsProcessed { get; set; }
|
||||
public DateTime? LastCheckDate { get; set; }
|
||||
public DateTime? ProcessedDate { get; set; }
|
||||
public long? TransactionId { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual User User { get; set; }
|
||||
public virtual Transactions? Transaction { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### **User Entity Extensions**
|
||||
```csharp
|
||||
public class User : BaseAuditableEntity
|
||||
{
|
||||
// ... existing properties ...
|
||||
|
||||
public bool HasReceivedDayaCredit { get; set; }
|
||||
public DateTime? DayaCreditReceivedAt { get; set; }
|
||||
public virtual ICollection<DayaLoanContract>? DayaLoanContracts { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Application Layer
|
||||
|
||||
#### **Commands**
|
||||
|
||||
##### 1. ProcessDayaLoanApprovalCommand
|
||||
شارژ کیف پول کاربر بعد از تایید وام دایا
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record ProcessDayaLoanApprovalCommand : IRequest<ProcessDayaLoanApprovalResponseDto>
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
public string ContractNumber { get; init; }
|
||||
public long WalletAmount { get; init; } = 56_000_000;
|
||||
public long LockedWalletAmount { get; init; } = 56_000_000;
|
||||
public long DiscountWalletAmount { get; init; } = 56_000_000;
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```csharp
|
||||
public class ProcessDayaLoanApprovalResponseDto
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public long TransactionId { get; set; }
|
||||
public string ContractNumber { get; set; }
|
||||
public long MainWalletBalance { get; set; }
|
||||
public long LockedWalletBalance { get; set; }
|
||||
public long DiscountWalletBalance { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Business Logic:**
|
||||
1. بررسی اینکه کاربر قبلاً اعتبار دایا را دریافت نکرده باشد
|
||||
2. ایجاد Transaction با:
|
||||
- Type: DepositExternal1
|
||||
- Amount: 168M تومان
|
||||
- RefId: شماره قرارداد دایا
|
||||
3. شارژ سه نوع کیف پول (Balance, NetworkBalance, DiscountBalance)
|
||||
4. ثبت UserWalletChangeLog برای Balance و NetworkBalance (⚠️ DiscountBalance لاگ ندارد)
|
||||
5. بهروزرسانی فلگهای کاربر (HasReceivedDayaCredit, DayaCreditReceivedAt)
|
||||
6. انتشار DayaLoanApprovedEvent
|
||||
|
||||
##### 2. CheckDayaLoanStatusCommand
|
||||
استعلام وضعیت وام از سرویس دایا
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record CheckDayaLoanStatusCommand : IRequest<CheckDayaLoanStatusResponseDto>
|
||||
{
|
||||
public List<string> NationalCodes { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```csharp
|
||||
public class CheckDayaLoanStatusResponseDto
|
||||
{
|
||||
public List<DayaLoanCheckResult> Results { get; set; }
|
||||
public int TotalChecked { get; set; }
|
||||
public int SuccessCount { get; set; }
|
||||
}
|
||||
|
||||
public class DayaLoanCheckResult
|
||||
{
|
||||
public string NationalCode { get; set; }
|
||||
public DayaLoanStatus Status { get; set; }
|
||||
public string? ContractNumber { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Current Status:** این Command کاملاً پیادهسازی شده و به API واقعی Daya متصل است.
|
||||
|
||||
#### **API Integration Details:**
|
||||
- **Endpoint**: `POST /api/merchant/contracts`
|
||||
- **Base URL**: `https://testdaya.tadbirandishan.com`
|
||||
- **Authentication**: `merchant-permission-key` header
|
||||
- **Request Body**:
|
||||
```json
|
||||
{
|
||||
"nationalCodes": ["1234567890", "0987654321"]
|
||||
}
|
||||
```
|
||||
- **Response Structure**:
|
||||
```json
|
||||
{
|
||||
"succeed": true,
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"data": [
|
||||
{
|
||||
"nationalCode": "1234567890",
|
||||
"contractNumber": "DAYA-12345",
|
||||
"statusDescription": "فعال شده (در انتظار تسویه)",
|
||||
"dateTime": "2024-12-06T10:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- **Status Mapping**:
|
||||
- "فعال شده (در انتظار تسویه)" → PendingReceive
|
||||
- "تایید شده" → Received
|
||||
- "رد شده" → Rejected
|
||||
- Default → UnderReview
|
||||
- **Cache Duration**: 20 minutes (per Daya API spec)
|
||||
- **Multiple Contracts**: If user has multiple contracts, system takes the latest one by DateTime
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
#### **IDayaLoanApiService Implementations**
|
||||
|
||||
**1. MockDayaLoanApiService** (Testing):
|
||||
- Returns mock data based on NationalCode patterns
|
||||
- Instant response for fast testing
|
||||
- No external dependencies
|
||||
|
||||
**2. DayaLoanApiService** (Production):
|
||||
- ✅ Fully implemented with HttpClient
|
||||
- Posts to `/api/merchant/contracts` endpoint
|
||||
- Handles API errors gracefully
|
||||
- Maps Persian status descriptions to enum values
|
||||
- Returns empty results on error (prevents worker crashes)
|
||||
|
||||
**Configuration** (`appsettings.json`):
|
||||
```json
|
||||
{
|
||||
"DayaApi": {
|
||||
"UseMock": false,
|
||||
"BaseAddress": "https://testdaya.tadbirandishan.com",
|
||||
"MerchantPermissionKey": "14752708$Db5Wk5h...",
|
||||
"CacheDurationMinutes": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Service Registration** (`ConfigureServices.cs`):
|
||||
- Reads `DayaApi:UseMock` from configuration
|
||||
- If `true`: Uses MockDayaLoanApiService
|
||||
- If `false`: Uses DayaLoanApiService with HttpClient
|
||||
- HttpClient configured with BaseAddress, headers, and 30s timeout
|
||||
|
||||
#### **Background Worker: DayaLoanCheckWorker**
|
||||
Worker خودکار که هر 15 دقیقه کاربران با وام pending را چک میکند.
|
||||
|
||||
**Location:** `CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs`
|
||||
|
||||
**Schedule:** `*/15 * * * *` (هر 15 دقیقه)
|
||||
|
||||
**Logic:**
|
||||
1. Query کاربرانی که `HasReceivedDayaCredit == false` و دارای `NationalCode` هستند
|
||||
2. فراخوانی `CheckDayaLoanStatusCommand` با لیست کدملیها
|
||||
3. برای هر نتیجه با Status=PendingReceive و ContractNumber موجود:
|
||||
- فراخوانی `ProcessDayaLoanApprovalCommand`
|
||||
- لاگ نتیجه عملیات
|
||||
4. Retry خودکار در صورت خطا (Hangfire AutomaticRetry)
|
||||
|
||||
**Registration:** در `Program.cs` ثبت شده است:
|
||||
```csharp
|
||||
DayaLoanCheckWorker.Schedule(recurringJobManager);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Process Flow
|
||||
|
||||
```
|
||||
1. کاربر درخواست وام دایا میدهد (خارج از سیستم)
|
||||
↓
|
||||
2. Worker هر 15 دقیقه کاربران pending را چک میکند
|
||||
↓
|
||||
3. CheckDayaLoanStatusCommand → فراخوانی API دایا
|
||||
↓
|
||||
4. اگر Status = PendingReceive و ContractNumber موجود بود:
|
||||
↓
|
||||
5. ProcessDayaLoanApprovalCommand اجرا میشود:
|
||||
- ایجاد Transaction (168M تومان)
|
||||
- شارژ Balance (+56M)
|
||||
- شارژ NetworkBalance (+56M)
|
||||
- شارژ DiscountBalance (+56M)
|
||||
- ثبت WalletChangeLog (برای Balance و NetworkBalance)
|
||||
- تنظیم HasReceivedDayaCredit = true
|
||||
↓
|
||||
6. DayaLoanApprovedEvent منتشر میشود
|
||||
↓
|
||||
7. EventHandler میتواند عملیات جانبی انجام دهد (مثل ارسال اطلاعرسانی)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Database Schema
|
||||
|
||||
### DayaLoanContracts Table
|
||||
```sql
|
||||
CREATE TABLE [CMS].[DayaLoanContracts] (
|
||||
[Id] bigint IDENTITY(1,1) PRIMARY KEY,
|
||||
[UserId] bigint NOT NULL FOREIGN KEY REFERENCES Users(Id),
|
||||
[NationalCode] nvarchar(max) NOT NULL,
|
||||
[ContractNumber] nvarchar(max) NULL,
|
||||
[Status] int NOT NULL,
|
||||
[IsProcessed] bit NOT NULL,
|
||||
[LastCheckDate] datetime2 NULL,
|
||||
[ProcessedDate] datetime2 NULL,
|
||||
[TransactionId] bigint NULL FOREIGN KEY REFERENCES Transactionss(Id),
|
||||
[Created] datetime2 NOT NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[LastModified] datetime2 NULL,
|
||||
[LastModifiedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### User Table Extensions
|
||||
```sql
|
||||
ALTER TABLE [CMS].[Users]
|
||||
ADD [HasReceivedDayaCredit] bit NOT NULL DEFAULT 0,
|
||||
[DayaCreditReceivedAt] datetime2 NULL;
|
||||
```
|
||||
|
||||
**Migration:** `20251201191716_AddDayaLoanIntegration.cs`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
### ⚠️ CRITICAL: Don't Remove Business Logic on Errors!
|
||||
- **وقتی با خطا مواجه شدیم، NEVER پاک نکنید بخشی از بیزینس را**
|
||||
- **اول 5 بار تلاش کنید که خطا را برطرف کنید**
|
||||
- اگر خطا برطرف نشد، آن را به حال خود رها کنید (Comment + TODO)
|
||||
- Developer دستی خطا را بررسی و حل خواهد کرد
|
||||
|
||||
**مثال درست:**
|
||||
```csharp
|
||||
// TODO: این قسمت خطا دارد - نیاز به بررسی
|
||||
// Error: CS1234 - Type not found
|
||||
// var discountLog = new UserWalletChangeLog { ... };
|
||||
// await _context.UserWalletChangeLogs.AddAsync(discountLog);
|
||||
```
|
||||
|
||||
**مثال غلط (ممنوع!):**
|
||||
```csharp
|
||||
// ❌ پاک کردن لاگ DiscountBalance برای حل خطا - WRONG!
|
||||
// این کار باعث از دست رفتن بخشی از بیزینس میشود
|
||||
```
|
||||
|
||||
### 1. UserWalletChangeLog Limitation
|
||||
- فیلدهای موجود: `CurrentBalance`, `ChangeValue`, `CurrentNetworkBalance`, `ChangeNerworkValue`
|
||||
- **مشکل:** فیلدی برای `DiscountBalance` وجود ندارد
|
||||
- **راهحل فعلی:** تغییرات DiscountBalance در لاگ ثبت نمیشود، فقط در جدول UserWallets ذخیره میشود
|
||||
- **پیشنهاد آینده:** اضافه کردن فیلدهای `CurrentDiscountBalance` و `ChangeDiscountValue` به UserWalletChangeLog
|
||||
|
||||
### 2. Daya API Integration
|
||||
- **وضعیت فعلی:** CheckDayaLoanStatusCommandHandler یک skeleton است
|
||||
- **TODO:** پیادهسازی API واقعی دایا در Handler
|
||||
- **Placeholder Code:**
|
||||
```csharp
|
||||
// TODO: فراخوانی سرویس دایا
|
||||
// در حال حاضر داده Mock برمیگردانیم
|
||||
```
|
||||
|
||||
### 3. Transaction Type
|
||||
- از `TransactionType.DepositExternal1` استفاده میشود
|
||||
- `RefId` = شماره قرارداد دایا
|
||||
- این اطلاعات برای پیگیری و تطبیق با دایا ضروری است
|
||||
|
||||
### 4. One-Time Credit
|
||||
- هر کاربر فقط **یک بار** میتواند اعتبار دایا دریافت کند
|
||||
- بررسی توسط `HasReceivedDayaCredit` flag
|
||||
- تلاش برای دریافت مجدد با خطا مواجه میشود
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Testing via Hangfire Dashboard
|
||||
1. به Hangfire Dashboard بروید: `/hangfire`
|
||||
2. در بخش "Recurring Jobs" job با نام `daya-loan-check` را پیدا کنید
|
||||
3. دکمه "Trigger now" را بزنید
|
||||
4. در بخش "Jobs" میتوانید لاگها را ببینید
|
||||
|
||||
### Testing Commands via gRPC (آینده)
|
||||
```bash
|
||||
# فراخوانی ProcessDayaLoanApproval
|
||||
grpcurl -d '{
|
||||
"userId": 123,
|
||||
"contractNumber": "DAYA-12345"
|
||||
}' localhost:5001 ProcessDayaLoanApproval
|
||||
|
||||
# فراخوانی CheckDayaLoanStatus
|
||||
grpcurl -d '{
|
||||
"nationalCodes": ["1234567890"]
|
||||
}' localhost:5001 CheckDayaLoanStatus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed Implementation
|
||||
|
||||
### High Priority (All Done)
|
||||
- ✅ پیادهسازی API واقعی دایا در DayaLoanApiService (December 6, 2025)
|
||||
- HTTP POST to `/api/merchant/contracts`
|
||||
- Request/Response models with JSON serialization
|
||||
- Status description mapping (Persian → Enum)
|
||||
- Error handling and logging
|
||||
- Configurable via appsettings.json
|
||||
- ✅ Conditional service registration (Mock vs Real)
|
||||
- ✅ HttpClient configuration with authentication
|
||||
- ✅ Worker fully operational with real API
|
||||
|
||||
### Low Priority (Optional)
|
||||
- [ ] اضافه کردن Proto definitions برای Daya commands
|
||||
- [ ] Admin UI for Daya contract management
|
||||
- [ ] Unit tests for API service
|
||||
- [ ] اضافه کردن gRPC service endpoints
|
||||
- [ ] تست Worker در محیط development
|
||||
|
||||
### Medium Priority
|
||||
- [ ] ایجاد BFF handlers برای عملیات دایا
|
||||
- [ ] ایجاد صفحات BackOffice برای مدیریت وام دایا
|
||||
- [ ] اضافه کردن فیلتر برای مشاهده کاربران با وام دایا
|
||||
- [ ] نمایش تاریخچه Daya Loan Contracts
|
||||
|
||||
### Low Priority
|
||||
- [ ] اضافه کردن Unit Tests برای ProcessDayaLoanApprovalCommand
|
||||
- [ ] اضافه کردن Integration Tests برای DayaLoanCheckWorker
|
||||
- [ ] اضافه کردن Monitoring/Alerting برای خطاهای API دایا
|
||||
- [ ] بهینهسازی Query برای یافتن کاربران pending
|
||||
- [ ] اضافه کردن فیلدهای DiscountBalance به UserWalletChangeLog
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Files
|
||||
|
||||
### Domain
|
||||
- `CMSMicroservice.Domain/Enums/DayaLoanStatus.cs`
|
||||
- `CMSMicroservice.Domain/Entities/DayaLoanContract.cs`
|
||||
- `CMSMicroservice.Domain/Entities/User.cs` (updated)
|
||||
- `CMSMicroservice.Domain/Events/DayaLoanApprovedEvent.cs`
|
||||
|
||||
### Application
|
||||
- `CMSMicroservice.Application/DayaLoanCQ/Commands/ProcessDayaLoanApproval/`
|
||||
- ProcessDayaLoanApprovalCommand.cs
|
||||
- ProcessDayaLoanApprovalCommandHandler.cs
|
||||
- ProcessDayaLoanApprovalCommandValidator.cs
|
||||
- ProcessDayaLoanApprovalResponseDto.cs
|
||||
- `CMSMicroservice.Application/DayaLoanCQ/Commands/CheckDayaLoanStatus/`
|
||||
- CheckDayaLoanStatusCommand.cs
|
||||
- CheckDayaLoanStatusCommandHandler.cs
|
||||
- CheckDayaLoanStatusResponseDto.cs
|
||||
- `CMSMicroservice.Application/DayaLoanCQ/EventHandlers/`
|
||||
- DayaLoanApprovedEventHandler.cs
|
||||
|
||||
### Infrastructure
|
||||
- `CMSMicroservice.Infrastructure/Persistence/ApplicationDbContext.cs` (updated)
|
||||
- `CMSMicroservice.Infrastructure/Persistence/Migrations/20251201191716_AddDayaLoanIntegration.cs`
|
||||
|
||||
### WebApi
|
||||
- `CMSMicroservice.WebApi/Workers/DayaLoanCheckWorker.cs`
|
||||
- `CMSMicroservice.WebApi/Program.cs` (updated)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
#### 1. ایجاد کاربر تست با کدملی شروع شده با "1"
|
||||
```sql
|
||||
-- کاربری که Mock Service برایش وام تایید میکند
|
||||
INSERT INTO CMS.Users (NationalCode, FirstName, LastName, Mobile, HasReceivedDayaCredit)
|
||||
VALUES ('1234567890', 'Test', 'User', '09121234567', 0);
|
||||
```
|
||||
|
||||
#### 2. اجرای دستی Worker از Hangfire Dashboard
|
||||
- باز کردن: `https://localhost:5001/hangfire`
|
||||
- انتخاب Job: `daya-loan-check`
|
||||
- کلیک روی "Trigger now"
|
||||
|
||||
#### 3. بررسی Logs
|
||||
```bash
|
||||
# در Console پروژه CMS
|
||||
[INFO] DayaLoanCheckWorker started at 2024-12-02 10:30:00
|
||||
[INFO] Found 1 users with pending Daya loan status
|
||||
[WARN] ⚠️ Using MOCK Daya API Service - Replace with real implementation!
|
||||
[INFO] Mock Daya API returned 1 results
|
||||
[INFO] Daya loan processed for user 123. Contract: MOCK-DAYA-1234567890-638123456789
|
||||
[INFO] DayaLoanCheckWorker completed. Checked: 1, Processed: 1
|
||||
```
|
||||
|
||||
#### 4. بررسی Database
|
||||
```sql
|
||||
-- چک کردن DayaLoanContract
|
||||
SELECT * FROM CMS.DayaLoanContracts WHERE NationalCode = '1234567890';
|
||||
|
||||
-- چک کردن UserWallet
|
||||
SELECT * FROM CMS.UserWallets WHERE UserId = 123;
|
||||
-- Balance باید 56,000,000 باشد
|
||||
-- NetworkBalance باید 56,000,000 باشد
|
||||
-- DiscountBalance باید 56,000,000 باشد
|
||||
|
||||
-- چک کردن Transaction
|
||||
SELECT * FROM CMS.Transactionss WHERE RefId LIKE 'MOCK-DAYA-%';
|
||||
-- Amount باید 168,000,000 باشد
|
||||
|
||||
-- چک کردن User Flag
|
||||
SELECT HasReceivedDayaCredit, DayaCreditReceivedAt FROM CMS.Users WHERE Id = 123;
|
||||
-- HasReceivedDayaCredit باید 1 باشد
|
||||
```
|
||||
|
||||
#### 5. تست Mock Service Scenarios
|
||||
```csharp
|
||||
// کدملی شروع با "1" → PendingReceive + ContractNumber
|
||||
// کدملی شروع با "2" → Rejected
|
||||
// سایر کدملیها → PendingReceive (بدون ContractNumber)
|
||||
```
|
||||
|
||||
### Integration Testing با Real API
|
||||
|
||||
زمانی که API واقعی دایا آماده شد:
|
||||
|
||||
1. **تغییر ConfigureServices:**
|
||||
```csharp
|
||||
// در CMSMicroservice.Infrastructure/ConfigureServices.cs
|
||||
services.AddScoped<IDayaLoanApiService, DayaLoanApiService>(); // Real
|
||||
// services.AddScoped<IDayaLoanApiService, MockDayaLoanApiService>(); // Mock - حذف شود
|
||||
```
|
||||
|
||||
2. **تنظیم HttpClient:**
|
||||
```csharp
|
||||
services.AddHttpClient<IDayaLoanApiService, DayaLoanApiService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(configuration["DayaApi:BaseUrl"]);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
```
|
||||
|
||||
3. **اضافه کردن به appsettings.json:**
|
||||
```json
|
||||
{
|
||||
"DayaApi": {
|
||||
"BaseUrl": "https://api.daya.ir",
|
||||
"ApiKey": "YOUR_API_KEY_HERE"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### مشکل: Worker اجرا نمیشود
|
||||
|
||||
**علت احتمالی:** Hangfire Server شروع نشده
|
||||
|
||||
**راه حل:**
|
||||
```csharp
|
||||
// در Program.cs چک کنید که این خط وجود دارد:
|
||||
builder.Services.AddHangfireServer();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مشکل: کاربران پیدا نمیشوند
|
||||
|
||||
**علت احتمالی:** همه کاربران قبلاً اعتبار دریافت کردهاند
|
||||
|
||||
**راه حل:**
|
||||
```sql
|
||||
-- Reset کردن وضعیت کاربران برای تست
|
||||
UPDATE CMS.Users SET HasReceivedDayaCredit = 0, DayaCreditReceivedAt = NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مشکل: کیف پول شارژ نمیشود
|
||||
|
||||
**علت احتمالی:** کاربر کیف پول ندارد
|
||||
|
||||
**راه حل:**
|
||||
```csharp
|
||||
// کد Handler خودکار UserWallet میسازد اگر موجود نباشد:
|
||||
if (wallet == null)
|
||||
{
|
||||
wallet = new UserWallet { UserId = request.UserId, Balance = 0, ... };
|
||||
await _context.UserWallets.AddAsync(wallet, cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مشکل: Mock API همیشه نتیجه یکسان برمیگرداند
|
||||
|
||||
**راه حل:** کدملی کاربر را تغییر دهید:
|
||||
- کدملی شروع با **"1"** → وام تایید میشود ✅
|
||||
- کدملی شروع با **"2"** → وام رد میشود ❌
|
||||
- سایر → در انتظار (بدون ContractNumber) ⏳
|
||||
|
||||
---
|
||||
|
||||
### مشکل: Exception در ProcessDayaLoanApproval
|
||||
|
||||
**خطای احتمالی:** `User has already received Daya credit`
|
||||
|
||||
**علت:** کاربر قبلاً اعتبار دریافت کرده
|
||||
|
||||
**راه حل:**
|
||||
```sql
|
||||
-- فقط برای محیط Development
|
||||
UPDATE CMS.Users SET HasReceivedDayaCredit = 0 WHERE Id = 123;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### مشکل: Migration اعمال نمیشود
|
||||
|
||||
**راه حل:**
|
||||
```bash
|
||||
cd CMS/src/CMSMicroservice.WebApi
|
||||
dotnet ef database update
|
||||
```
|
||||
|
||||
یا در Package Manager Console:
|
||||
```powershell
|
||||
Update-Database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Hangfire Dashboard
|
||||
|
||||
**URL:** `https://localhost:5001/hangfire`
|
||||
|
||||
**Metrics:**
|
||||
- Succeeded jobs
|
||||
- Failed jobs
|
||||
- Processing jobs
|
||||
- Scheduled jobs
|
||||
|
||||
**Job Details:**
|
||||
- Job ID: `daya-loan-check`
|
||||
- Schedule: `*/15 * * * *` (Every 15 minutes)
|
||||
- Next Run: نمایش داده میشود در Dashboard
|
||||
|
||||
### Application Logs
|
||||
|
||||
**Successful Run:**
|
||||
```
|
||||
[INFO] DayaLoanCheckWorker started at {Time}
|
||||
[INFO] Found {Count} users with pending Daya loan status
|
||||
[INFO] Daya loan processed for user {UserId}. Contract: {ContractNumber}
|
||||
[INFO] DayaLoanCheckWorker completed. Checked: {Total}, Processed: {Success}
|
||||
```
|
||||
|
||||
**Error Scenarios:**
|
||||
```
|
||||
[ERROR] Error processing Daya loan for user {UserId}
|
||||
[ERROR] Error calling Daya API service
|
||||
[ERROR] Error in DayaLoanCheckWorker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
1. **API Key Management:**
|
||||
- هرگز API Key را در کد Commit نکنید
|
||||
- از User Secrets برای Development استفاده کنید
|
||||
- از Azure Key Vault یا مشابه برای Production استفاده کنید
|
||||
|
||||
2. **Rate Limiting:**
|
||||
- Worker هر 15 دقیقه اجرا میشود → حداکثر 96 بار در روز
|
||||
- اگر API دایا محدودیت دارد، باید تنظیم شود
|
||||
|
||||
3. **Data Validation:**
|
||||
- کدملی باید 10 رقمی باشد
|
||||
- فقط یک بار برای هر کاربر پردازش میشود
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Batch Processing
|
||||
|
||||
اگر تعداد کاربران زیاد باشد، میتوان Query را بهینه کرد:
|
||||
|
||||
```csharp
|
||||
// پردازش دستهای (100 کاربر در هر بار)
|
||||
var pendingUsers = await _context.Users
|
||||
.Where(u => u.HasReceivedDayaCredit == false && u.NationalCode != null)
|
||||
.Take(100) // Limit
|
||||
.Select(u => new { u.Id, u.NationalCode })
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
میتوان نتایج API را برای مدت کوتاهی Cache کرد:
|
||||
|
||||
```csharp
|
||||
// Cache result for 5 minutes
|
||||
[MemoryCache]
|
||||
public async Task<List<DayaLoanStatusResult>> CheckLoanStatusAsync(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [Hangfire Documentation](https://docs.hangfire.io/)
|
||||
- [MediatR Pattern](https://github.com/jbogard/MediatR)
|
||||
- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
|
||||
---
|
||||
|
||||
**Created:** 2024-12-01
|
||||
**Last Updated:** 2024-12-02
|
||||
**Status:** ✅ 100% Implemented (Mock API in use - Real API integration pending)
|
||||
**Migration:** `20251201191716_AddDayaLoanIntegration`
|
||||
**Test Coverage:** Manual testing documented
|
||||
**Next Steps:** Replace MockDayaLoanApiService with real API implementation when available
|
||||
@@ -0,0 +1,750 @@
|
||||
# Club Discount Shop System - سیستم فروشگاه باشگاه مشتریان با تخفیف ترکیبی
|
||||
|
||||
**تاریخ ایجاد:** 2024-12-02
|
||||
**تاریخ آپدیت:** 2024-12-02
|
||||
**وضعیت:** طراحی (Phase 9)
|
||||
**اولویت:** 🔴 بالا (یکی از دو فاز باقیمانده)
|
||||
|
||||
---
|
||||
|
||||
## 📋 فهرست
|
||||
|
||||
1. [مقدمه](#مقدمه)
|
||||
2. [مفهوم اصلی: پرداخت ترکیبی](#مفهوم-اصلی-پرداخت-ترکیبی)
|
||||
3. [تفاوت با Regular Shop](#تفاوت-با-regular-shop)
|
||||
4. [معماری جداسازی](#معماری-جداسازی)
|
||||
5. [Entity Design](#entity-design)
|
||||
6. [Business Rules](#business-rules)
|
||||
7. [تسکهای پیادهسازی](#تسک-های-پیاده-سازی)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 مقدمه
|
||||
|
||||
### هدف:
|
||||
ایجاد **فروشگاه باشگاه مشتریان** که در آن کاربران میتوانند با **پرداخت ترکیبی** خرید کنند:
|
||||
|
||||
**🔑 قانون اصلی**:
|
||||
- کاربر **نمیتواند** کل محصول را فقط با `DiscountBalance` بخرد
|
||||
- کاربر میتواند **درصدی از قیمت** را با `DiscountBalance` پرداخت کند
|
||||
- **مابقی مبلغ** باید از طریق **درگاه پرداخت واقعی در Gateway/PYMS** پرداخت شود (نه در CMS)
|
||||
|
||||
### مثال عملی:
|
||||
```
|
||||
قیمت محصول: 1,000,000 تومان
|
||||
حداکثر تخفیف مجاز: 30%
|
||||
DiscountBalance کاربر: 500,000 تومان
|
||||
|
||||
محاسبه:
|
||||
- حداکثر تخفیف قابل استفاده: 1,000,000 × 30% = 300,000 تومان
|
||||
- DiscountBalance کاربر: 500,000 تومان (بیشتر از 300,000)
|
||||
- مبلغ تخفیف نهایی: 300,000 تومان (محدود به 30%)
|
||||
- مبلغ قابل پرداخت از درگاه: 1,000,000 - 300,000 = 700,000 تومان
|
||||
|
||||
نتیجه:
|
||||
✅ کسر از DiscountBalance: 300,000 تومان
|
||||
✅ پرداخت از درگاه: 700,000 تومان
|
||||
✅ DiscountBalance باقیمانده: 200,000 تومان
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 مفهوم اصلی: پرداخت ترکیبی
|
||||
|
||||
### Flow خرید:
|
||||
|
||||
```
|
||||
1. کاربر محصول را انتخاب میکند
|
||||
2. سیستم چک میکند:
|
||||
- قیمت محصول: X تومان
|
||||
- حداکثر تخفیف مجاز: Y%
|
||||
- DiscountBalance کاربر: Z تومان
|
||||
|
||||
3. محاسبه تخفیف:
|
||||
MaxDiscountAmount = X × (Y / 100)
|
||||
ActualDiscountAmount = Min(Z, MaxDiscountAmount)
|
||||
|
||||
4. محاسبه مبلغ درگاه:
|
||||
GatewayAmount = X - ActualDiscountAmount
|
||||
|
||||
5. ریدایرکت به درگاه پرداخت (GatewayAmount)
|
||||
|
||||
6. بعد از بازگشت موفق از درگاه:
|
||||
- Verify payment از درگاه
|
||||
- کسر ActualDiscountAmount از DiscountBalance
|
||||
- ثبت سفارش با دو مبلغ جدا
|
||||
- ارسال اطلاعیه به کاربر
|
||||
```
|
||||
|
||||
### مزایا:
|
||||
✅ کاربر نمیتواند کل محصول را با تخفیف بخرد (محدودیت درصد)
|
||||
✅ کاربر میتواند از موجودی تخفیف خود استفاده کند
|
||||
✅ فروشنده مطمئن است مبلغی واقعی دریافت میکند
|
||||
✅ سیستم از سوءاستفاده جلوگیری میکند
|
||||
|
||||
---
|
||||
|
||||
## 🔄 تفاوت با Regular Shop
|
||||
|
||||
| ویژگی | فروشگاه عادی (Regular) | فروشگاه تخفیفی (Club Discount) |
|
||||
|-------|------------------------|---------------------------|
|
||||
| **نوع کیف پول** | `UserWallet.Balance` | `UserWallet.DiscountBalance` + درگاه |
|
||||
| **نحوه پرداخت** | 100% از Balance یا IPG | **ترکیبی**: X% از DiscountBalance + مابقی از IPG |
|
||||
| **محدودیت تخفیف** | ندارد | **دارد** (MaxDiscountPercent per product) |
|
||||
| **نحوه شارژ** | خرید پکیج طلایی (56M) | کمیسیون برداشت Diamond |
|
||||
| **ارتباط با باشگاه** | ✅ دارد | ✅ دارد (اعضای باشگاه) |
|
||||
| **محصولات** | `Products` | `DiscountProduct` (یا flag در Products) |
|
||||
| **سفارش** | `UserOrder` | `DiscountOrder` (با دو مبلغ جدا) |
|
||||
| **پرداخت** | یک مرحلهای | **دو مرحلهای**: 1) Verify IPG، 2) Deduct DiscountBalance |
|
||||
| **TransactionType** | `DepositIpg` | `DiscountPurchase` (hybrid) |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ معماری جداسازی
|
||||
|
||||
### اصل طراحی:
|
||||
> **"همه چیز جدا، جز درگاه پرداخت و کیف پول"**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User │
|
||||
│ - Id │
|
||||
│ - FirstName, LastName, Mobile │
|
||||
│ - PackagePurchaseMethod │
|
||||
└────────────┬────────────────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────────┐ ┌──────────────────────────┐
|
||||
│ UserWallet │ │ Transactions (مشترک) │
|
||||
│ - Balance │ │ - Type │
|
||||
│ - DiscountBalance │ │ - RefId │
|
||||
│ - NetworkBalance │ │ - Amount │
|
||||
└────────────┬───────────────┘ └──────────────────────────┘
|
||||
│
|
||||
├──────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────────┐ ┌──────────────────────────┐
|
||||
│ Regular Shop │ │ Discount Shop │
|
||||
│ - Products │ │ - DiscountProduct │
|
||||
│ - Category │ │ - DiscountCategory │
|
||||
│ - UserCarts │ │ - DiscountShoppingCart │
|
||||
│ - UserOrder │ │ - DiscountOrder │
|
||||
│ - FactorDetails │ │ - DiscountOrderDetail │
|
||||
└────────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Entity Design
|
||||
|
||||
### 1️⃣ `DiscountProduct`
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// محصول فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountProduct : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// عنوان محصول
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات مختصر
|
||||
/// </summary>
|
||||
public string ShortInfomation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات کامل
|
||||
/// </summary>
|
||||
public string FullInformation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// قیمت (ریال)
|
||||
/// </summary>
|
||||
public long Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// درصد تخفیف
|
||||
/// </summary>
|
||||
public int DiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// امتیاز (0 تا 5)
|
||||
/// </summary>
|
||||
public int Rate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// آدرس تصویر اصلی
|
||||
/// </summary>
|
||||
public string ImagePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// آدرس تصویر کوچک
|
||||
/// </summary>
|
||||
public string ThumbnailPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد فروش
|
||||
/// </summary>
|
||||
public int SaleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد بازدید
|
||||
/// </summary>
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// موجودی انبار
|
||||
/// </summary>
|
||||
public int RemainingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت فعال/غیرفعال
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<DiscountShoppingCart> ShoppingCarts { get; set; }
|
||||
public virtual ICollection<DiscountOrderDetail> OrderDetails { get; set; }
|
||||
public virtual ICollection<DiscountProductCategory> ProductCategories { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ `DiscountCategory`
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// دستهبندی فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountCategory : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// نام لاتین (برای URL)
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// عنوان فارسی
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// آدرس تصویر
|
||||
/// </summary>
|
||||
public string? ImagePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه والد (برای دستهبندی چند سطحی)
|
||||
/// </summary>
|
||||
public long? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent Navigation Property
|
||||
/// </summary>
|
||||
public virtual DiscountCategory? Parent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// فعال/غیرفعال
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ترتیب نمایش
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<DiscountCategory> Children { get; set; }
|
||||
public virtual ICollection<DiscountProductCategory> ProductCategories { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ `DiscountProductCategory` (Many-to-Many)
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// رابطه محصول و دستهبندی در فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountProductCategory : BaseAuditableEntity
|
||||
{
|
||||
public long DiscountProductId { get; set; }
|
||||
public virtual DiscountProduct DiscountProduct { get; set; }
|
||||
|
||||
public long DiscountCategoryId { get; set; }
|
||||
public virtual DiscountCategory DiscountCategory { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ `DiscountShoppingCart`
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// سبد خرید فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountShoppingCart : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User Navigation Property
|
||||
/// </summary>
|
||||
public virtual User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه محصول
|
||||
/// </summary>
|
||||
public long DiscountProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DiscountProduct Navigation Property
|
||||
/// </summary>
|
||||
public virtual DiscountProduct DiscountProduct { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// قیمت واحد در زمان افزودن به سبد
|
||||
/// </summary>
|
||||
public long UnitPrice { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ `DiscountOrder`
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// سفارش از فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountOrder : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه کاربر
|
||||
/// </summary>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User Navigation Property
|
||||
/// </summary>
|
||||
public virtual User User { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ کل سفارش
|
||||
/// </summary>
|
||||
public long TotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ تخفیف
|
||||
/// </summary>
|
||||
public long DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ قابل پرداخت
|
||||
/// </summary>
|
||||
public long PayableAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت پرداخت
|
||||
/// </summary>
|
||||
public PaymentStatus PaymentStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تاریخ پرداخت
|
||||
/// </summary>
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه تراکنش (اگر پرداخت موفق باشد)
|
||||
/// </summary>
|
||||
public long? TransactionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transaction Navigation Property
|
||||
/// </summary>
|
||||
public virtual Transactions? Transaction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه آدرس کاربر
|
||||
/// </summary>
|
||||
public long UserAddressId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UserAddress Navigation Property
|
||||
/// </summary>
|
||||
public virtual UserAddress UserAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// وضعیت ارسال
|
||||
/// </summary>
|
||||
public DeliveryStatus DeliveryStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// کد رهگیری مرسوله
|
||||
/// </summary>
|
||||
public string? TrackingCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// توضیحات وضعیت ارسال
|
||||
/// </summary>
|
||||
public string? DeliveryDescription { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<DiscountOrderDetail> OrderDetails { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ `DiscountOrderDetail`
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Entities.DiscountShop;
|
||||
|
||||
/// <summary>
|
||||
/// جزئیات سفارش از فروشگاه تخفیفی
|
||||
/// </summary>
|
||||
public class DiscountOrderDetail : BaseAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// شناسه سفارش
|
||||
/// </summary>
|
||||
public long DiscountOrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DiscountOrder Navigation Property
|
||||
/// </summary>
|
||||
public virtual DiscountOrder DiscountOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// شناسه محصول
|
||||
/// </summary>
|
||||
public long DiscountProductId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DiscountProduct Navigation Property
|
||||
/// </summary>
|
||||
public virtual DiscountProduct DiscountProduct { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// تعداد
|
||||
/// </summary>
|
||||
public int Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// قیمت واحد در زمان ثبت سفارش
|
||||
/// </summary>
|
||||
public long UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// درصد تخفیف در زمان ثبت سفارش
|
||||
/// </summary>
|
||||
public int DiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// مبلغ کل این آیتم (بعد از تخفیف)
|
||||
/// </summary>
|
||||
public long TotalPrice { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Business Rules
|
||||
|
||||
### قانون 1: خرید از Discount Shop فقط با DiscountBalance
|
||||
|
||||
```csharp
|
||||
// در زمان Checkout از Discount Shop:
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == userId);
|
||||
|
||||
if (wallet.DiscountBalance < order.PayableAmount)
|
||||
{
|
||||
throw new ValidationException(
|
||||
$"موجودی کیف پول تخفیفی شما کافی نیست. " +
|
||||
$"موجودی فعلی: {wallet.DiscountBalance:N0} تومان، " +
|
||||
$"مبلغ مورد نیاز: {order.PayableAmount:N0} تومان"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 2: خرید از Regular Shop فقط با Balance
|
||||
|
||||
```csharp
|
||||
// در زمان Checkout از Regular Shop:
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == userId);
|
||||
|
||||
if (wallet.Balance < order.Amount)
|
||||
{
|
||||
throw new ValidationException(
|
||||
$"موجودی کیف پول اصلی شما کافی نیست. " +
|
||||
$"موجودی فعلی: {wallet.Balance:N0} تومان، " +
|
||||
$"مبلغ مورد نیاز: {order.Amount:N0} تومان"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 3: شارژ DiscountBalance از طریق درگاه
|
||||
|
||||
```csharp
|
||||
// در VerifyDiscountWalletChargeCommand:
|
||||
wallet.DiscountBalance += amount;
|
||||
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Type = TransactionType.DiscountWalletCharge,
|
||||
Amount = amount,
|
||||
RefId = verifyResult.RefId
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 4: محصولات Discount Shop جدا از Regular Shop
|
||||
|
||||
- یک محصول **نمیتواند** هم در `Products` باشد، هم در `DiscountProduct`
|
||||
- Admin باید محصولات را جداگانه مدیریت کند
|
||||
- هیچ رابطهای بین `Products` و `DiscountProduct` نیست
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flow Diagram: خرید از Discount Shop
|
||||
|
||||
```
|
||||
کاربر → مشاهده محصولات Discount Shop
|
||||
↓
|
||||
افزودن به DiscountShoppingCart
|
||||
↓
|
||||
Checkout (بررسی DiscountBalance)
|
||||
↓
|
||||
ثبت DiscountOrder (PaymentStatus: Pending)
|
||||
↓
|
||||
کم کردن DiscountBalance از کیف پول
|
||||
↓
|
||||
ثبت Transaction (Type: Buy) ← این تراکنش برای خرید است
|
||||
↓
|
||||
ثبت DiscountOrderDetail برای هر محصول
|
||||
↓
|
||||
بهروزرسانی DiscountOrder (PaymentStatus: Success)
|
||||
↓
|
||||
خالی کردن DiscountShoppingCart
|
||||
↓
|
||||
نمایش پیام موفقیت + کد رهگیری
|
||||
```
|
||||
|
||||
**نکته:** در این فلو از درگاه استفاده **نمیشود** چون موجودی از قبل شارژ شده است.
|
||||
|
||||
---
|
||||
|
||||
## 📝 تسکهای پیادهسازی
|
||||
|
||||
### Phase 1: Entity Creation (2 روز)
|
||||
|
||||
1. **ایجاد namespace جدید**:
|
||||
- `CMSMicroservice.Domain/Entities/DiscountShop/`
|
||||
|
||||
2. **ایجاد Entityها**:
|
||||
- `DiscountProduct`
|
||||
- `DiscountCategory`
|
||||
- `DiscountProductCategory`
|
||||
- `DiscountShoppingCart`
|
||||
- `DiscountOrder`
|
||||
- `DiscountOrderDetail`
|
||||
|
||||
3. **ایجاد Configurationها**:
|
||||
- `DiscountProductConfiguration`
|
||||
- `DiscountCategoryConfiguration`
|
||||
- و غیره...
|
||||
|
||||
4. **بهروزرسانی `DbContext`**:
|
||||
```csharp
|
||||
public DbSet<DiscountProduct> DiscountProducts { get; set; }
|
||||
public DbSet<DiscountCategory> DiscountCategories { get; set; }
|
||||
// ...
|
||||
```
|
||||
|
||||
5. **ایجاد Migration**:
|
||||
```bash
|
||||
dotnet ef migrations add AddDiscountShopTables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Commands & Queries (3 روز)
|
||||
|
||||
#### DiscountProduct CRUD:
|
||||
- `CreateDiscountProductCommand`
|
||||
- `UpdateDiscountProductCommand`
|
||||
- `DeleteDiscountProductCommand`
|
||||
- `GetDiscountProductByIdQuery`
|
||||
- `GetDiscountProductsListQuery`
|
||||
|
||||
#### DiscountCategory CRUD:
|
||||
- `CreateDiscountCategoryCommand`
|
||||
- `UpdateDiscountCategoryCommand`
|
||||
- `DeleteDiscountCategoryCommand`
|
||||
- `GetDiscountCategoriesTreeQuery`
|
||||
|
||||
#### Shopping Cart:
|
||||
- `AddToDiscountCartCommand`
|
||||
- `RemoveFromDiscountCartCommand`
|
||||
- `GetDiscountCartQuery`
|
||||
|
||||
#### Order:
|
||||
- `CreateDiscountOrderCommand` (Checkout)
|
||||
- `GetDiscountOrderByIdQuery`
|
||||
- `GetMyDiscountOrdersQuery` (برای کاربر)
|
||||
- `UpdateDiscountOrderDeliveryCommand` (برای Admin)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: BackOffice.BFF APIs (1 روز)
|
||||
|
||||
**Proto file**: `DiscountShopContract.proto`
|
||||
|
||||
```protobuf
|
||||
service DiscountShopContract {
|
||||
// Product
|
||||
rpc CreateDiscountProduct(CreateDiscountProductRequest) returns (CreateDiscountProductResponse);
|
||||
rpc UpdateDiscountProduct(UpdateDiscountProductRequest) returns (UpdateDiscountProductResponse);
|
||||
rpc GetDiscountProducts(GetDiscountProductsRequest) returns (GetDiscountProductsResponse);
|
||||
|
||||
// Category
|
||||
rpc CreateDiscountCategory(CreateDiscountCategoryRequest) returns (CreateDiscountCategoryResponse);
|
||||
rpc GetDiscountCategoriesTree(Empty) returns (GetDiscountCategoriesTreeResponse);
|
||||
|
||||
// Orders
|
||||
rpc GetDiscountOrders(GetDiscountOrdersRequest) returns (GetDiscountOrdersResponse);
|
||||
rpc UpdateDiscountOrderDelivery(UpdateDiscountOrderDeliveryRequest) returns (UpdateDiscountOrderDeliveryResponse);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: FrontOffice.BFF APIs (1 روز)
|
||||
|
||||
**Proto file**: `DiscountShopContract.proto` (در FrontOffice.BFF)
|
||||
|
||||
```protobuf
|
||||
service DiscountShopContract {
|
||||
// Browse
|
||||
rpc GetDiscountProducts(GetDiscountProductsRequest) returns (GetDiscountProductsResponse);
|
||||
rpc GetDiscountProductById(GetDiscountProductByIdRequest) returns (GetDiscountProductByIdResponse);
|
||||
|
||||
// Cart
|
||||
rpc AddToDiscountCart(AddToDiscountCartRequest) returns (AddToDiscountCartResponse);
|
||||
rpc GetMyDiscountCart(Empty) returns (GetMyDiscountCartResponse);
|
||||
rpc RemoveFromDiscountCart(RemoveFromDiscountCartRequest) returns (RemoveFromDiscountCartResponse);
|
||||
|
||||
// Order
|
||||
rpc CheckoutDiscountCart(CheckoutDiscountCartRequest) returns (CheckoutDiscountCartResponse);
|
||||
rpc GetMyDiscountOrders(Empty) returns (GetMyDiscountOrdersResponse);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: BackOffice UI (3 روز)
|
||||
|
||||
**صفحات مدیریت:**
|
||||
1. **لیست محصولات تخفیفی** + CRUD
|
||||
2. **دستهبندیها** (Tree View) + CRUD
|
||||
3. **سفارشات تخفیفی** + تغییر وضعیت ارسال
|
||||
4. **گزارش فروش** Discount Shop
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: FrontOffice UI (3 روز)
|
||||
|
||||
**صفحات کاربر:**
|
||||
1. **لیست محصولات تخفیفی** (با فیلتر دستهبندی)
|
||||
2. **جزئیات محصول تخفیفی**
|
||||
3. **سبد خرید تخفیفی**
|
||||
4. **Checkout** (با نمایش `DiscountBalance`)
|
||||
5. **لیست سفارشات تخفیفی کاربر**
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Unit Tests (2 روز)
|
||||
|
||||
1. تست **CRUD محصولات تخفیفی**
|
||||
2. تست **AddToDiscountCart**
|
||||
3. تست **CheckoutDiscountCart**:
|
||||
- کاربر با موجودی کافی → موفق
|
||||
- کاربر با موجودی ناکافی → خطا
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Documentation (0.5 روز)
|
||||
|
||||
- بهروزرسانی `implementation-progress.md`
|
||||
- لینک از `REMAINING-TASKS-CONSOLIDATED.md`
|
||||
|
||||
---
|
||||
|
||||
## 📊 خلاصه Timeline
|
||||
|
||||
| Phase | عنوان | زمان |
|
||||
|-------|-------|------|
|
||||
| 1 | Entity Creation | 2 روز |
|
||||
| 2 | Commands & Queries (CMS) | 3 روز |
|
||||
| 3 | BackOffice.BFF APIs | 1 روز |
|
||||
| 4 | FrontOffice.BFF APIs | 1 روز |
|
||||
| 5 | BackOffice UI | 3 روز |
|
||||
| 6 | FrontOffice UI | 3 روز |
|
||||
| 7 | Unit Tests | 2 روز |
|
||||
| 8 | Documentation | 0.5 روز |
|
||||
| **جمع** | | **15.5 روز** (~3 هفته) |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 مراجع
|
||||
|
||||
- [Package Purchase System](./package-purchase-system.md)
|
||||
- [Manual Payment System](./manual-payment-system.md)
|
||||
- [Implementation Progress](./implementation-progress.md)
|
||||
- [REMAINING-TASKS](../REMAINING-TASKS-CONSOLIDATED.md)
|
||||
|
||||
---
|
||||
|
||||
**تاریخ آخرین بهروزرسانی:** 2024-12-02
|
||||
**نویسنده:** GitHub Copilot
|
||||
**وضعیت:** ✅ تایید شده توسط کاربر
|
||||
@@ -0,0 +1,548 @@
|
||||
# Manual Payment System (سیستم پرداخت دستی مشتریان)
|
||||
|
||||
## 📌 Overview
|
||||
|
||||
سیستم پرداخت دستی برای مشتریانی که **بدون خرید وام دایا** میخواهند مستقیماً 56 میلیون تومان پرداخت کنند و همان مزایا را دریافت کنند.
|
||||
|
||||
### 🎯 سناریوها
|
||||
|
||||
#### سناریو 1: پرداخت آنلاین (درگاه پرداخت)
|
||||
```
|
||||
کاربر → انتخاب گزینه "پرداخت دستی" در فرانتآفیس
|
||||
↓
|
||||
ایجاد Transaction با Type=ManualPaymentOnline, Amount=56M, Status=Pending
|
||||
↓
|
||||
ریدایرکت به درگاه پرداخت (Zarinpal/Mellat/...)
|
||||
↓
|
||||
Callback از درگاه با RefId
|
||||
↓
|
||||
VerifyManualPaymentCommand → تایید تراکنش
|
||||
↓
|
||||
شارژ کیفپولها (Balance=56M, NetworkBalance=56M, DiscountBalance=56M)
|
||||
↓
|
||||
فعالسازی عضویت باشگاه (ClubMembership)
|
||||
```
|
||||
|
||||
#### سناریو 2: کارتبهکارت با تایید ادمین
|
||||
```
|
||||
کاربر → کارتبهکارت 56 میلیون + ارسال تصویر رسید
|
||||
↓
|
||||
CreateManualPaymentRequestCommand → ثبت درخواست با Status=PendingAdminApproval
|
||||
- تصویر رسید + کد پیگیری استخراج شده توسط کاربر
|
||||
↓
|
||||
ادمین → بررسی درخواست در BackOffice
|
||||
↓
|
||||
ApproveManualPaymentCommand یا RejectManualPaymentCommand
|
||||
↓
|
||||
در صورت تایید:
|
||||
- ایجاد Transaction با RefId=کد پیگیری
|
||||
- شارژ کیفپولها
|
||||
- فعالسازی عضویت باشگاه
|
||||
↓
|
||||
در صورت رد:
|
||||
- ثبت دلیل رد
|
||||
- اطلاعرسانی به کاربر
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Architecture
|
||||
|
||||
### Domain Layer
|
||||
|
||||
> **وضعیت فعلی پیادهسازی (CMS)**
|
||||
> در نسخهای که الآن در CMS داریم، سناریوی «درخواست پرداخت دستی توسط کاربر» (ManualPaymentRequest + Verify از درگاه) هنوز پیادهسازی نشده و فقط بخش **پرداخت دستی توسط Admin/SuperAdmin** با Entity سادهتر `ManualPayment` و Enumهای `ManualPaymentType` و `ManualPaymentStatus` (Pending/Approved/Rejected/Cancelled) اجرا شده است.
|
||||
> بخشهای زیر که با `ManualPaymentRequest`، `ManualPaymentMethod` و Verify/ProcessManualPayment توضیح داده شدهاند، طراحی کامل سیستم هستند و برای فاز بعدی (FrontOffice + OnlineGateway/CardToCard) استفاده خواهند شد.
|
||||
|
||||
#### **ManualPaymentStatus Enum (طراحی کامل – برای Requestها)**
|
||||
```csharp
|
||||
public enum ManualPaymentStatus
|
||||
{
|
||||
PendingAdminApproval = 0, // در انتظار تایید ادمین (کارتبهکارت)
|
||||
PendingPayment = 1, // در انتظار پرداخت (درگاه آنلاین)
|
||||
PaymentVerified = 2, // پرداخت تایید شده (از درگاه)
|
||||
AdminApproved = 3, // تایید شده توسط ادمین
|
||||
AdminRejected = 4, // رد شده توسط ادمین
|
||||
Completed = 5, // تکمیل شده (کیفپول شارژ شده)
|
||||
Failed = 6 // خطا در پردازش
|
||||
}
|
||||
```
|
||||
|
||||
#### **ManualPaymentMethod Enum**
|
||||
```csharp
|
||||
public enum ManualPaymentMethod
|
||||
{
|
||||
OnlineGateway = 0, // درگاه آنلاین
|
||||
CardToCard = 1 // کارتبهکارت
|
||||
}
|
||||
```
|
||||
|
||||
#### **ManualPaymentRequest Entity (طراحی کامل – هنوز پیاده نشده)**
|
||||
```csharp
|
||||
public class ManualPaymentRequest : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public ManualPaymentMethod Method { get; set; }
|
||||
public ManualPaymentStatus Status { get; set; }
|
||||
public long Amount { get; set; } = 56_000_000; // مبلغ ثابت
|
||||
|
||||
// آنلاین Gateway
|
||||
public string? GatewayName { get; set; } // Zarinpal, Mellat, etc.
|
||||
public string? GatewayTrackingCode { get; set; } // کد پیگیری درگاه
|
||||
public DateTime? GatewayPaymentDate { get; set; }
|
||||
|
||||
// کارتبهکارت
|
||||
public string? ReceiptImageUrl { get; set; } // مسیر تصویر رسید
|
||||
public string? UserProvidedTrackingCode { get; set; } // کد پیگیری که کاربر داده
|
||||
public DateTime? CardToCardDate { get; set; }
|
||||
|
||||
// تایید/رد ادمین
|
||||
public long? ApprovedByAdminId { get; set; }
|
||||
public DateTime? AdminDecisionDate { get; set; }
|
||||
public string? AdminNotes { get; set; } // توضیحات ادمین (دلیل رد)
|
||||
|
||||
// تراکنش نهایی
|
||||
public long? TransactionId { get; set; }
|
||||
public bool IsProcessed { get; set; }
|
||||
public DateTime? ProcessedDate { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual User User { get; set; }
|
||||
public virtual User? ApprovedByAdmin { get; set; }
|
||||
public virtual Transactions? Transaction { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Application Layer
|
||||
|
||||
#### **Commands**
|
||||
|
||||
##### 1. CreateManualPaymentRequestCommand (FrontOffice)
|
||||
ایجاد درخواست پرداخت دستی توسط کاربر
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record CreateManualPaymentRequestCommand : IRequest<CreateManualPaymentRequestResponseDto>
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
public ManualPaymentMethod Method { get; init; }
|
||||
|
||||
// برای OnlineGateway
|
||||
public string? GatewayName { get; init; }
|
||||
public string? ReturnUrl { get; init; } // URL بازگشت بعد از پرداخت
|
||||
|
||||
// برای CardToCard
|
||||
public IFormFile? ReceiptImage { get; init; } // فایل تصویر رسید
|
||||
public string? TrackingCode { get; init; } // کد پیگیری
|
||||
public DateTime? TransactionDate { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```csharp
|
||||
public class CreateManualPaymentRequestResponseDto
|
||||
{
|
||||
public long RequestId { get; set; }
|
||||
public ManualPaymentStatus Status { get; set; }
|
||||
|
||||
// برای OnlineGateway: URL پرداخت
|
||||
public string? PaymentUrl { get; set; }
|
||||
|
||||
// برای CardToCard: پیام موفقیت
|
||||
public string Message { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**Business Logic:**
|
||||
1. بررسی اینکه کاربر قبلاً درخواست Pending ندارد
|
||||
2. اگر Method=OnlineGateway:
|
||||
- ایجاد ManualPaymentRequest با Status=PendingPayment
|
||||
- فراخوانی Gateway Service برای دریافت URL پرداخت
|
||||
- ذخیره GatewayName و کد درخواست
|
||||
- برگرداندن PaymentUrl به کاربر
|
||||
3. اگر Method=CardToCard:
|
||||
- آپلود تصویر رسید به Storage
|
||||
- ایجاد ManualPaymentRequest با Status=PendingAdminApproval
|
||||
- ذخیره UserProvidedTrackingCode و CardToCardDate
|
||||
- ارسال نوتیفیکیشن به ادمینها
|
||||
|
||||
##### 2. VerifyManualPaymentCommand (Callback از درگاه)
|
||||
تایید پرداخت آنلاین بعد از بازگشت از درگاه
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record VerifyManualPaymentCommand : IRequest<VerifyManualPaymentResponseDto>
|
||||
{
|
||||
public long RequestId { get; init; }
|
||||
public string GatewayTrackingCode { get; init; }
|
||||
public string? Authority { get; init; } // پارامتر درگاه
|
||||
}
|
||||
```
|
||||
|
||||
**Business Logic:**
|
||||
1. یافتن ManualPaymentRequest با Status=PendingPayment
|
||||
2. فراخوانی Gateway Service برای Verify کردن تراکنش
|
||||
3. اگر تایید شد:
|
||||
- بهروزرسانی Status → PaymentVerified
|
||||
- ذخیره GatewayTrackingCode و GatewayPaymentDate
|
||||
- فراخوانی ProcessManualPaymentCommand برای شارژ کیفپول
|
||||
4. اگر رد شد:
|
||||
- بهروزرسانی Status → Failed
|
||||
|
||||
##### 3. ApproveManualPaymentCommand (Admin)
|
||||
تایید درخواست کارتبهکارت توسط ادمین
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record ApproveManualPaymentCommand : IRequest<ApproveManualPaymentResponseDto>
|
||||
{
|
||||
public long RequestId { get; init; }
|
||||
public long AdminUserId { get; init; }
|
||||
public string? AdminNotes { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Business Logic:**
|
||||
1. بررسی RequestId موجود با Status=PendingAdminApproval
|
||||
2. بررسی دسترسی ادمین
|
||||
3. بهروزرسانی:
|
||||
- Status → AdminApproved
|
||||
- ApprovedByAdminId, AdminDecisionDate, AdminNotes
|
||||
4. فراخوانی ProcessManualPaymentCommand برای شارژ کیفپول
|
||||
|
||||
##### 4. RejectManualPaymentCommand (Admin)
|
||||
رد درخواست کارتبهکارت توسط ادمین
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record RejectManualPaymentCommand : IRequest<RejectManualPaymentResponseDto>
|
||||
{
|
||||
public long RequestId { get; init; }
|
||||
public long AdminUserId { get; init; }
|
||||
public string RejectionReason { get; init; } // الزامی
|
||||
}
|
||||
```
|
||||
|
||||
**Business Logic:**
|
||||
1. بررسی RequestId موجود
|
||||
2. بهروزرسانی:
|
||||
- Status → AdminRejected
|
||||
- ApprovedByAdminId, AdminDecisionDate
|
||||
- AdminNotes = RejectionReason
|
||||
3. ارسال نوتیفیکیشن به کاربر با دلیل رد
|
||||
|
||||
##### 5. ProcessManualPaymentCommand (Internal)
|
||||
شارژ کیفپولها بعد از تایید پرداخت
|
||||
|
||||
**این Command داخلی است و فقط توسط Verify یا Approve فراخوانی میشود.**
|
||||
|
||||
**Business Logic:**
|
||||
1. ایجاد Transaction:
|
||||
- Type: DepositManual
|
||||
- Amount: 56M
|
||||
- RefId: GatewayTrackingCode یا UserProvidedTrackingCode
|
||||
2. شارژ Balance: +56M
|
||||
3. شارژ NetworkBalance: +56M
|
||||
4. شارژ DiscountBalance: +56M
|
||||
5. فعالسازی ClubMembership (اگر غیرفعال باشد)
|
||||
6. ثبت UserWalletChangeLog
|
||||
7. بهروزرسانی ManualPaymentRequest:
|
||||
- Status → Completed
|
||||
- TransactionId, IsProcessed=true, ProcessedDate
|
||||
8. ارسال نوتیفیکیشن موفقیت به کاربر
|
||||
|
||||
##### 6. GetUserManualPaymentHistoryQuery
|
||||
دریافت تاریخچه پرداختهای دستی کاربر
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record GetUserManualPaymentHistoryQuery : IRequest<List<ManualPaymentHistoryDto>>
|
||||
{
|
||||
public long UserId { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
##### 7. GetPendingManualPaymentsQuery (Admin)
|
||||
دریافت لیست درخواستهای در انتظار تایید
|
||||
|
||||
**Request:**
|
||||
```csharp
|
||||
public record GetPendingManualPaymentsQuery : IRequest<List<PendingManualPaymentDto>>
|
||||
{
|
||||
public ManualPaymentStatus? StatusFilter { get; init; } = ManualPaymentStatus.PendingAdminApproval;
|
||||
public int PageNumber { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Database Schema
|
||||
|
||||
### ManualPaymentRequests Table
|
||||
```sql
|
||||
CREATE TABLE [CMS].[ManualPaymentRequests] (
|
||||
[Id] bigint IDENTITY(1,1) PRIMARY KEY,
|
||||
[UserId] bigint NOT NULL FOREIGN KEY REFERENCES Users(Id),
|
||||
[Method] int NOT NULL,
|
||||
[Status] int NOT NULL,
|
||||
[Amount] bigint NOT NULL DEFAULT 56000000,
|
||||
|
||||
-- آنلاین Gateway
|
||||
[GatewayName] nvarchar(50) NULL,
|
||||
[GatewayTrackingCode] nvarchar(200) NULL,
|
||||
[GatewayPaymentDate] datetime2 NULL,
|
||||
|
||||
-- کارتبهکارت
|
||||
[ReceiptImageUrl] nvarchar(500) NULL,
|
||||
[UserProvidedTrackingCode] nvarchar(200) NULL,
|
||||
[CardToCardDate] datetime2 NULL,
|
||||
|
||||
-- تایید ادمین
|
||||
[ApprovedByAdminId] bigint NULL FOREIGN KEY REFERENCES Users(Id),
|
||||
[AdminDecisionDate] datetime2 NULL,
|
||||
[AdminNotes] nvarchar(max) NULL,
|
||||
|
||||
-- تراکنش
|
||||
[TransactionId] bigint NULL FOREIGN KEY REFERENCES Transactionss(Id),
|
||||
[IsProcessed] bit NOT NULL DEFAULT 0,
|
||||
[ProcessedDate] datetime2 NULL,
|
||||
|
||||
-- Audit
|
||||
[Created] datetime2 NOT NULL,
|
||||
[CreatedBy] nvarchar(max) NULL,
|
||||
[LastModified] datetime2 NULL,
|
||||
[LastModifiedBy] nvarchar(max) NULL,
|
||||
[IsDeleted] bit NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IX_ManualPaymentRequests_UserId ON ManualPaymentRequests(UserId);
|
||||
CREATE INDEX IX_ManualPaymentRequests_Status ON ManualPaymentRequests(Status);
|
||||
CREATE INDEX IX_ManualPaymentRequests_TransactionId ON ManualPaymentRequests(TransactionId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Process Flows
|
||||
|
||||
### Flow 1: پرداخت آنلاین
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as کاربر
|
||||
participant FrontOffice as FrontOffice
|
||||
participant CMS as CMS API
|
||||
participant Gateway as درگاه پرداخت
|
||||
|
||||
User->>FrontOffice: انتخاب "پرداخت دستی"
|
||||
FrontOffice->>CMS: CreateManualPaymentRequest (Method=OnlineGateway)
|
||||
CMS->>Gateway: ایجاد درخواست پرداخت
|
||||
Gateway-->>CMS: PaymentUrl
|
||||
CMS-->>FrontOffice: PaymentUrl
|
||||
FrontOffice->>Gateway: ریدایرکت کاربر
|
||||
User->>Gateway: پرداخت 56M
|
||||
Gateway->>CMS: Callback (RefId, Authority)
|
||||
CMS->>Gateway: Verify Payment
|
||||
Gateway-->>CMS: تایید پرداخت
|
||||
CMS->>CMS: ProcessManualPayment (شارژ کیفپول)
|
||||
CMS-->>FrontOffice: موفقیت
|
||||
FrontOffice-->>User: پرداخت موفق
|
||||
```
|
||||
|
||||
### Flow 2: کارتبهکارت
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as کاربر
|
||||
participant FrontOffice as FrontOffice
|
||||
participant CMS as CMS API
|
||||
participant Admin as ادمین (BackOffice)
|
||||
|
||||
User->>User: کارتبهکارت 56M
|
||||
User->>FrontOffice: آپلود رسید + کد پیگیری
|
||||
FrontOffice->>CMS: CreateManualPaymentRequest (Method=CardToCard)
|
||||
CMS->>CMS: ذخیره تصویر + Status=PendingAdminApproval
|
||||
CMS-->>Admin: نوتیفیکیشن (درخواست جدید)
|
||||
Admin->>CMS: GetPendingManualPayments
|
||||
CMS-->>Admin: لیست درخواستها
|
||||
Admin->>Admin: بررسی رسید و کد پیگیری
|
||||
|
||||
alt تایید
|
||||
Admin->>CMS: ApproveManualPayment
|
||||
CMS->>CMS: ProcessManualPayment (شارژ کیفپول)
|
||||
CMS-->>User: نوتیفیکیشن موفقیت
|
||||
else رد
|
||||
Admin->>CMS: RejectManualPayment (دلیل رد)
|
||||
CMS-->>User: نوتیفیکیشن رد با دلیل
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Scenarios
|
||||
|
||||
### Test 1: پرداخت آنلاین موفق
|
||||
```bash
|
||||
# Step 1: ایجاد درخواست
|
||||
POST /api/manualpayment/create
|
||||
{
|
||||
"userId": 123,
|
||||
"method": 0,
|
||||
"gatewayName": "Zarinpal",
|
||||
"returnUrl": "https://example.com/callback"
|
||||
}
|
||||
|
||||
# Response: PaymentUrl
|
||||
|
||||
# Step 2: کاربر پرداخت میکند (Mock Gateway)
|
||||
|
||||
# Step 3: Callback
|
||||
POST /api/manualpayment/verify
|
||||
{
|
||||
"requestId": 456,
|
||||
"gatewayTrackingCode": "ZP-12345",
|
||||
"authority": "A00000000..."
|
||||
}
|
||||
|
||||
# Result: کیفپول شارژ شده، باشگاه فعال
|
||||
```
|
||||
|
||||
### Test 2: کارتبهکارت با تایید ادمین
|
||||
```bash
|
||||
# Step 1: ایجاد درخواست کاربر
|
||||
POST /api/manualpayment/create
|
||||
{
|
||||
"userId": 123,
|
||||
"method": 1,
|
||||
"receiptImage": <file>,
|
||||
"trackingCode": "REF-98765",
|
||||
"transactionDate": "2024-12-01T10:00:00Z"
|
||||
}
|
||||
|
||||
# Step 2: ادمین بررسی میکند
|
||||
GET /api/admin/manualpayment/pending
|
||||
|
||||
# Step 3: ادمین تایید میکند
|
||||
POST /api/admin/manualpayment/approve
|
||||
{
|
||||
"requestId": 456,
|
||||
"adminUserId": 1,
|
||||
"adminNotes": "رسید معتبر است"
|
||||
}
|
||||
|
||||
# Result: کیفپول شارژ شده
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementation Tasks
|
||||
|
||||
### CMS Microservice
|
||||
|
||||
#### Domain Layer
|
||||
- [ ] ایجاد `ManualPaymentStatus` enum
|
||||
- [ ] ایجاد `ManualPaymentMethod` enum
|
||||
- [ ] ایجاد `ManualPaymentRequest` entity
|
||||
- [ ] اضافه کردن به `ApplicationDbContext`
|
||||
|
||||
#### Application Layer
|
||||
- [ ] `CreateManualPaymentRequestCommand` + Handler + Validator
|
||||
- [ ] `VerifyManualPaymentCommand` + Handler
|
||||
- [ ] `ApproveManualPaymentCommand` + Handler
|
||||
- [ ] `RejectManualPaymentCommand` + Handler
|
||||
- [ ] `ProcessManualPaymentCommand` + Handler (Internal)
|
||||
- [ ] `GetUserManualPaymentHistoryQuery` + Handler
|
||||
- [ ] `GetPendingManualPaymentsQuery` + Handler
|
||||
- [ ] Interface: `IPaymentGatewayService`
|
||||
- [ ] Interface: `IFileStorageService` (برای آپلود تصویر)
|
||||
|
||||
#### Infrastructure Layer
|
||||
- [ ] `ZarinpalGatewayService` : IPaymentGatewayService
|
||||
- [ ] `LocalFileStorageService` : IFileStorageService
|
||||
- [ ] Migration: `AddManualPaymentSystem`
|
||||
|
||||
#### WebApi Layer (Protobuf/gRPC)
|
||||
- [ ] Proto definitions: `ManualPayment.proto`
|
||||
- [ ] gRPC Service: `ManualPaymentService`
|
||||
|
||||
### FrontOffice
|
||||
|
||||
#### Components
|
||||
- [ ] `ManualPaymentPage.razor` - صفحه انتخاب روش پرداخت
|
||||
- [ ] `OnlinePaymentForm.razor` - فرم پرداخت آنلاین
|
||||
- [ ] `CardToCardForm.razor` - فرم کارتبهکارت (آپلود رسید)
|
||||
- [ ] `PaymentCallbackPage.razor` - صفحه بازگشت از درگاه
|
||||
- [ ] `PaymentHistoryPage.razor` - تاریخچه پرداختهای کاربر
|
||||
|
||||
#### Services
|
||||
- [ ] `ManualPaymentService.cs` - فراخوانی BFF
|
||||
|
||||
### FrontOffice.BFF
|
||||
|
||||
#### Application Layer
|
||||
- [ ] CQRS Handlers برای مپ کردن gRPC به REST
|
||||
- [ ] DTOs برای API های REST
|
||||
|
||||
#### WebApi Layer
|
||||
- [ ] `ManualPaymentController.cs` - REST endpoints
|
||||
|
||||
### BackOffice
|
||||
|
||||
#### Components
|
||||
- [ ] `PendingPaymentsPage.razor` - لیست درخواستهای در انتظار
|
||||
- [ ] `PaymentRequestDetailsModal.razor` - جزئیات + نمایش رسید
|
||||
- [ ] `ApproveRejectButtons.razor` - دکمههای تایید/رد
|
||||
|
||||
#### Services
|
||||
- [ ] `ManualPaymentAdminService.cs` - فراخوانی BFF
|
||||
|
||||
### BackOffice.BFF
|
||||
|
||||
#### Application Layer
|
||||
- [ ] Admin CQRS Handlers
|
||||
- [ ] Admin DTOs
|
||||
|
||||
#### WebApi Layer
|
||||
- [ ] `AdminManualPaymentController.cs` - REST endpoints برای ادمین
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
### 1. Transaction Type
|
||||
- برای پرداخت دستی از `TransactionType.DepositManual` استفاده شود
|
||||
- RefId = GatewayTrackingCode (آنلاین) یا UserProvidedTrackingCode (کارتبهکارت)
|
||||
|
||||
### 2. Security
|
||||
- تایید پرداخت درگاه باید با Signature Verification انجام شود
|
||||
- تصاویر رسید باید با Validation بارگذاری شوند (حجم، فرمت، محتوا)
|
||||
- فقط ادمینها حق تایید/رد کارتبهکارت دارند
|
||||
|
||||
### 3. Idempotency
|
||||
- نباید کاربر بتواند چند درخواست همزمان Pending داشته باشد
|
||||
- هر RequestId فقط یک بار قابل Verify است
|
||||
|
||||
### 4. Notifications
|
||||
- SMS/Email به کاربر بعد از:
|
||||
- ایجاد درخواست کارتبهکارت
|
||||
- تایید/رد ادمین
|
||||
- موفقیت پرداخت آنلاین
|
||||
|
||||
### 5. File Storage
|
||||
- تصاویر رسید باید با GUID ذخیره شوند
|
||||
- مسیر: `/uploads/receipts/{year}/{month}/{guid}.jpg`
|
||||
- حداکثر حجم: 2MB
|
||||
- فرمتهای مجاز: JPG, PNG, PDF
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [daya-loan-integration.md](./daya-loan-integration.md) - سیستم وام دایا
|
||||
- [network-club-commission-system-v1.1.md](./network-club-commission-system-v1.1.md) - بیزینس کلی
|
||||
|
||||
---
|
||||
|
||||
**Created:** 2024-12-01
|
||||
**Status:** ⚠️ Not Implemented Yet (Design Complete)
|
||||
**Priority:** High (برای کاربران بدون وام دایا ضروری است)
|
||||
@@ -0,0 +1,905 @@
|
||||
# سیستم باشگاه مشتریان و محاسبه کمیسیون شبکه
|
||||
|
||||
## خلاصه اجرایی
|
||||
این سند تحلیل جامع و معماری پیشنهادی برای پیادهسازی سیستم باشگاه مشتریان (Club Membership) و محاسبه کمیسیون شبکهای (MLM Binary Plan) را ارائه میدهد. این سیستم امکان مدیریت سه نوع کیف پول، فروشگاه اختصاصی با تخفیف، و توزیع عادلانه کمیسیون بر اساس تعادل شبکه را فراهم میکند.
|
||||
|
||||
---
|
||||
|
||||
## ۱. مفاهیم کلیدی
|
||||
|
||||
### ۱.۱ کیف پولهای سهگانه
|
||||
هر کاربر سه نوع کیف پول دارد:
|
||||
|
||||
1. **کیف پول اصلی (Balance)**: برای خرید از فروشگاه عمومی بازار
|
||||
2. **کیف پول تخفیف (DiscountBalance)**: فقط برای خرید از فروشگاه باشگاه مشتریان (محدود به درصد تخفیف محصولات)
|
||||
3. **کیف پول طلایی/کارمزد (NetworkBalance)**: دریافتی از کمیسیون شبکهای - قابل برداشت نقدی یا خرید الماس از دایا
|
||||
|
||||
### ۱.۲ فعالسازی عضویت
|
||||
- کاربر ۵۶ میلیون تومان پرداخت میکند (از طریق دایا یا درگاه)
|
||||
- سیستم به صورت خودکار:
|
||||
- `Balance += 56M` (کیف پول اصلی)
|
||||
- `DiscountBalance += 56M` (کیف پول تخفیف)
|
||||
- کاربر دکمه «عضویت در باشگاه» را میزند:
|
||||
- `25M` به استخر کمیسیون هفتگی اضافه میشود
|
||||
- کاربر در شبکه باینری (Binary Tree) قرار میگیرد
|
||||
|
||||
### ۱.۳ شبکه باینری (Binary MLM Plan)
|
||||
- هر کاربر حداکثر دو زیرمجموعه دارد: **دست راست** و **دست چپ**
|
||||
- تعادل (Balance): زمانی که هر دو شاخه دارای اعضای جدید شوند، یک تعادل ایجاد میشود
|
||||
- **فرمول تعادل**: `UserBalances = MIN(LeftLegBalances, RightLegBalances)`
|
||||
- تعادلها به صورت هفتگی محاسبه و بعد از توزیع کمیسیون، ریست میشوند
|
||||
|
||||
### ۱.۴ محاسبه کمیسیون هفتگی
|
||||
```text
|
||||
مبلغ ریالی هر امتیاز = (مجموع مبالغ استخر) ÷ (مجموع تعادلهای کل سیستم)
|
||||
کمیسیون هر کاربر = (تعداد تعادل کاربر) × (مبلغ ریالی هر امتیاز)
|
||||
```
|
||||
|
||||
**مثال**:
|
||||
- کاربر A: خودش ۱ تعادل + زیرمجموعههایش ۲ تعادل = **۳ امتیاز**
|
||||
- استخر هفتگی: `175M`
|
||||
- مجموع امتیازهای سیستم: `5`
|
||||
- ارزش هر امتیاز: `175M ÷ 5 = 35M`
|
||||
- کمیسیون کاربر A: `3 × 35M = 105M`
|
||||
|
||||
---
|
||||
|
||||
## ۲. موجودیتهای جدید (Domain Entities)
|
||||
|
||||
### ۲.۱ `ClubMembership` (عضویت باشگاه مشتریان)
|
||||
```csharp
|
||||
public class ClubMembership : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public virtual User User { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime? ActivatedAt { get; set; }
|
||||
|
||||
// مبلغ اولیه پرداختی برای فعالسازی (معمولاً ۲۵ میلیون)
|
||||
public long InitialContribution { get; set; }
|
||||
|
||||
// مجموع درآمد کارمزد تاکنون
|
||||
public long TotalEarned { get; set; }
|
||||
|
||||
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۲.۲ `ClubFeature` (امکانات باشگاه)
|
||||
```csharp
|
||||
public class ClubFeature : BaseAuditableEntity
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public int? RequiredPoints { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۲.۳ `UserClubFeature` (امتیاز/فیچرهای فعال برای کاربر)
|
||||
```csharp
|
||||
public class UserClubFeature : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public virtual User User { get; set; }
|
||||
|
||||
public long ClubFeatureId { get; set; }
|
||||
public virtual ClubFeature ClubFeature { get; set; }
|
||||
|
||||
public DateTime GrantedAt { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۲.۴ `NetworkWeeklyBalance` (تعادل هفتگی شبکه)
|
||||
```csharp
|
||||
public class NetworkWeeklyBalance : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public virtual User User { get; set; }
|
||||
|
||||
// مثلاً "2025-W48"
|
||||
public string WeekNumber { get; set; }
|
||||
|
||||
public int LeftLegBalances { get; set; }
|
||||
public int RightLegBalances { get; set; }
|
||||
public int TotalBalances { get; set; }
|
||||
|
||||
// مبلغی که این کاربر همان هفته به استخر اضافه کرده (معمولاً InitialContribution)
|
||||
public long WeeklyPoolContribution { get; set; }
|
||||
|
||||
public DateTime? CalculatedAt { get; set; }
|
||||
public bool IsExpired { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۲.۵ `WeeklyCommissionPool` (استخر کمیسیون هفتگی)
|
||||
```csharp
|
||||
public class WeeklyCommissionPool : BaseAuditableEntity
|
||||
{
|
||||
public string WeekNumber { get; set; }
|
||||
|
||||
public long TotalPoolAmount { get; set; }
|
||||
public int TotalBalances { get; set; }
|
||||
public long ValuePerBalance { get; set; }
|
||||
|
||||
public bool IsCalculated { get; set; }
|
||||
public DateTime? CalculatedAt { get; set; }
|
||||
|
||||
public virtual ICollection<UserCommissionPayout> UserCommissionPayouts { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۲.۶ `UserCommissionPayout` (پرداخت کمیسیون به کاربر)
|
||||
```csharp
|
||||
public class UserCommissionPayout : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public virtual User User { get; set; }
|
||||
|
||||
public string WeekNumber { get; set; }
|
||||
|
||||
public long WeeklyPoolId { get; set; }
|
||||
public virtual WeeklyCommissionPool WeeklyPool { get; set; }
|
||||
|
||||
public int BalancesEarned { get; set; }
|
||||
public long ValuePerBalance { get; set; }
|
||||
public long TotalAmount { get; set; }
|
||||
|
||||
public CommissionPayoutStatus Status { get; set; }
|
||||
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
public WithdrawalMethod? WithdrawalMethod { get; set; }
|
||||
public string? IbanNumber { get; set; }
|
||||
public DateTime? WithdrawnAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۲.۷ موجودیتهای History (جداول لاگ)
|
||||
|
||||
#### ۲.۷.۱ `ClubMembershipHistory`
|
||||
لاگ تغییرات مهم روی عضویت باشگاه (فعالسازی، غیرفعالسازی، ویرایش):
|
||||
|
||||
```csharp
|
||||
public class ClubMembershipHistory : BaseAuditableEntity
|
||||
{
|
||||
public long ClubMembershipId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
|
||||
public bool OldIsActive { get; set; }
|
||||
public bool NewIsActive { get; set; }
|
||||
|
||||
public long? OldInitialContribution { get; set; }
|
||||
public long? NewInitialContribution { get; set; }
|
||||
|
||||
// Activated / Deactivated / Updated / ManualFix
|
||||
public string Action { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### ۲.۷.۲ `NetworkMembershipHistory`
|
||||
برای اینکه همیشه بدانیم «چه کسی زیرمجموعهی کی شده، چه زمانی، و اگر بعداً جابهجا شد چه اتفاقی افتاده»:
|
||||
|
||||
```csharp
|
||||
public class NetworkMembershipHistory : BaseAuditableEntity
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
|
||||
public long? OldParentId { get; set; }
|
||||
public long? NewParentId { get; set; }
|
||||
|
||||
public NetworkLeg? OldLegPosition { get; set; }
|
||||
public NetworkLeg? NewLegPosition { get; set; }
|
||||
|
||||
// Join / Move / Remove
|
||||
public string Action { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- هر بار `RecordNetworkJoin` یا `UpdateNetworkPosition` صدا زده میشود، باید یک رکورد در این جدول نوشته شود.
|
||||
- این جدول مرجع اصلی برای بازسازی درخت شبکه در زمانهای گذشته است.
|
||||
|
||||
#### ۲.۷.۳ `CommissionPayoutHistory`
|
||||
برای لاگ کامل همهی تغییرات روی پرداخت کمیسیونها (ایجاد، ویرایش دستی، تغییر وضعیت، برداشت و ...):
|
||||
|
||||
```csharp
|
||||
public class CommissionPayoutHistory : BaseAuditableEntity
|
||||
{
|
||||
public long UserCommissionPayoutId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string WeekNumber { get; set; }
|
||||
|
||||
public long AmountBefore { get; set; }
|
||||
public long AmountAfter { get; set; }
|
||||
|
||||
public CommissionPayoutStatus OldStatus { get; set; }
|
||||
public CommissionPayoutStatus NewStatus { get; set; }
|
||||
|
||||
// Created / Paid / WithdrawRequested / Withdrawn / Cancelled / ManualFix
|
||||
public string Action { get; set; }
|
||||
public string? PerformedBy { get; set; } // UserId یا System
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- اگر بعداً بفهمیم یک پرداخت اشتباه بوده و اصلاحش کنیم، اینجا قابل ردیابی است.
|
||||
- برای گزارشگیری Audit کامل پرداختها، این جدول استفاده میشود.
|
||||
|
||||
#### ۲.۷.۴ `SystemConfigurationHistory`
|
||||
تاریخچه تغییرات تنظیمات (Config) برای اینکه بعداً بدانیم در هر زمان چه محدودیتی فعال بوده:
|
||||
|
||||
```csharp
|
||||
public class SystemConfigurationHistory : BaseAuditableEntity
|
||||
{
|
||||
public long ConfigurationId { get; set; }
|
||||
|
||||
public ConfigurationScope Scope { get; set; }
|
||||
public string Key { get; set; }
|
||||
|
||||
public string OldValue { get; set; }
|
||||
public string NewValue { get; set; }
|
||||
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۲.۸ موجودیتهای Configuration (تنظیمات پویا)
|
||||
|
||||
#### ۲.۸.۱ `ConfigurationScope` (Enum)
|
||||
```csharp
|
||||
public enum ConfigurationScope
|
||||
{
|
||||
System = 0,
|
||||
Network = 1,
|
||||
Club = 2,
|
||||
Commission = 3
|
||||
}
|
||||
```
|
||||
|
||||
#### ۲.۸.۲ `SystemConfiguration`
|
||||
جدولی برای نگهداری تنظیمات پویا. هم تنظیمات عمومی سیستم، هم تنظیمات مخصوص شبکه، باشگاه و کمیسیون:
|
||||
|
||||
```csharp
|
||||
public class SystemConfiguration : BaseAuditableEntity
|
||||
{
|
||||
public ConfigurationScope Scope { get; set; } // System / Network / Club / Commission
|
||||
|
||||
// مثل: "MaxWeeklyBalancesPerUser", "MinContributionAmount", ...
|
||||
public string Key { get; set; }
|
||||
|
||||
// مقدار بهصورت رشته - تفسیر در لایه Application
|
||||
public string Value { get; set; }
|
||||
|
||||
// برای UI و Validation (Int / Decimal / Bool / String / Json)
|
||||
public string? DataType { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
**مثال کانفیگهای مرتبط با شبکه:**
|
||||
|
||||
- `Scope = Network`, `Key = "MaxWeeklyBalancesPerUser"`, `Value = "300"`
|
||||
- `Scope = Network`, `Key = "MaxChildrenPerLeg"`, `Value = "1"`
|
||||
- `Scope = Commission`, `Key = "DefaultInitialContribution"`, `Value = "25000000"`
|
||||
|
||||
> نکته: هر بار که مقدار `SystemConfiguration` تغییر میکند، یک رکورد در `SystemConfigurationHistory` ثبت میشود تا تنظیمات گذشته قابل ردیابی باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۲.۹ Enums جدید
|
||||
```csharp
|
||||
public enum CommissionPayoutStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Paid = 1,
|
||||
WithdrawRequested = 2,
|
||||
Withdrawn = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
public enum WithdrawalMethod
|
||||
{
|
||||
Cash = 0,
|
||||
Diamond = 1
|
||||
}
|
||||
|
||||
public enum NetworkLeg
|
||||
{
|
||||
Left = 0,
|
||||
Right = 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۳. تغییرات در موجودیتهای موجود
|
||||
|
||||
### ۳.۱ `User`
|
||||
افزودن فیلدهای مربوط به شبکه باینری و ناوبری:
|
||||
|
||||
```csharp
|
||||
public class User : BaseAuditableEntity
|
||||
{
|
||||
// ...
|
||||
|
||||
public long? NetworkParentId { get; set; }
|
||||
public virtual User? NetworkParent { get; set; }
|
||||
|
||||
public NetworkLeg? LegPosition { get; set; }
|
||||
|
||||
public virtual ICollection<User> NetworkChildren { get; set; }
|
||||
|
||||
public virtual ClubMembership? ClubMembership { get; set; }
|
||||
public virtual ICollection<NetworkWeeklyBalance> NetworkWeeklyBalances { get; set; }
|
||||
public virtual ICollection<UserCommissionPayout> CommissionPayouts { get; set; }
|
||||
|
||||
public virtual ICollection<UserClubFeature> UserClubFeatures { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### ۳.۲ `UserWallet`
|
||||
```csharp
|
||||
public class UserWallet : BaseAuditableEntity
|
||||
{
|
||||
// موجودی ریالی اصلی
|
||||
public long Balance { get; set; }
|
||||
|
||||
// موجودی شبکه/کارمزد (کیف پول طلایی)
|
||||
public long NetworkBalance { get; set; }
|
||||
|
||||
// موجودی تخفیف (فقط برای خرید از فروشگاه باشگاه)
|
||||
public long DiscountBalance { get; set; }
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### ۳.۳ `Products`
|
||||
```csharp
|
||||
public class Product : BaseAuditableEntity
|
||||
{
|
||||
// ...
|
||||
|
||||
// آیا این محصول فقط در فروشگاه باشگاه موجود است
|
||||
public bool IsClubExclusive { get; set; }
|
||||
|
||||
// درصد تخفیف باشگاه (0 تا 100)
|
||||
public int ClubDiscountPercent { get; set; }
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### ۳.۴ `UserWalletChangeLog`
|
||||
افزودن نوع جدید تراکنش:
|
||||
```csharp
|
||||
public enum TransactionType
|
||||
{
|
||||
// ...
|
||||
|
||||
NetworkCommission = 10, // دریافت کمیسیون شبکه
|
||||
ClubActivation = 11, // فعالسازی عضویت باشگاه
|
||||
DiscountWalletCharge = 12, // شارژ کیف پول تخفیف
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۴. معماری ماژولهای جدید (Application / CQRS)
|
||||
|
||||
### ۴.۱ `ClubMembershipCQ/`
|
||||
#### Commands
|
||||
- **ActivateClubMembership**: فعالسازی عضویت باشگاه (کسر ۲۵ میلیون و اضافه به استخر)
|
||||
- **DeactivateClubMembership**: غیرفعالسازی عضویت
|
||||
- **UpdateClubMembership**: بهروزرسانی اطلاعات عضویت
|
||||
|
||||
#### Queries
|
||||
- **GetUserClubStatus**: دریافت وضعیت عضویت کاربر
|
||||
- **GetAllClubMembersByFilter**: لیست اعضای باشگاه با فیلتر
|
||||
|
||||
### ۴.۲ `ClubFeatureCQ/`
|
||||
#### Commands
|
||||
- **CreateClubFeature**: ایجاد فیچر جدید
|
||||
- **UpdateClubFeature**: ویرایش فیچر
|
||||
- **DeleteClubFeature**: حذف فیچر
|
||||
- **GrantFeatureToUser**: فعالسازی فیچر برای کاربر
|
||||
- **RevokeFeatureFromUser**: غیرفعالسازی فیچر از کاربر
|
||||
|
||||
#### Queries
|
||||
- **GetAllClubFeatures**: لیست تمام فیچرها
|
||||
- **GetUserClubFeatures**: لیست فیچرهای فعال یک کاربر
|
||||
|
||||
### ۴.۳ `NetworkBalanceCQ/`
|
||||
#### Commands
|
||||
- **RecordNetworkJoin**: ثبت ورود کاربر به شبکه باینری (تعیین والد و شاخه)
|
||||
- حتماً باید یک رکورد در `NetworkMembershipHistory` ایجاد کند.
|
||||
- **UpdateNetworkPosition**: تغییر موقعیت در شبکه (مدیریتی)
|
||||
- هر تغییر، یک رکورد History.
|
||||
- **CalculateWeeklyBalances**: محاسبه تعادلهای هفتگی (فراخوانی از Worker)
|
||||
|
||||
#### Queries
|
||||
- **GetUserNetworkTree**: دریافت درخت زیرمجموعههای کاربر (چند سطح)
|
||||
- **GetUserWeeklyBalances**: دریافت تعادلهای هفتگی یک کاربر
|
||||
- **GetNetworkStatistics**: آمار کلی شبکه (تعداد اعضا، عمق، تعادل)
|
||||
|
||||
### ۴.۴ `CommissionPoolCQ/`
|
||||
#### Commands
|
||||
- **InitializeWeeklyPool**: ایجاد استخر جدید برای هفته
|
||||
- **AddToWeeklyPool**: افزودن مبلغ به استخر هفتگی (هنگام فعالسازی عضویت)
|
||||
- **CalculatePoolValue**: محاسبه ارزش هر امتیاز
|
||||
- **DistributeCommissions**: توزیع کمیسیونها به کاربران (Worker)
|
||||
- **CloseWeeklyPool**: بستن استخر پس از توزیع
|
||||
|
||||
#### Queries
|
||||
- **GetCurrentWeekPool**: دریافت اطلاعات استخر هفته جاری
|
||||
- **GetPoolHistory**: تاریخچه استخرهای قبلی با فیلتر
|
||||
|
||||
### ۴.۵ `CommissionPayoutCQ/`
|
||||
#### Commands
|
||||
- **CreatePayoutRecord**: ثبت پرداخت کمیسیون (اتوماتیک از Worker)
|
||||
- همراه با ایجاد رکورد در `CommissionPayoutHistory` (Action = Created).
|
||||
- **RequestWithdrawal**: درخواست برداشت کمیسیون (نقدی یا الماس)
|
||||
- History با Action = WithdrawRequested.
|
||||
- **ProcessWithdrawal**: پردازش درخواست برداشت (تایید/رد ادمین)
|
||||
- تغییر Status + History.
|
||||
- **CancelPayout**: لغو پرداخت
|
||||
|
||||
#### Queries
|
||||
- **GetUserCommissionHistory**: تاریخچه کمیسیونهای دریافتی کاربر
|
||||
- **GetPendingWithdrawals**: لیست درخواستهای برداشت در انتظار (برای ادمین)
|
||||
- **GetCommissionSummary**: خلاصه درآمد کمیسیون (مجموع، ماهانه، سالانه)
|
||||
|
||||
### ۴.۶ `ConfigurationCQ/`
|
||||
#### Commands
|
||||
- **SetConfigurationValue**: ثبت/ویرایش یک تنظیم (SystemConfiguration)
|
||||
- هر تغییر باید در `SystemConfigurationHistory` ثبت شود.
|
||||
- **DeactivateConfiguration**: غیرفعالسازی یک تنظیم
|
||||
|
||||
#### Queries
|
||||
- **GetConfigurationValue**: دریافت مقدار یک Key
|
||||
- **GetConfigurationByScope**: لیست تنظیمات یک Scope (مثلاً Network)
|
||||
|
||||
---
|
||||
|
||||
## ۵. Background Worker/Job (محاسبات هفتگی)
|
||||
|
||||
### ۵.۱ `WeeklyNetworkCommissionWorker`
|
||||
**زمانبندی**: هر یکشنبه ساعت ۲۳:۵۹ (یا دوشنبه ۰۰:۰۱)
|
||||
|
||||
**مراحل اجرایی (High-level):**
|
||||
|
||||
#### گام ۱: بستن هفته قبل و ایجاد استخر جدید
|
||||
```csharp
|
||||
var currentWeek = GetCurrentWeekNumber(); // مثلاً "2025-W48"
|
||||
var previousWeek = GetPreviousWeekNumber();
|
||||
|
||||
await CloseWeeklyPool(previousWeek);
|
||||
await InitializeWeeklyPool(currentWeek);
|
||||
```
|
||||
|
||||
#### گام ۲: محاسبه تعادلهای شبکه
|
||||
```csharp
|
||||
var maxBalancesPerUser = GetConfig<int>("MaxWeeklyBalancesPerUser", scope: ConfigurationScope.Network);
|
||||
|
||||
var activeMembers = await GetActiveClubMembers();
|
||||
|
||||
foreach (var member in activeMembers)
|
||||
{
|
||||
var leftBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Left, previousWeek);
|
||||
var rightBalances = await CalculateLegBalances(member.UserId, NetworkLeg.Right, previousWeek);
|
||||
|
||||
var totalBalances = Math.Min(leftBalances, rightBalances);
|
||||
|
||||
// اعمال محدودیت کانفیگ (مثلاً حداکثر 300 تعادل برای هر کاربر)
|
||||
if (totalBalances > maxBalancesPerUser)
|
||||
totalBalances = maxBalancesPerUser;
|
||||
|
||||
await RecordWeeklyBalance(new NetworkWeeklyBalance {
|
||||
UserId = member.UserId,
|
||||
WeekNumber = previousWeek,
|
||||
LeftLegBalances = leftBalances,
|
||||
RightLegBalances = rightBalances,
|
||||
TotalBalances = totalBalances,
|
||||
WeeklyPoolContribution = member.InitialContribution,
|
||||
CalculatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### الگوریتم بازگشتی محاسبه تعادل شاخه
|
||||
```csharp
|
||||
private async Task<int> CalculateLegBalances(long userId, NetworkLeg leg, string weekNumber)
|
||||
{
|
||||
var children = await GetNetworkChildren(userId, leg);
|
||||
int totalBalances = 0;
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
var childMembership = await GetClubMembership(child.Id);
|
||||
if (childMembership != null && IsInWeek(childMembership.ActivatedAt, weekNumber))
|
||||
{
|
||||
totalBalances++;
|
||||
}
|
||||
|
||||
var childLeftBalances = await CalculateLegBalances(child.Id, NetworkLeg.Left, weekNumber);
|
||||
var childRightBalances = await CalculateLegBalances(child.Id, NetworkLeg.Right, weekNumber);
|
||||
|
||||
totalBalances += Math.Min(childLeftBalances, childRightBalances);
|
||||
}
|
||||
|
||||
return totalBalances;
|
||||
}
|
||||
```
|
||||
|
||||
#### گام ۳: محاسبه استخر و ارزش امتیاز
|
||||
```csharp
|
||||
var totalPoolAmount = await SumPoolContributions(previousWeek);
|
||||
var totalBalances = await SumTotalBalances(previousWeek);
|
||||
|
||||
var valuePerBalance = totalBalances > 0 ? totalPoolAmount / totalBalances : 0;
|
||||
|
||||
await UpdatePoolValue(previousWeek, totalPoolAmount, totalBalances, valuePerBalance);
|
||||
```
|
||||
|
||||
#### گام ۴: توزیع کمیسیونها
|
||||
```csharp
|
||||
var weeklyBalances = await GetWeeklyBalances(previousWeek);
|
||||
|
||||
foreach (var balance in weeklyBalances.Where(b => b.TotalBalances > 0))
|
||||
{
|
||||
var payoutAmount = balance.TotalBalances * valuePerBalance;
|
||||
|
||||
var payout = new UserCommissionPayout {
|
||||
UserId = balance.UserId,
|
||||
WeekNumber = previousWeek,
|
||||
BalancesEarned = balance.TotalBalances,
|
||||
ValuePerBalance = valuePerBalance,
|
||||
TotalAmount = payoutAmount,
|
||||
Status = CommissionPayoutStatus.Pending
|
||||
};
|
||||
await CreatePayoutRecord(payout); // داخلش CommissionPayoutHistory هم ثبت میشود
|
||||
|
||||
await AddToNetworkBalance(balance.UserId, payoutAmount);
|
||||
|
||||
await RecordWalletChange(new UserWalletChangeLog {
|
||||
WalletId = balance.UserId,
|
||||
// PreviousBalance / AfterBalance پر میشود
|
||||
Amount = payoutAmount,
|
||||
TransactionType = TransactionType.NetworkCommission,
|
||||
ReferenceId = payout.Id.ToString()
|
||||
});
|
||||
|
||||
payout.Status = CommissionPayoutStatus.Paid;
|
||||
payout.PaidAt = DateTime.UtcNow;
|
||||
await UpdatePayout(payout);
|
||||
|
||||
await AddCommissionHistory(payout, "Paid");
|
||||
}
|
||||
```
|
||||
|
||||
#### گام ۵: ریست تعادلها
|
||||
```csharp
|
||||
await ExpireWeeklyBalances(previousWeek);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۶. لاجیک فروشگاه و سبد خرید
|
||||
|
||||
### ۶.۱ نمایش محصولات
|
||||
```csharp
|
||||
var query = _context.Products.Where(p => !p.IsDeleted);
|
||||
|
||||
if (!user.ClubMembership?.IsActive ?? true)
|
||||
{
|
||||
query = query.Where(p => !p.IsClubExclusive);
|
||||
}
|
||||
|
||||
// اگر کاربر عضو است، قیمت با تخفیف باشگاه محاسبه میشود
|
||||
```
|
||||
|
||||
### ۶.۲ استفاده از کیف پول تخفیف در Checkout
|
||||
(خلاصهسازی شده – در کد اصلی از DiscountBalance استفاده میشود و ChangeLog ثبت میگردد.)
|
||||
|
||||
---
|
||||
|
||||
## ۷. سناریوی کامل فعالسازی عضویت
|
||||
|
||||
### مرحله ۱: شارژ اولیه
|
||||
```text
|
||||
کاربر → پرداخت ۵۶ میلیون (دایا/درگاه)
|
||||
↓
|
||||
UserWallet.Balance += 56,000,000
|
||||
UserWallet.DiscountBalance += 56,000,000
|
||||
```
|
||||
|
||||
### مرحله ۲: فعالسازی عضویت
|
||||
```text
|
||||
کاربر → کلیک روی دکمه «عضویت در باشگاه»
|
||||
↓
|
||||
API: ActivateClubMembership
|
||||
↓
|
||||
1. ایجاد رکورد ClubMembership:
|
||||
- IsActive = true
|
||||
- InitialContribution = 25,000,000
|
||||
|
||||
2. افزودن به استخر هفتگی:
|
||||
- WeeklyCommissionPool.TotalPoolAmount += 25,000,000
|
||||
|
||||
3. تعیین موقعیت در شبکه:
|
||||
- User.NetworkParentId = والد
|
||||
- User.LegPosition = Left یا Right
|
||||
|
||||
4. ثبت ChangeLog برای استخر:
|
||||
- TransactionType = ClubActivation
|
||||
|
||||
5. ثبت ClubMembershipHistory:
|
||||
- Action = "Activated"
|
||||
```
|
||||
|
||||
### مرحله ۳: محاسبه هفتگی (Worker)
|
||||
(مطابق بخش ۵)
|
||||
|
||||
### مرحله ۴: برداشت کمیسیون
|
||||
```text
|
||||
کاربر → درخواست برداشت
|
||||
↓
|
||||
API: RequestWithdrawal (Cash یا Diamond)
|
||||
↓
|
||||
ادمین → تایید درخواست
|
||||
↓
|
||||
1. اگر Cash:
|
||||
- واریز به حساب بانکی
|
||||
- NetworkBalance -= مبلغ
|
||||
|
||||
2. اگر Diamond:
|
||||
- خرید الماس از دایا
|
||||
- NetworkBalance -= مبلغ
|
||||
```
|
||||
|
||||
همراه با ثبت رکورد در `CommissionPayoutHistory` (Action = WithdrawRequested / Withdrawn).
|
||||
|
||||
---
|
||||
|
||||
## ۸. پروتوباف و gRPC Services
|
||||
|
||||
### ۸.۱ `clubmembership.proto`
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
package clubmembership;
|
||||
|
||||
service ClubMembershipService {
|
||||
rpc ActivateMembership (ActivateMembershipRequest) returns (ActivateMembershipResponse);
|
||||
rpc GetClubStatus (GetClubStatusRequest) returns (GetClubStatusResponse);
|
||||
rpc GrantFeature (GrantFeatureRequest) returns (GrantFeatureResponse);
|
||||
rpc GetUserFeatures (GetUserFeaturesRequest) returns (GetUserFeaturesResponse);
|
||||
}
|
||||
|
||||
message ActivateMembershipRequest {
|
||||
int64 user_id = 1;
|
||||
int64 contribution_amount = 2;
|
||||
int64 network_parent_id = 3;
|
||||
NetworkLeg leg_position = 4;
|
||||
}
|
||||
|
||||
message ActivateMembershipResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
ClubMembershipDto membership = 3;
|
||||
}
|
||||
|
||||
message GetClubStatusRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message GetClubStatusResponse {
|
||||
bool is_member = 1;
|
||||
ClubMembershipDto membership = 2;
|
||||
}
|
||||
|
||||
message ClubMembershipDto {
|
||||
int64 id = 1;
|
||||
int64 user_id = 2;
|
||||
bool is_active = 3;
|
||||
google.protobuf.Timestamp activated_at = 4;
|
||||
int64 initial_contribution = 5;
|
||||
int64 total_earned = 6;
|
||||
}
|
||||
|
||||
enum NetworkLeg {
|
||||
LEFT = 0;
|
||||
RIGHT = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### ۸.۲ `networkbalance.proto`
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
|
||||
package networkbalance;
|
||||
|
||||
service NetworkBalanceService {
|
||||
rpc GetNetworkTree (GetNetworkTreeRequest) returns (GetNetworkTreeResponse);
|
||||
rpc GetWeeklyBalances (GetWeeklyBalancesRequest) returns (GetWeeklyBalancesResponse);
|
||||
rpc GetNetworkStats (GetNetworkStatsRequest) returns (GetNetworkStatsResponse);
|
||||
}
|
||||
|
||||
message GetNetworkTreeRequest {
|
||||
int64 user_id = 1;
|
||||
int32 max_depth = 2;
|
||||
}
|
||||
|
||||
message GetNetworkTreeResponse {
|
||||
NetworkNodeDto root = 1;
|
||||
}
|
||||
|
||||
message NetworkNodeDto {
|
||||
int64 user_id = 1;
|
||||
string full_name = 2;
|
||||
NetworkLeg leg_position = 3;
|
||||
bool is_active = 4;
|
||||
repeated NetworkNodeDto children = 5;
|
||||
}
|
||||
|
||||
message GetWeeklyBalancesRequest {
|
||||
int64 user_id = 1;
|
||||
string week_number = 2;
|
||||
}
|
||||
|
||||
message GetWeeklyBalancesResponse {
|
||||
int32 left_leg_balances = 1;
|
||||
int32 right_leg_balances = 2;
|
||||
int32 total_balances = 3;
|
||||
int64 pool_contribution = 4;
|
||||
}
|
||||
```
|
||||
|
||||
### ۸.۳ `commissionpayout.proto`
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
package commissionpayout;
|
||||
|
||||
service CommissionPayoutService {
|
||||
rpc RequestWithdrawal (RequestWithdrawalRequest) returns (RequestWithdrawalResponse);
|
||||
rpc GetCommissionHistory (GetCommissionHistoryRequest) returns (GetCommissionHistoryResponse);
|
||||
rpc GetPendingWithdrawals (GetPendingWithdrawalsRequest) returns (GetPendingWithdrawalsResponse);
|
||||
rpc ProcessWithdrawal (ProcessWithdrawalRequest) returns (ProcessWithdrawalResponse);
|
||||
}
|
||||
|
||||
message RequestWithdrawalRequest {
|
||||
int64 user_id = 1;
|
||||
int64 amount = 2;
|
||||
WithdrawalMethod method = 3;
|
||||
string iban_number = 4;
|
||||
}
|
||||
|
||||
message RequestWithdrawalResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 request_id = 3;
|
||||
}
|
||||
|
||||
message GetCommissionHistoryRequest {
|
||||
int64 user_id = 1;
|
||||
int32 page_number = 2;
|
||||
int32 page_size = 3;
|
||||
}
|
||||
|
||||
message GetCommissionHistoryResponse {
|
||||
repeated CommissionPayoutDto payouts = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
message CommissionPayoutDto {
|
||||
int64 id = 1;
|
||||
string week_number = 2;
|
||||
int32 balances_earned = 3;
|
||||
int64 value_per_balance = 4;
|
||||
int64 total_amount = 5;
|
||||
CommissionPayoutStatus status = 6;
|
||||
google.protobuf.Timestamp paid_at = 7;
|
||||
WithdrawalMethod withdrawal_method = 8;
|
||||
}
|
||||
|
||||
enum WithdrawalMethod {
|
||||
CASH = 0;
|
||||
DIAMOND = 1;
|
||||
}
|
||||
|
||||
enum CommissionPayoutStatus {
|
||||
PENDING = 0;
|
||||
PAID = 1;
|
||||
WITHDRAW_REQUESTED = 2;
|
||||
WITHDRAWN = 3;
|
||||
CANCELLED = 4;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۹. نکات حیاتی و بهترین رویهها
|
||||
|
||||
### ۹.۱ یکپارچگی شبکه باینری
|
||||
- هر کاربر حداکثر دو فرزند (یکی Left، یکی Right)
|
||||
- هنگام اضافه کردن فرزند، کنترل Race Condition
|
||||
- حذف کاربر نباید ساختار شبکه را خراب کند
|
||||
|
||||
### ۹.۲ Transaction Management
|
||||
- Worker باید تمام مراحل را در یک TransactionScope انجام دهد
|
||||
- در صورت شکست، Rollback کامل
|
||||
|
||||
### ۹.۳ Idempotency
|
||||
- محاسبه هفتگی برای یک WeekNumber فقط یکبار
|
||||
- بررسی `WeeklyCommissionPool.IsCalculated` قبل از شروع
|
||||
|
||||
### ۹.۴ Performance
|
||||
- Caching درخت شبکه برای کاربران پرحجم
|
||||
- Index روی `WeekNumber`, `UserId`, `NetworkParentId`
|
||||
|
||||
### ۹.۵ Audit و Compliance
|
||||
- همه تغییرات کیف پول در `UserWalletChangeLog`
|
||||
- همه پرداختهای کمیسیون در `UserCommissionPayout` + `CommissionPayoutHistory`
|
||||
- تغییرات شبکه در `NetworkMembershipHistory`
|
||||
- تغییرات تنظیمات در `SystemConfigurationHistory`
|
||||
|
||||
### ۹.۶ Security
|
||||
- محدودیت تعداد درخواست برداشت
|
||||
- تایید دو مرحلهای برای برداشتهای بالا
|
||||
- Audit Log برای عملیات حساس
|
||||
|
||||
---
|
||||
|
||||
## ۱۰. مراحل پیادهسازی (Roadmap)
|
||||
(مطابق نسخه قبلی – فاز ۱ تا ۶)
|
||||
|
||||
---
|
||||
|
||||
## ۱۱. متریکهای کلیدی (KPIs)
|
||||
- تعداد اعضای فعال باشگاه
|
||||
- مجموع کمیسیونهای پرداختی هر ماه
|
||||
- میانگین تعادل هر کاربر در هفته
|
||||
- نرخ تبدیل به عضویت باشگاه
|
||||
- زمان اجرای Worker، تعداد خطاها، عمق درخت، حجم داده History و …
|
||||
|
||||
---
|
||||
|
||||
## ۱۲. سوالات متداول (FAQ)
|
||||
(همان سوالات قبلی + میتوان سوالات مربوط به سقف تعادل و تنظیمات را اضافه کرد.)
|
||||
|
||||
---
|
||||
|
||||
## ۱۳. ضمیمه: مثال عددی کامل
|
||||
(مثال دو هفتهای A, B, C, D, E, F, G مثل نسخه قبلی.)
|
||||
|
||||
---
|
||||
|
||||
## ۱۴. مسیرهای مرتبط
|
||||
- Domain: `CMS/src/CMSMicroservice.Domain/Entities/`
|
||||
- Application: `CMS/src/CMSMicroservice.Application/ClubMembershipCQ/`, `NetworkBalanceCQ/`, `CommissionPoolCQ/`, `CommissionPayoutCQ/`, `ConfigurationCQ/`
|
||||
- Protobuf: `CMS/src/CMSMicroservice.Protobuf/Protos/`
|
||||
- Worker: `CMS/src/CMSMicroservice.Infrastructure/BackgroundJobs/`
|
||||
- مستند حاضر: `CMS/docs/network-club-commission-system.md`
|
||||
|
||||
**نسخه**: 1.1
|
||||
**تاریخ**: 2025-11-29
|
||||
**نویسنده**: تیم توسعه CMS
|
||||
**وضعیت**: آماده پیادهسازی (با History و Config)
|
||||
@@ -0,0 +1,329 @@
|
||||
# توضیحات جدید بیزینس - 2025-12-08
|
||||
|
||||
**تاریخ دریافت**: 2025-12-08
|
||||
**وضعیت**: نیاز به تطبیق با کد و داکیومنت موجود
|
||||
**منبع**: توضیحات شفاهی از صاحب پروژه
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ فعالسازی کاربر و نمایش لینک معرفی
|
||||
|
||||
### قوانین فعالسازی:
|
||||
کاربر زمانی میتواند **لینک معرفی** خود را ببیند که:
|
||||
- ✅ وام خود را از **دایا** گرفته باشه
|
||||
- ✅ یا **پرداخت مستقیم 56 میلیون تومان** انجام داده باشه
|
||||
|
||||
### عضویت باشگاه مشتریان (الزامی):
|
||||
در هر دو حالت بالا:
|
||||
1. کاربر **اجباراً** باید عضو باشگاه مشتریان بشه
|
||||
2. دیالوگ باشگاه مشتریان و امضای قرارداد **الزامی** است
|
||||
3. **تا زمانی که این کار انجام نشه** → لینک معرفی نمایش داده نمیشود
|
||||
|
||||
### فرآیند:
|
||||
```
|
||||
کاربر ثبت نام میکنه
|
||||
↓
|
||||
پرداخت 56M (دایا یا مستقیم)
|
||||
↓
|
||||
دیالوگ باشگاه مشتریان (الزامی) ← امضای قرارداد
|
||||
↓
|
||||
لینک معرفی نمایش داده میشود
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ محاسبه تعادل (Balance) شبکه
|
||||
|
||||
### قانون اصلی:
|
||||
**هر نود شبکه = یک تعادل**
|
||||
|
||||
```
|
||||
تعداد تعادل = MIN(دست راست، دست چپ)
|
||||
```
|
||||
|
||||
### حالت عادی (زیر 300 تعادل):
|
||||
- اگر دست راست = 200 نفر و دست چپ = 150 نفر
|
||||
- ✅ تعادل = MIN(200, 150) = **150 امتیاز**
|
||||
- ✅ باقیمانده راست = 200 - 150 = **50** → برای هفته بعد
|
||||
|
||||
### حالت بالای 300 تعادل (سقف):
|
||||
اگر مجموع کاربران جفت دست یک نفر **بیشتر از 600 نفر** باشد:
|
||||
|
||||
#### مثال:
|
||||
```
|
||||
دست راست = 600 نفر
|
||||
دست چپ = 400 نفر
|
||||
```
|
||||
|
||||
**مرحله 1: محاسبه تعادل اولیه**
|
||||
- تعادل = MIN(600, 400) = 400
|
||||
|
||||
**مرحله 2: محاسبه باقیمانده اولیه**
|
||||
- باقیمانده راست = 600 - 400 = 200 → **میرود برای هفته بعد**
|
||||
|
||||
**مرحله 3: اعمال سقف 300**
|
||||
- چون تعادل (400) > 300 → فقط **300 امتیاز** حساب میشود
|
||||
- از دست راست: 100 نفر فلش میشود
|
||||
- از دست چپ: 100 نفر فلش میشود
|
||||
- **مجموع 200 نفر فلش میشود** (دیگه هیچ جا حساب نمیشن)
|
||||
|
||||
**نتیجه نهایی:**
|
||||
- امتیاز این هفته: **300**
|
||||
- باقیمانده راست برای هفته بعد: **200** (این مجزا از فلش است)
|
||||
- فلش شده (از بین رفته): **200** (100 چپ + 100 راست)
|
||||
|
||||
### نکته مهم:
|
||||
> باقیماندهای که از هفته قبل میآید **فلش نمیشود**، فقط اضافهای که بزرگتر از 300 تعادل است فلش میشود.
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ محاسبه تعادل بازگشتی (Recursive Balance)
|
||||
|
||||
### قانون مهم:
|
||||
**هر نفر تعداد تعادلهاش فقط برای خودش حساب میشه**
|
||||
|
||||
### مثال درخت:
|
||||
```
|
||||
کاربر 1
|
||||
/ \
|
||||
کاربر 2 کاربر 3
|
||||
/ \
|
||||
کاربر 4 کاربر 5
|
||||
```
|
||||
|
||||
### محاسبات:
|
||||
1. **کاربر 2**:
|
||||
- جذب کرده: کاربر 4 و کاربر 5
|
||||
- تعادل کاربر 2 = MIN(1, 1) = **1 تعادل**
|
||||
|
||||
2. **کاربر 1**:
|
||||
- دست راست: کاربر 2 = 1 نفر
|
||||
- دست چپ: کاربر 3 = 1 نفر
|
||||
- تعادل کاربر 1 = MIN(1, 1) = **1 تعادل**
|
||||
|
||||
### ⚠️ نکته کلیدی:
|
||||
**کاربر 1 پورسانت کاربر 4 و 5 را نمیگیرد!**
|
||||
|
||||
چرا؟ چون:
|
||||
- کاربر 3 کسی را جذب نکرده
|
||||
- برای اینکه کاربر 1 از تعادل کاربر 4 و 5 بهرهمند شود
|
||||
- کاربر 3 حتماً باید **دو نفر** جذب کند
|
||||
|
||||
### مثال تصحیح شده:
|
||||
```
|
||||
کاربر 1
|
||||
/ \
|
||||
کاربر 2 کاربر 3
|
||||
/ \ / \
|
||||
کاربر 4 5 کاربر 6 7
|
||||
```
|
||||
|
||||
حالا:
|
||||
- کاربر 3: تعادل = MIN(1, 1) = 1
|
||||
- کاربر 2: تعادل = MIN(1, 1) = 1
|
||||
- **کاربر 1**: تعادل = MIN(2, 2) = **2 تعادل** ✅
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ ارزش امتیاز و توزیع کمیسیون
|
||||
|
||||
### فرمول:
|
||||
```
|
||||
ارزش هر امتیاز = (مجموع مبلغ صندوق) ÷ (تعداد کل تعادلها)
|
||||
```
|
||||
|
||||
### مبلغ صندوق:
|
||||
هر کاربری که 56 میلیون تومان واریز میکند:
|
||||
- **25 میلیون تومان** وارد صندوق میشود
|
||||
|
||||
### مثال محاسبه:
|
||||
```
|
||||
صندوق هفته = 175 میلیون تومان (7 نفر × 25M)
|
||||
مجموع تعادلهای سیستم = 50 امتیاز
|
||||
|
||||
ارزش هر امتیاز = 175,000,000 ÷ 50 = 3,500,000 ریال
|
||||
```
|
||||
|
||||
اگر یک کاربر **5 تعادل** داشته باشد:
|
||||
```
|
||||
کمیسیون = 5 × 3,500,000 = 17,500,000 ریال
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ حذف خودکار کاربران غیرفعال (Worker جدید مورد نیاز)
|
||||
|
||||
### قانون:
|
||||
کاربری که تا **2 هفته** بعد از ثبت نام:
|
||||
- ❌ وام دایا را نگرفته
|
||||
- ❌ 56 میلیون تومان مستقیم واریز نکرده
|
||||
|
||||
→ **به صورت اتوماتیک حذف میشود**
|
||||
|
||||
### Worker مورد نیاز:
|
||||
```csharp
|
||||
// نام پیشنهادی: DeleteInactiveUsersWorker
|
||||
// زمان اجرا: روزانه یک بار (مثلاً 3 صبح)
|
||||
|
||||
شبهکد:
|
||||
1. کاربرانی که CreatedAt < (Now - 14 روز)
|
||||
2. IsActive == false (یعنی نه دایا گرفته، نه پرداخت مستقیم)
|
||||
3. ClubMembershipId == null
|
||||
4. حذف کاربر
|
||||
5. آزاد کردن جایگاه در شبکه برای معرف
|
||||
```
|
||||
|
||||
### هدف:
|
||||
- معرفی که این کاربر را جذب کرده بود، یکی از دستهایش آزاد میشود
|
||||
- میتواند **کاربر جدید** جذب کند
|
||||
- امکان **تعادل متعادل** دست چپ و راست فراهم میشود
|
||||
|
||||
---
|
||||
|
||||
## 6️⃣ محدودیت تعداد زیرمجموعه
|
||||
|
||||
### قانون سخت:
|
||||
**هر کاربر فقط 2 نفر میتواند جذب کند** (دست چپ + دست راست)
|
||||
|
||||
### سناریو خطا:
|
||||
```
|
||||
کاربر A: دو نفر زیرمجموعه فعال دارد
|
||||
کاربر B: با کد معرف کاربر A ثبت نام میکند
|
||||
|
||||
→ ❌ پیغام خطا:
|
||||
"این کاربر تعداد زیرمجموعههاش پر شده و شما نمیتونید جزو زیرمجموعه این آدم بشید"
|
||||
```
|
||||
|
||||
### نکته:
|
||||
**فعال** یعنی:
|
||||
- وام دایا گرفته یا پرداخت مستقیم کرده
|
||||
- عضو باشگاه مشتریان شده
|
||||
|
||||
---
|
||||
|
||||
## 7️⃣ فرآیند کامل ثبت نام تا فعالسازی
|
||||
|
||||
```
|
||||
1. ثبت نام با کد معرف
|
||||
↓
|
||||
2. بررسی ظرفیت معرف (حداکثر 2 نفر)
|
||||
↓ (اگر پر بود → خطا)
|
||||
↓
|
||||
3. درخواست وام دایا یا پرداخت مستقیم (56M)
|
||||
↓
|
||||
4. تأیید پرداخت 56M
|
||||
↓
|
||||
5. شارژ کیف پولها:
|
||||
- کیف پول اصلی: +56M
|
||||
- کیف پول تخفیفی: +56M
|
||||
↓
|
||||
6. **دیالوگ الزامی باشگاه مشتریان**
|
||||
- امضای قرارداد
|
||||
- تخصیص 25M به صندوق
|
||||
↓
|
||||
7. کاربر فعال میشود
|
||||
↓
|
||||
8. لینک معرفی نمایش داده میشود
|
||||
↓
|
||||
9. ورود به فرآیند محاسبه کمیسیون هفتگی
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8️⃣ خرید از فروشگاهها
|
||||
|
||||
### دو نوع فروشگاه:
|
||||
|
||||
1. **فروشگاه اصلی**:
|
||||
- از کیف پول اصلی کسر میشود
|
||||
|
||||
2. **فروشگاه تخفیفی** (باشگاه مشتریان):
|
||||
- از کیف پول تخفیفی کسر میشود
|
||||
- به مقداری که تخفیف دارد
|
||||
|
||||
---
|
||||
|
||||
## 9️⃣ جمعبندی تعادل و فلش
|
||||
|
||||
### سناریو کامل:
|
||||
|
||||
```
|
||||
هفته 1:
|
||||
- چپ = 500، راست = 600
|
||||
- تعادل = MIN(500, 600) = 500
|
||||
|
||||
چون 500 > 300:
|
||||
- امتیاز این هفته = 300
|
||||
- فلش چپ = 500 - 300 = 200
|
||||
- فلش راست = 600 - 300 = 300
|
||||
- جمع فلش = 500 (از بین رفت)
|
||||
```
|
||||
|
||||
### قوانین فلش:
|
||||
1. ❌ باقیماندهای که از هفته قبل میآید فلش **نمیشود**
|
||||
2. ✅ فقط اضافهای که بزرگتر از 300 است فلش میشود
|
||||
3. ✅ هر دو طرف (چپ و راست) فلش میشوند
|
||||
4. ❌ **نمیتواند** فقط یک طرف فلش شود
|
||||
|
||||
### مثال فلش:
|
||||
```
|
||||
هفته قبل باقیمانده راست = 200
|
||||
هفته جدید راست = 400
|
||||
مجموع راست = 600
|
||||
|
||||
سقف = 300
|
||||
فلش راست = 600 - 300 = 300 ✅ (نه 200)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔟 نکات مهم اضافی
|
||||
|
||||
### چرخش هفتگی:
|
||||
- محاسبات هر هفته صورت میگیرد
|
||||
- تعادلهای استفاده شده **ریست** میشوند
|
||||
- فقط **باقیمانده** به هفته بعد منتقل میشود
|
||||
- فلشها **هیچ جا حساب نمیشوند**
|
||||
|
||||
### محدودیتهای عمق شبکه:
|
||||
- **تا همه کاربرها** در زیر شبکه حساب میشوند
|
||||
- **بدون محدودیت عمق** (تا سطح آخر درخت)
|
||||
|
||||
### اولویت محاسبه:
|
||||
1. محاسبه تعادل اولیه
|
||||
2. محاسبه باقیمانده
|
||||
3. اعمال سقف 300
|
||||
4. محاسبه فلش
|
||||
5. ذخیره باقیمانده برای هفته بعد
|
||||
|
||||
---
|
||||
|
||||
## 📊 جدول مقایسه حالات مختلف
|
||||
|
||||
| چپ | راست | تعادل اولیه | سقف 300 | امتیاز | باقی چپ | باقی راست | فلش کل |
|
||||
|-----|-------|-------------|---------|--------|---------|-----------|---------|
|
||||
| 200 | 250 | 200 | 200 | 200 | 0 | 50 | 0 |
|
||||
| 400 | 350 | 350 | 300 | 300 | 100 | 50 | 100 |
|
||||
| 500 | 600 | 500 | 300 | 300 | 200 | 300 | 400 |
|
||||
| 150 | 280 | 150 | 150 | 150 | 0 | 130 | 0 |
|
||||
| 350 | 350 | 350 | 300 | 300 | 50 | 50 | 100 |
|
||||
|
||||
**توضیح ستونها:**
|
||||
- **تعادل اولیه**: MIN(چپ، راست)
|
||||
- **سقف 300**: MIN(تعادل اولیه، 300)
|
||||
- **امتیاز**: همان سقف 300 (امتیاز نهایی)
|
||||
- **باقی چپ**: چپ - سقف چپ (300)
|
||||
- **باقی راست**: راست - سقف راست (300)
|
||||
- **فلش کل**: (چپ - 300) + (راست - 300) اگر > 0
|
||||
|
||||
---
|
||||
|
||||
## ✅ وضعیت پیادهسازی فعلی
|
||||
|
||||
این سند نیاز به **تطبیق کامل** با:
|
||||
1. ✅ کد موجود در `CalculateWeeklyBalancesCommandHandler`
|
||||
2. ✅ داکیومنتهای موجود در `totalDoc/01-BUSINESS/`
|
||||
3. ✅ Entity ها در Domain Layer
|
||||
4. ✅ Worker های پسزمینه
|
||||
|
||||
→ در مرحله بعد مقایسه و شناسایی تفاوتها انجام میشود.
|
||||
@@ -0,0 +1,967 @@
|
||||
# Package Purchase System - سیستم خرید پکیج طلایی
|
||||
|
||||
**تاریخ ایجاد:** 2024-12-02
|
||||
**وضعیت:** در حال طراحی
|
||||
**اولویت:** 🔴 بسیار بالا
|
||||
|
||||
---
|
||||
|
||||
## 📋 فهرست
|
||||
|
||||
1. [مقدمه](#مقدمه)
|
||||
2. [سه سناریوی اصلی](#سه-سناریوی-اصلی)
|
||||
3. [Entity Changes](#entity-changes)
|
||||
4. [Business Rules](#business-rules)
|
||||
5. [Flow Diagrams](#flow-diagrams)
|
||||
6. [Commands & Handlers](#commands--handlers)
|
||||
7. [تسکهای پیادهسازی](#تسک-های-پیاده-سازی)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 مقدمه
|
||||
|
||||
سیستم خرید پکیج طلایی سه سناریوی مختلف دارد که باید به درستی از هم تفکیک شوند:
|
||||
|
||||
### هدف کلی:
|
||||
- **سناریو 1 و 2**: خرید پکیج طلایی (56 میلیون تومان) → امکان فعالسازی باشگاه مشتریان
|
||||
- **سناریو 3**: شارژ عادی کیف پول تخفیفی → فقط برای خرید از فروشگاه تخفیفی
|
||||
|
||||
### نکات کلیدی:
|
||||
1. کاربر فقط **یک بار** میتواند پکیج طلایی خریداری کند (سناریو 1 یا 2)
|
||||
2. بعد از خرید پکیج، کاربر **باید خودش** دکمه فعالسازی باشگاه را بزند
|
||||
3. فعالسازی باشگاه **نیاز به تایید Admin ندارد**
|
||||
4. عضویت در شبکه (NetworkMembership) **جدا** از عضویت در باشگاه (ClubMembership) است
|
||||
5. کمیسیونها **فقط بعد** از فعالسازی باشگاه محاسبه میشوند
|
||||
|
||||
---
|
||||
|
||||
## 🔄 سه سناریوی اصلی
|
||||
|
||||
### 📌 سناریو 1: دریافت وام دایا (DayaLoan)
|
||||
|
||||
```
|
||||
کاربر → درخواست وام از دایا → دایا وام را تایید میکند
|
||||
↓
|
||||
شارژ Balance در UserWallet (56,000,000 تومان)
|
||||
↓
|
||||
ثبت UserWalletChangeLog (Amount: +56M, Type: DepositExternal1)
|
||||
↓
|
||||
ثبت Transaction (Type: DepositExternal1, RefId: شماره قرارداد دایا)
|
||||
↓
|
||||
ثبت UserOrder (PackageId: پکیج طلایی, TransactionId: xxx, Amount: 56M)
|
||||
↓
|
||||
کاربر میتواند با این 56M از فروشگاه عادی خرید کند
|
||||
↓
|
||||
[کاربر باید خودش دکمه "فعالسازی باشگاه مشتریان" را بزند]
|
||||
↓
|
||||
ثبت/بهروزرسانی ClubMembership (IsActive: true, PurchaseMethod: DayaLoan)
|
||||
↓
|
||||
شروع محاسبه کمیسیونها
|
||||
```
|
||||
|
||||
**نکات:**
|
||||
- `Transaction.Type` = `DepositExternal1` (وام دایا)
|
||||
- `Transaction.RefId` = شماره قرارداد دایا
|
||||
- `UserOrder.PackageId` پر میشود
|
||||
- `User.PackagePurchaseMethod` = `DayaLoan`
|
||||
|
||||
---
|
||||
|
||||
### 📌 سناریو 2: خرید پکیج طلایی از درگاه (Direct Purchase)
|
||||
|
||||
```
|
||||
کاربر → انتخاب پکیج طلایی (56M) → کلیک "پرداخت"
|
||||
↓
|
||||
ثبت UserOrder (PackageId: پکیج طلایی, Amount: 56M, PaymentStatus: Pending)
|
||||
↓
|
||||
Redirect به درگاه بانکی (IPG)
|
||||
↓
|
||||
کاربر پرداخت میکند و بر میگردد
|
||||
↓
|
||||
Verify پرداخت با بانک
|
||||
↓
|
||||
شارژ Balance در UserWallet (56,000,000 تومان)
|
||||
↓
|
||||
ثبت UserWalletChangeLog (Amount: +56M, Type: DepositIpg)
|
||||
↓
|
||||
ثبت Transaction (Type: DepositIpg, RefId: کد پیگیری بانک)
|
||||
↓
|
||||
بهروزرسانی UserOrder (TransactionId: xxx, PaymentStatus: Success)
|
||||
↓
|
||||
کاربر میتواند با این 56M از فروشگاه عادی خرید کند
|
||||
↓
|
||||
[کاربر باید خودش دکمه "فعالسازی باشگاه مشتریان" را بزند]
|
||||
↓
|
||||
ثبت/بهروزرسانی ClubMembership (IsActive: true, PurchaseMethod: DirectPurchase)
|
||||
↓
|
||||
شروع محاسبه کمیسیونها
|
||||
```
|
||||
|
||||
**نکات:**
|
||||
- `Transaction.Type` = `DepositIpg` (پرداخت از درگاه)
|
||||
- `Transaction.RefId` = کد پیگیری بانک
|
||||
- `UserOrder.PackageId` پر میشود
|
||||
- `User.PackagePurchaseMethod` = `DirectPurchase`
|
||||
|
||||
---
|
||||
|
||||
### 📌 سناریو 3: شارژ عادی کیف پول تخفیفی (Regular Wallet Charge)
|
||||
|
||||
```
|
||||
کاربر → انتخاب مبلغ دلخواه → کلیک "شارژ کیف پول"
|
||||
↓
|
||||
Redirect به درگاه بانکی (IPG)
|
||||
↓
|
||||
کاربر پرداخت میکند و بر میگردد
|
||||
↓
|
||||
Verify پرداخت با بانک
|
||||
↓
|
||||
شارژ DiscountBalance در UserWallet (مبلغ دلخواه)
|
||||
↓
|
||||
ثبت UserWalletChangeLog (Amount: +xxx, Type: DiscountWalletCharge)
|
||||
↓
|
||||
ثبت Transaction (Type: DiscountWalletCharge, RefId: کد پیگیری بانک)
|
||||
↓
|
||||
کاربر میتواند فقط از فروشگاه تخفیفی خرید کند
|
||||
↓
|
||||
[هیچ ارتباطی با باشگاه مشتریان ندارد]
|
||||
```
|
||||
|
||||
**نکات:**
|
||||
- `Transaction.Type` = `DiscountWalletCharge`
|
||||
- `Transaction.RefId` = کد پیگیری بانک
|
||||
- **PackageId در هیچ جا ثبت نمیشود**
|
||||
- فقط `DiscountBalance` شارژ میشود، نه `Balance`
|
||||
- هیچ `UserOrder` با `PackageId` ثبت نمیشود
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Entity Changes
|
||||
|
||||
### 1️⃣ **Enum جدید: `PackagePurchaseMethod`**
|
||||
|
||||
```csharp
|
||||
namespace CMSMicroservice.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج طلایی توسط کاربر
|
||||
/// </summary>
|
||||
public enum PackagePurchaseMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// هنوز پکیج خریداری نکرده
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// از طریق وام دایا
|
||||
/// </summary>
|
||||
DayaLoan = 1,
|
||||
|
||||
/// <summary>
|
||||
/// از طریق پرداخت مستقیم درگاه بانکی
|
||||
/// </summary>
|
||||
DirectPurchase = 2
|
||||
}
|
||||
```
|
||||
|
||||
**محل:** `CMS/src/CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs`
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ **تغییرات `User` Entity**
|
||||
|
||||
```csharp
|
||||
// اضافه کردن این فیلد به User.cs:
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج طلایی (برای جلوگیری از خرید مجدد)
|
||||
/// </summary>
|
||||
public PackagePurchaseMethod PackagePurchaseMethod { get; set; } = PackagePurchaseMethod.None;
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
- وقتی کاربر سناریو 1 یا 2 را انجام میدهد، این فیلد تغییر میکند
|
||||
- اگر `PackagePurchaseMethod != None` باشد، کاربر نمیتواند دوباره پکیج خریداری کند
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ **تغییرات `ClubMembership` Entity**
|
||||
|
||||
```csharp
|
||||
// اضافه کردن این فیلد به ClubMembership.cs:
|
||||
|
||||
/// <summary>
|
||||
/// نحوه خرید پکیج که منجر به فعالسازی باشگاه شد
|
||||
/// </summary>
|
||||
public PackagePurchaseMethod PurchaseMethod { get; set; }
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
- وقتی کاربر دکمه "فعالسازی باشگاه" را میزند، این فیلد از `User.PackagePurchaseMethod` کپی میشود
|
||||
- برای گزارشگیری و تحلیل: چند نفر از طریق وام دایا و چند نفر از طریق خرید مستقیم عضو شدند
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ **تغییرات `TransactionType` Enum**
|
||||
|
||||
```csharp
|
||||
// فعلاً موجود است:
|
||||
public enum TransactionType
|
||||
{
|
||||
Buy = 0,
|
||||
DepositIpg = 1, // پرداخت از درگاه (سناریو 2)
|
||||
DepositExternal1 = 2, // وام دایا (سناریو 1)
|
||||
Withdraw = 3,
|
||||
NetworkCommission = 10,
|
||||
ClubActivation = 11,
|
||||
DiscountWalletCharge = 12 // شارژ کیف پول تخفیفی (سناریو 3) ✅
|
||||
}
|
||||
```
|
||||
|
||||
**نکته:** `DiscountWalletCharge` از قبل وجود دارد، پس نیازی به تغییر نیست.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Business Rules
|
||||
|
||||
### قانون 1: یک کاربر فقط یک بار میتواند پکیج طلایی خریداری کند
|
||||
|
||||
```csharp
|
||||
// Check قبل از خرید پکیج:
|
||||
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
|
||||
{
|
||||
throw new ValidationException("شما قبلاً پکیج طلایی را خریداری کردهاید.");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 2: فعالسازی باشگاه فقط با موجودی اصلی (Balance) امکانپذیر است
|
||||
|
||||
```csharp
|
||||
// Check موقع فعالسازی باشگاه:
|
||||
var userWallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == userId);
|
||||
|
||||
if (userWallet.Balance < 56_000_000)
|
||||
{
|
||||
throw new ValidationException("برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید.");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 3: فعالسازی باشگاه فقط برای کسانی که پکیج خریدهاند
|
||||
|
||||
```csharp
|
||||
// Check موقع فعالسازی باشگاه:
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
{
|
||||
throw new ValidationException("برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید.");
|
||||
}
|
||||
|
||||
// پیدا کردن UserOrder مربوط به پکیج:
|
||||
var packageOrder = await _context.UserOrders
|
||||
.FirstOrDefaultAsync(o =>
|
||||
o.UserId == userId &&
|
||||
o.PackageId != null &&
|
||||
o.PaymentStatus == PaymentStatus.Success
|
||||
);
|
||||
|
||||
if (packageOrder == null)
|
||||
{
|
||||
throw new ValidationException("سفارش پکیج طلایی یافت نشد.");
|
||||
}
|
||||
|
||||
// پیدا کردن Transaction مربوطه:
|
||||
var transaction = await _context.Transactions
|
||||
.FirstOrDefaultAsync(t => t.Id == packageOrder.TransactionId);
|
||||
|
||||
if (transaction == null ||
|
||||
(transaction.Type != TransactionType.DepositIpg &&
|
||||
transaction.Type != TransactionType.DepositExternal1))
|
||||
{
|
||||
throw new ValidationException("تراکنش معتبر برای فعالسازی باشگاه یافت نشد.");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### قانون 4: NetworkMembership جدا از ClubMembership است
|
||||
|
||||
- **NetworkMembership**: موقع ثبتنام کاربر خودکار ایجاد میشود (با `ParentId`)
|
||||
- **ClubMembership**: فقط وقتی کاربر دکمه "فعالسازی باشگاه" را بزند ایجاد میشود
|
||||
- کاربر میتواند زیرمجموعه بگیرد بدون اینکه جزو باشگاه باشد (ولی سیاستگذاری میکنیم که قبل از گرفتن زیرمجموعه باید باشگاه را فعال کرده باشد)
|
||||
|
||||
---
|
||||
|
||||
### قانون 5: محاسبه کمیسیون فقط بعد از فعالسازی باشگاه
|
||||
|
||||
```csharp
|
||||
// در محاسبه کمیسیون:
|
||||
var clubMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsActive);
|
||||
|
||||
if (clubMembership == null)
|
||||
{
|
||||
// این کاربر کمیسیون نمیگیرد چون جزو باشگاه نیست
|
||||
return;
|
||||
}
|
||||
|
||||
// ادامه محاسبه کمیسیون...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Flow Diagrams
|
||||
|
||||
### 🔹 Flow 1: خرید پکیج از درگاه (سناریو 2)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FrontOffice UI (کاربر) │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ انتخاب پکیج طلایی (56M) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ PurchaseGoldenPackageCommand │
|
||||
│ - بررسی User.PackagePurchaseMethod │
|
||||
│ - ثبت UserOrder (Pending) │
|
||||
│ - Redirect به درگاه │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ درگاه بانکی (IPG) │
|
||||
│ کاربر پرداخت میکند │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ VerifyGoldenPackagePurchaseCommand │
|
||||
│ - Verify با بانک │
|
||||
│ - شارژ UserWallet.Balance (56M) │
|
||||
│ - ثبت Transaction (DepositIpg) │
|
||||
│ - ثبت UserWalletChangeLog │
|
||||
│ - Set User.PackagePurchaseMethod │
|
||||
│ = DirectPurchase │
|
||||
│ - بهروزرسانی UserOrder (Success) │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ کاربر میتواند از فروشگاه عادی │
|
||||
│ خرید کند (با Balance) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔹 Flow 2: فعالسازی باشگاه مشتریان
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FrontOffice UI (کاربر وارد شده) │
|
||||
│ کاربر دکمه "فعالسازی باشگاه" را میزند │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ ActivateClubMembershipCommand │
|
||||
│ │
|
||||
│ 1. بررسی User.PackagePurchaseMethod │
|
||||
│ → باید != None باشد │
|
||||
│ │
|
||||
│ 2. بررسی UserWallet.Balance │
|
||||
│ → باید >= 56M باشد │
|
||||
│ │
|
||||
│ 3. پیدا کردن UserOrder با PackageId │
|
||||
│ → PaymentStatus = Success │
|
||||
│ │
|
||||
│ 4. پیدا کردن Transaction │
|
||||
│ → Type = DepositIpg یا │
|
||||
│ DepositExternal1 │
|
||||
│ │
|
||||
│ 5. ثبت/بهروزرسانی ClubMembership │
|
||||
│ - IsActive = true │
|
||||
│ - ActivatedAt = DateTime.Now │
|
||||
│ - PurchaseMethod = کپی از User │
|
||||
│ │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ کاربر جزو باشگاه مشتریان شد │
|
||||
│ کمیسیونها شروع به محاسبه میکنند │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔹 Flow 3: شارژ کیف پول تخفیفی (سناریو 3)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FrontOffice UI (کاربر) │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ انتخاب مبلغ دلخواه │
|
||||
│ (برای فروشگاه تخفیفی) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ ChargeDiscountWalletCommand │
|
||||
│ - Redirect به درگاه │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ درگاه بانکی (IPG) │
|
||||
│ کاربر پرداخت میکند │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ VerifyDiscountWalletChargeCommand │
|
||||
│ - Verify با بانک │
|
||||
│ - شارژ UserWallet.DiscountBalance │
|
||||
│ - ثبت Transaction │
|
||||
│ (Type: DiscountWalletCharge) │
|
||||
│ - ثبت UserWalletChangeLog │
|
||||
└────────────┬─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ کاربر میتواند از فروشگاه تخفیفی │
|
||||
│ خرید کند (با DiscountBalance) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**نکته:** در این سناریو هیچ `UserOrder` با `PackageId` ثبت نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## 💻 Commands & Handlers
|
||||
|
||||
### 1️⃣ `PurchaseGoldenPackageCommand`
|
||||
|
||||
**مسئولیت:** ایجاد سفارش پکیج طلایی و Redirect به درگاه
|
||||
|
||||
```csharp
|
||||
public class PurchaseGoldenPackageCommand : IRequest<PaymentInitiateResult>
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
}
|
||||
|
||||
public class PurchaseGoldenPackageCommandHandler
|
||||
: IRequestHandler<PurchaseGoldenPackageCommand, PaymentInitiateResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
|
||||
public async Task<PaymentInitiateResult> Handle(
|
||||
PurchaseGoldenPackageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. بررسی User
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
|
||||
// 2. بررسی اینکه قبلاً پکیج نخریده باشد
|
||||
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
|
||||
{
|
||||
throw new ValidationException("شما قبلاً پکیج طلایی را خریداری کردهاید.");
|
||||
}
|
||||
|
||||
// 3. پیدا کردن پکیج طلایی
|
||||
var goldenPackage = await _context.Packages
|
||||
.FirstOrDefaultAsync(p => p.Title.Contains("طلایی"), cancellationToken);
|
||||
|
||||
if (goldenPackage == null)
|
||||
throw new NotFoundException("پکیج طلایی یافت نشد.");
|
||||
|
||||
// 4. ایجاد UserOrder
|
||||
var order = new UserOrder
|
||||
{
|
||||
UserId = user.Id,
|
||||
PackageId = goldenPackage.Id,
|
||||
Amount = goldenPackage.Price, // 56,000,000
|
||||
PaymentStatus = PaymentStatus.Pending,
|
||||
DeliveryStatus = DeliveryStatus.None,
|
||||
UserAddressId = 0 // پکیج نیاز به آدرس ندارد
|
||||
};
|
||||
|
||||
_context.UserOrders.Add(order);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. Redirect به درگاه
|
||||
var paymentRequest = new PaymentRequest
|
||||
{
|
||||
Amount = order.Amount,
|
||||
OrderId = order.Id.ToString(),
|
||||
CallbackUrl = "https://yourdomain.com/verify-golden-package",
|
||||
Description = $"خرید پکیج طلایی"
|
||||
};
|
||||
|
||||
var result = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ `VerifyGoldenPackagePurchaseCommand`
|
||||
|
||||
**مسئولیت:** Verify پرداخت و شارژ کیف پول
|
||||
|
||||
```csharp
|
||||
public class VerifyGoldenPackagePurchaseCommand : IRequest<bool>
|
||||
{
|
||||
public long OrderId { get; set; }
|
||||
public string Authority { get; set; } // از درگاه
|
||||
}
|
||||
|
||||
public class VerifyGoldenPackagePurchaseCommandHandler
|
||||
: IRequestHandler<VerifyGoldenPackagePurchaseCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
|
||||
public async Task<bool> Handle(
|
||||
VerifyGoldenPackagePurchaseCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. پیدا کردن Order
|
||||
var order = await _context.UserOrders
|
||||
.Include(o => o.Package)
|
||||
.Include(o => o.User)
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
|
||||
|
||||
if (order == null)
|
||||
throw new NotFoundException(nameof(UserOrder), request.OrderId);
|
||||
|
||||
// 2. Verify با بانک
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
request.Authority,
|
||||
order.Amount
|
||||
);
|
||||
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
order.PaymentStatus = PaymentStatus.Failed;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. شارژ کیف پول
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
|
||||
|
||||
wallet.Balance += order.Amount; // 56,000,000
|
||||
|
||||
// 4. ثبت Transaction
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Amount = order.Amount,
|
||||
Description = "خرید پکیج طلایی از درگاه",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.Now,
|
||||
RefId = verifyResult.RefId,
|
||||
Type = TransactionType.DepositIpg
|
||||
};
|
||||
|
||||
_context.Transactions.Add(transaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. ثبت ChangeLog
|
||||
var changeLog = new UserWalletChangeLog
|
||||
{
|
||||
UserId = order.UserId,
|
||||
Amount = order.Amount,
|
||||
ChangeType = WalletChangeType.Deposit,
|
||||
Description = "شارژ موجودی از پکیج طلایی",
|
||||
BalanceBefore = wallet.Balance - order.Amount,
|
||||
BalanceAfter = wallet.Balance
|
||||
};
|
||||
|
||||
_context.UserWalletChangeLogs.Add(changeLog);
|
||||
|
||||
// 6. بهروزرسانی Order
|
||||
order.TransactionId = transaction.Id;
|
||||
order.PaymentStatus = PaymentStatus.Success;
|
||||
order.PaymentDate = DateTime.Now;
|
||||
order.PaymentMethod = PaymentMethod.Online;
|
||||
|
||||
// 7. تغییر User.PackagePurchaseMethod
|
||||
order.User.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ `ActivateClubMembershipCommand`
|
||||
|
||||
**مسئولیت:** فعالسازی عضویت در باشگاه مشتریان
|
||||
|
||||
```csharp
|
||||
public class ActivateClubMembershipCommand : IRequest<bool>
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
}
|
||||
|
||||
public class ActivateClubMembershipCommandHandler
|
||||
: IRequestHandler<ActivateClubMembershipCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ActivateClubMembershipCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. بررسی User
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
|
||||
// 2. بررسی اینکه پکیج خریده باشد
|
||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید."
|
||||
);
|
||||
}
|
||||
|
||||
// 3. بررسی موجودی
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (wallet.Balance < 56_000_000)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید."
|
||||
);
|
||||
}
|
||||
|
||||
// 4. بررسی UserOrder
|
||||
var packageOrder = await _context.UserOrders
|
||||
.FirstOrDefaultAsync(o =>
|
||||
o.UserId == user.Id &&
|
||||
o.PackageId != null &&
|
||||
o.PaymentStatus == PaymentStatus.Success,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (packageOrder == null)
|
||||
{
|
||||
throw new ValidationException("سفارش پکیج طلایی یافت نشد.");
|
||||
}
|
||||
|
||||
// 5. بررسی Transaction
|
||||
var transaction = await _context.Transactions
|
||||
.FirstOrDefaultAsync(t => t.Id == packageOrder.TransactionId, cancellationToken);
|
||||
|
||||
if (transaction == null ||
|
||||
(transaction.Type != TransactionType.DepositIpg &&
|
||||
transaction.Type != TransactionType.DepositExternal1))
|
||||
{
|
||||
throw new ValidationException("تراکنش معتبر برای فعالسازی باشگاه یافت نشد.");
|
||||
}
|
||||
|
||||
// 6. بررسی اینکه قبلاً فعال نکرده باشد
|
||||
var existingMembership = await _context.ClubMemberships
|
||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (existingMembership != null && existingMembership.IsActive)
|
||||
{
|
||||
throw new ValidationException("شما قبلاً عضو باشگاه مشتریان هستید.");
|
||||
}
|
||||
|
||||
// 7. ثبت یا بهروزرسانی ClubMembership
|
||||
if (existingMembership == null)
|
||||
{
|
||||
existingMembership = new ClubMembership
|
||||
{
|
||||
UserId = user.Id,
|
||||
IsActive = true,
|
||||
ActivatedAt = DateTime.Now,
|
||||
InitialContribution = 56_000_000,
|
||||
TotalEarned = 0,
|
||||
PurchaseMethod = user.PackagePurchaseMethod
|
||||
};
|
||||
|
||||
_context.ClubMemberships.Add(existingMembership);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingMembership.IsActive = true;
|
||||
existingMembership.ActivatedAt = DateTime.Now;
|
||||
existingMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ `ChargeDiscountWalletCommand` (سناریو 3)
|
||||
|
||||
**مسئولیت:** شارژ کیف پول تخفیفی
|
||||
|
||||
```csharp
|
||||
public class ChargeDiscountWalletCommand : IRequest<PaymentInitiateResult>
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public long Amount { get; set; }
|
||||
}
|
||||
|
||||
public class ChargeDiscountWalletCommandHandler
|
||||
: IRequestHandler<ChargeDiscountWalletCommand, PaymentInitiateResult>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
|
||||
public async Task<PaymentInitiateResult> Handle(
|
||||
ChargeDiscountWalletCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. بررسی User
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
|
||||
// 2. بررسی مبلغ (حداقل 10,000 تومان)
|
||||
if (request.Amount < 10_000)
|
||||
{
|
||||
throw new ValidationException("حداقل مبلغ شارژ 10,000 تومان است.");
|
||||
}
|
||||
|
||||
// 3. Redirect به درگاه
|
||||
var paymentRequest = new PaymentRequest
|
||||
{
|
||||
Amount = request.Amount,
|
||||
OrderId = $"DISCOUNT_{user.Id}_{DateTime.Now:yyyyMMddHHmmss}",
|
||||
CallbackUrl = "https://yourdomain.com/verify-discount-wallet",
|
||||
Description = $"شارژ کیف پول تخفیفی"
|
||||
};
|
||||
|
||||
var result = await _paymentGateway.InitiatePaymentAsync(paymentRequest);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ `VerifyDiscountWalletChargeCommand` (سناریو 3)
|
||||
|
||||
**مسئولیت:** Verify و شارژ DiscountBalance
|
||||
|
||||
```csharp
|
||||
public class VerifyDiscountWalletChargeCommand : IRequest<bool>
|
||||
{
|
||||
public long UserId { get; set; }
|
||||
public long Amount { get; set; }
|
||||
public string Authority { get; set; }
|
||||
}
|
||||
|
||||
public class VerifyDiscountWalletChargeCommandHandler
|
||||
: IRequestHandler<VerifyDiscountWalletChargeCommand, bool>
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly IPaymentGatewayService _paymentGateway;
|
||||
|
||||
public async Task<bool> Handle(
|
||||
VerifyDiscountWalletChargeCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. پیدا کردن User
|
||||
var user = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user == null)
|
||||
throw new NotFoundException(nameof(User), request.UserId);
|
||||
|
||||
// 2. Verify با بانک
|
||||
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
|
||||
request.Authority,
|
||||
request.Amount
|
||||
);
|
||||
|
||||
if (!verifyResult.IsSuccess)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. شارژ DiscountBalance
|
||||
var wallet = await _context.UserWallets
|
||||
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
|
||||
|
||||
wallet.DiscountBalance += request.Amount;
|
||||
|
||||
// 4. ثبت Transaction
|
||||
var transaction = new Transactions
|
||||
{
|
||||
Amount = request.Amount,
|
||||
Description = "شارژ کیف پول تخفیفی",
|
||||
PaymentStatus = PaymentStatus.Success,
|
||||
PaymentDate = DateTime.Now,
|
||||
RefId = verifyResult.RefId,
|
||||
Type = TransactionType.DiscountWalletCharge
|
||||
};
|
||||
|
||||
_context.Transactions.Add(transaction);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. ثبت ChangeLog
|
||||
var changeLog = new UserWalletChangeLog
|
||||
{
|
||||
UserId = user.Id,
|
||||
Amount = request.Amount,
|
||||
ChangeType = WalletChangeType.Deposit,
|
||||
Description = "شارژ موجودی تخفیفی",
|
||||
BalanceBefore = wallet.DiscountBalance - request.Amount,
|
||||
BalanceAfter = wallet.DiscountBalance
|
||||
};
|
||||
|
||||
_context.UserWalletChangeLogs.Add(changeLog);
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 تسکهای پیادهسازی
|
||||
|
||||
### Phase 1: Entity Changes (1 روز)
|
||||
|
||||
1. **ایجاد `PackagePurchaseMethod` Enum**
|
||||
- محل: `CMSMicroservice.Domain/Enums/PackagePurchaseMethod.cs`
|
||||
- مقادیر: None, DayaLoan, DirectPurchase
|
||||
|
||||
2. **اضافه کردن فیلد به `User`**
|
||||
- فیلد: `PackagePurchaseMethod PackagePurchaseMethod`
|
||||
- مقدار پیشفرض: `PackagePurchaseMethod.None`
|
||||
|
||||
3. **اضافه کردن فیلد به `ClubMembership`**
|
||||
- فیلد: `PackagePurchaseMethod PurchaseMethod`
|
||||
|
||||
4. **ایجاد Migration**
|
||||
```bash
|
||||
dotnet ef migrations add AddPackagePurchaseMethod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Commands (2 روز)
|
||||
|
||||
1. **`PurchaseGoldenPackageCommand`**
|
||||
- بررسی `User.PackagePurchaseMethod`
|
||||
- ثبت `UserOrder` با `PackageId`
|
||||
- Redirect به درگاه
|
||||
|
||||
2. **`VerifyGoldenPackagePurchaseCommand`**
|
||||
- Verify پرداخت
|
||||
- شارژ `Balance`
|
||||
- ثبت `Transaction` (DepositIpg)
|
||||
- Set `User.PackagePurchaseMethod = DirectPurchase`
|
||||
|
||||
3. **`ActivateClubMembershipCommand`**
|
||||
- چکهای امنیتی (UserOrder + Transaction)
|
||||
- ثبت/بهروزرسانی `ClubMembership`
|
||||
|
||||
4. **`ChargeDiscountWalletCommand` + `VerifyDiscountWalletChargeCommand`**
|
||||
- شارژ `DiscountBalance`
|
||||
- ثبت `Transaction` (DiscountWalletCharge)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: بهروزرسانی DayaLoan Flow (0.5 روز)
|
||||
|
||||
- تغییر `ProcessDayaLoanCommandHandler`:
|
||||
```csharp
|
||||
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Unit Tests (1 روز)
|
||||
|
||||
1. تست `PurchaseGoldenPackageCommand`:
|
||||
- کاربری که قبلاً پکیج خریده → باید خطا بدهد
|
||||
- کاربر جدید → باید Order ایجاد شود
|
||||
|
||||
2. تست `ActivateClubMembershipCommand`:
|
||||
- کاربر بدون پکیج → خطا
|
||||
- کاربر با موجودی کمتر از 56M → خطا
|
||||
- کاربر معتبر → موفق
|
||||
|
||||
3. تست `VerifyDiscountWalletChargeCommand`:
|
||||
- پرداخت موفق → `DiscountBalance` افزایش یابد
|
||||
- پرداخت ناموفق → هیچ تغییری نکند
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Documentation (0.5 روز)
|
||||
|
||||
- بهروزرسانی `implementation-progress.md`
|
||||
- لینک از `REMAINING-TASKS-CONSOLIDATED.md`
|
||||
|
||||
---
|
||||
|
||||
## 📊 خلاصه Timeline
|
||||
|
||||
| Phase | عنوان | زمان |
|
||||
|-------|-------|------|
|
||||
| 1 | Entity Changes | 1 روز |
|
||||
| 2 | Commands & Handlers | 2 روز |
|
||||
| 3 | DayaLoan Flow Update | 0.5 روز |
|
||||
| 4 | Unit Tests | 1 روز |
|
||||
| 5 | Documentation | 0.5 روز |
|
||||
| **جمع** | | **5 روز** |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 مراجع
|
||||
|
||||
- [DayaLoan Integration](./daya-loan-integration.md)
|
||||
- [Manual Payment System](./manual-payment-system.md)
|
||||
- [Implementation Progress](./implementation-progress.md)
|
||||
- [REMAINING-TASKS](../REMAINING-TASKS-CONSOLIDATED.md)
|
||||
|
||||
---
|
||||
|
||||
**تاریخ آخرین بهروزرسانی:** 2024-12-02
|
||||
**نویسنده:** GitHub Copilot
|
||||
**وضعیت:** ✅ تایید شده توسط کاربر
|
||||
Reference in New Issue
Block a user