Implement Persian Date Conversion and Enhance User Network Information Service

- Added PersianDateTimeService for converting Gregorian dates to Persian format in the BackOffice frontend.
- Updated multiple frontend pages (Dashboard, UserPayouts, WorkerControl, UserNetworkInfo) to utilize the new Persian date service.
- Enhanced GetUserNetworkPositionDto with 28+ new fields for comprehensive user network data.
- Updated GetUserNetworkPositionQueryHandler to include new methods for calculating network statistics.
- Modified Protobuf messages to accommodate the new fields, increasing from 14 to 42.
- Refined week number calculation algorithm to ensure consistency across C# and SQL implementations.
- Created new CSV and Excel files for binary plan calculations.
- Ensured all changes are tested and validated for accuracy and performance.
This commit is contained in:
masoodafar-web
2025-12-20 06:15:59 +03:30
parent 13a3489765
commit 002e99f6bf
32 changed files with 10263 additions and 106 deletions
+84 -1
View File
@@ -1,3 +1,86 @@
# BackOffice.BFF
BackOffice BFF
> Backend For Frontend layer برای BackOffice UI
## 📋 خلاصه
BackOffice.BFF لایه واسط بین BackOffice UI و CMS microservices است که:
- درخواست‌های UI را aggregate می‌کند
- قراردادهای gRPC اختصاصی ارائه می‌دهد
- منطق سطح BFF را پیاده‌سازی می‌کند
## 🏗️ معماری
### Protobuf Projects (Own Contracts)
BackOffice.BFF از قراردادهای Protobuf **اختصاصی خودش** استفاده می‌کند:
| Project | Version | Namespace | Purpose |
|---------|---------|-----------|----------|
| BackOffice.BFF.ClubMembership.Protobuf | 0.0.6 | Foursat.BackOffice.BFF.ClubMembership.Protos | باشگاه مشتریان |
| BackOffice.BFF.Commission.Protobuf | 0.0.6 | Foursat.BackOffice.BFF.Commission.Protos | کمیسیون |
| BackOffice.BFF.Configuration.Protobuf | 1.0.6 | Foursat.BackOffice.BFF.Configuration.Protos | تنظیمات |
| BackOffice.BFF.NetworkMembership.Protobuf | 0.0.6 | Foursat.BackOffice.BFF.NetworkMembership.Protos | شبکه |
**تغییر معماری (۱۷ آذر ۱۴۰۴)**:
-**قبلا**: استفاده مستقیم از `CMSMicroservice.Protobuf` (Anti-Pattern)
-**حالا**: Protobuf اختصاصی با namespace مجزا
-**مزایا**: جدایی concerns، versioning مستقل، کاهش coupling
### GrpcServices Mode
همه پروژه‌های Protobuf با `GrpcServices="Both"` پیکربندی شده‌اند:
- **Server**: Base classes برای پیاده‌سازی در BFF
- **Client**: Client classes برای استفاده در BackOffice UI
### HTTP Annotations (Swagger)
همه 33 endpoint با HTTP annotations پیاده‌سازی شده‌اند:
```protobuf
import "google/api/annotations.proto";
rpc GetClubMembershipById(GetClubMembershipByIdRequest) returns (GetClubMembershipByIdResponse) {
option (google.api.http) = { get: "/GetClubMembershipById" };
}
```
**Package**: Google.Api.CommonProtos v2.10.0
## 🔧 Mapster Configuration
### Immutable Type Handling
Protobuf messages دارای فیلدهای immutable هستند. از `MapWith()` استفاده کنید:
```csharp
config.NewConfig<GetNetworkTreeResponseDto, GetNetworkTreeResponse>()
.MapWith(src => new GetNetworkTreeResponse {
Items = { src.Items.Select(x => new NetworkTreeNodeModel {
UserId = x.UserId,
FirstName = x.FirstName,
// ...
}) }
});
```
**Profiles**:
- NetworkMembershipProfile.cs
- ProductsProfile.cs
## 📦 Package Publishing
برای publish به GitLab registry:
```bash
cd BackOffice.BFF.{Module}.Protobuf
dotnet pack -c Release
# Auto-push via PushToFourSat target
```
**Registry**: https://git.afrino.co/api/packages/FourSat/nuget
## 🔗 Related Docs
- [Architecture Patterns](../../../02-ARCHITECTURE/README.md)
- [API Coverage](api-coverage.md)
- [Protobuf Dependencies](protobuf-dependencies.md)
+9 -3
View File
@@ -4,7 +4,7 @@
[![Progress](https://img.shields.io/badge/Progress-85%25-blue)]()
[![MVP](https://img.shields.io/badge/MVP-100%25%20Complete-brightgreen)]()
## 📊 Project Status (2025-12-01)
## 📊 Project Status (2025-12-18)
**Overall Progress**: 85% Complete (7/10 phases)
**Production Readiness**: 95%
@@ -32,9 +32,15 @@
---
## 🚀 Recent Updates (2025-12-01)
## 🚀 Recent Updates (2025-12-18 / ۲۸ آذر)
### Email & SMS Notifications - COMPLETED
### Entity Configuration - Persian Encoding Fix
-**Geography Entities**: Country, State, City
-**Change**: All string columns now `NVARCHAR` with `Persian_100_CI_AI` collation
-**Migration**: `FixPersianCollation_Geography`
-**Fixes**: Persian characters display correctly in Geography tables
### Previous Updates (2025-12-01)
-**MailKit 4.14.1** for Email (SMTP with HTML templates)
-**Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
- ✅ User.Email field added with migration
+281
View File
@@ -0,0 +1,281 @@
# Club Membership Migration Scripts
**Created**: 2025-12-09
**Purpose**: مهاجرت کاربران موجود به سیستم باشگاه مشتریان
**Location**: `/dbbkup/`
---
## 📋 Overview
این اسکریپت‌ها کاربرانی که قبل از راه‌اندازی سیستم باشگاه مشتریان، مبلغ 56 میلیون ریال شارژ کرده‌اند را به‌طور خودکار عضو باشگاه می‌کنند.
---
## 📄 Scripts
### 1. MigrateUsersToClubMembership.sql (نسخه کامل)
**Path**: `/dbbkup/MigrateUsersToClubMembership.sql`
**Features**:
- ✅ بررسی `UserWalletChangeLogs` برای محاسبه مجموع شارژ‌ها
- ✅ Fallback به `Transactions` اگر Logs خالی بود
- ✅ ثبت تاریخ دقیق اولین شارژ به‌عنوان `ActivatedAt`
- ✅ Skip کاربرانی که قبلاً عضو باشگاه هستند
- ✅ Transaction-safe (هر کاربر یک transaction جداگانه)
- ✅ گزارش کامل (موفقیت‌ها + خطاها)
**What It Does**:
```sql
-- برای هر کاربر با شارژ >= 56M:
1. INSERT INTO ClubMemberships (UserId, ActivatedAt=FirstChargeDate, InitialContribution=25M)
2. INSERT INTO ClubMembershipHistories (Action=0, Reason='فعال‌سازی خودکار - مهاجرت')
3. INSERT INTO UserClubFeatures (ClubFeatureId IN (1,2,3,4), Notes='اعطا شده خودکار')
```
**Sample Output**:
```
╔═══════════════════════════════════════════════════════════════╗
║ شروع فرآیند انتقال کاربران به باشگاه مشتریان ║
╚═══════════════════════════════════════════════════════════════╝
تاریخ و زمان اجرا: 2025-12-09 16:30:00.0000000
مبلغ سهم استخر: 25,000,000 ریال
─────────────────────────────────────────────────────────────────
📊 تعداد کاربران کاندید: 45
─────────────────────────────────────────────────────────────────
🔄 شروع ثبت عضویت‌ها...
✓ کاربر 1001 (علی محمدی - 1234567890): عضویت با ID 501 ایجاد شد.
✓ کاربر 1002 (سارا احمدی - 0987654321): عضویت با ID 502 ایجاد شد.
...
─────────────────────────────────────────────────────────────────
╔═══════════════════════════════════════════════════════════════╗
║ گزارش نهایی مهاجرت ║
╚═══════════════════════════════════════════════════════════════╝
تعداد کل کاندیدها: 45
تعداد قبلاً عضو: 0
تعداد پردازش شده: 45
تعداد خطا: 0
مجموع سهم استخر: 1,125,000,000 ریال
✓ فرآیند مهاجرت با موفقیت به پایان رسید.
```
---
### 2. MigrateUsersToClubMembership_Simple.sql (نسخه ساده)
**Path**: `/dbbkup/MigrateUsersToClubMembership_Simple.sql`
**Features**:
- ✅ بررسی موجودی فعلی (`UserWallets.Balance` >= 56M)
- ✅ سریع‌تر از نسخه کامل
- ✅ برای سیستم‌هایی که تاریخچه شارژ ندارند
- ✅ همان Transaction safety
**Difference**:
```sql
-- نسخه کامل:
SUM(uwcl.ChangeValue) >= 56000000 -- از تاریخچه
-- نسخه ساده:
uw.Balance >= 56000000 -- از موجودی فعلی
```
---
## 🔧 Technical Details
### Transaction Strategy
**قبلی (اشتباه)**:
```sql
BEGIN TRANSACTION; -- یک transaction بزرگ
-- 100 INSERT...
COMMIT TRANSACTION;
```
❌ با cursor سازگار نیست! → `log file overflow`
**فعلی (صحیح)**:
```sql
WHILE @@FETCH_STATUS = 0
BEGIN
BEGIN TRANSACTION; -- transaction جداگانه
INSERT ClubMemberships;
INSERT ClubMembershipHistories;
INSERT UserClubFeatures (4 rows);
COMMIT TRANSACTION; -- برای هر کاربر
END
```
✅ هر کاربر مستقل → اگر یکی خطا داد، بقیه commit می‌شوند
---
### Schema Compatibility
**تغییرات از Schema واقعی**:
1. ❌ حذف `User.ClubMembershipId` (این ستون وجود نداره!)
2. ✅ رابطه: `ClubMemberships.UserId → Users.Id` (یک‌طرفه)
3.`Action` از نوع `INT` است (نه `NVARCHAR`):
- `0` = Activated
- `1` = Deactivated
**Unicode Encoding**:
```sql
-- اشتباه (encoding خراب):
N'فارسی' -- در SELECT باز هم خراب می‌شه!
-- درست:
CAST(N'فعال‌سازی خودکار' AS NVARCHAR(500))
```
---
## 📊 Data Flow
```
┌─────────────────────────────────────────────────────────┐
│ 1. Query: Users with TotalCharge >= 56M │
│ Sources: UserWalletChangeLogs OR Transactions │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 2. Filter: Skip users already in ClubMemberships │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 3. For Each User (in cursor): │
│ BEGIN TRANSACTION │
│ ├─ INSERT ClubMembership │
│ │ (UserId, ActivatedAt=FirstCharge, │
│ │ InitialContribution=25M) │
│ ├─ INSERT ClubMembershipHistory │
│ │ (Action=0, Reason='مهاجرت داده‌ها') │
│ └─ INSERT UserClubFeatures (x4) │
│ (ClubFeatureId IN (1,2,3,4)) │
│ COMMIT TRANSACTION │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 4. Report: Success count, Errors, Summary │
└─────────────────────────────────────────────────────────┘
```
---
## ⚙️ Configuration Variables
```sql
DECLARE @InitialContribution BIGINT = 25000000; -- 25M به صندوق
DECLARE @ChargeAmount BIGINT = 56000000; -- 56M شارژ
DECLARE @CurrentDateTime DATETIME2(7) = SYSDATETIME();
```
**Adjustable**:
- `@ChargeAmount`: تغییر حداقل مبلغ شارژ
- `@InitialContribution`: تغییر سهم استخر
---
## 🧪 Testing Queries
### 1. شمارش کاربران واجد شرایط
```sql
-- نسخه کامل:
SELECT COUNT(DISTINCT u.Id)
FROM [CMS].[Users] u
INNER JOIN [CMS].[UserWallets] uw ON uw.UserId = u.Id
INNER JOIN [CMS].[UserWalletChangeLogs] uwcl ON uwcl.WalletId = uw.Id
WHERE u.IsDeleted = 0
AND uwcl.IsIncrease = 1
AND uwcl.ChangeValue > 0
GROUP BY u.Id
HAVING SUM(uwcl.ChangeValue) >= 56000000;
-- نسخه ساده:
SELECT COUNT(*)
FROM [CMS].[Users] u
INNER JOIN [CMS].[UserWallets] uw ON uw.UserId = u.Id
LEFT JOIN [CMS].[ClubMemberships] cm ON cm.UserId = u.Id
WHERE u.IsDeleted = 0
AND cm.Id IS NULL
AND uw.Balance >= 56000000;
```
### 2. تأیید ویژگی‌های ثبت شده
```sql
SELECT
cm.Id AS MembershipId,
cm.UserId,
u.FirstName + ' ' + u.LastName AS FullName,
cm.ActivatedAt,
COUNT(ucf.Id) AS FeaturesCount
FROM [CMS].[ClubMemberships] cm
INNER JOIN [CMS].[Users] u ON u.Id = cm.UserId
LEFT JOIN [CMS].[UserClubFeatures] ucf ON ucf.ClubMembershipId = cm.Id
WHERE cm.Created >= '2025-12-09' -- امروز
GROUP BY cm.Id, cm.UserId, u.FirstName, u.LastName, cm.ActivatedAt
HAVING COUNT(ucf.Id) != 4; -- باید 4 تا باشه!
```
### 3. چک کردن History
```sql
SELECT
h.UserId,
u.FirstName + ' ' + u.LastName AS FullName,
h.Action,
h.Reason,
h.Created
FROM [CMS].[ClubMembershipHistories] h
INNER JOIN [CMS].[Users] u ON u.Id = h.UserId
WHERE h.CreatedBy = 'MigrationScript'
ORDER BY h.Created DESC;
```
---
## 🚨 Error Handling
**Script Behavior**:
- ✅ هر transaction جداگانه → اگر یک کاربر fail شد، بقیه commit می‌شوند
- ✅ خطاها در `@ProcessLog` ذخیره می‌شوند
- ✅ گزارش نهایی شامل لیست کامل خطاها
**Common Errors**:
1. **"Invalid column 'UserName'"** → ستون وجود نداره (باید `FirstName + LastName`)
2. **"Invalid column 'ClubMembershipId'"** → در جدول `Users` نیست
3. **"Conversion failed 'Activated'"** → باید `0` باشه نه `'Activated'`
4. **"Transaction cannot be committed"** → نباید `SET XACT_ABORT ON` باشه با cursor
---
## 📝 Notes
1. **Idempotent**: اجرای مجدد اسکریپت، کاربران قبلی را skip می‌کند
2. **Rollback-Safe**: اگر کل script fail شد، چیزی commit نمی‌شه
3. **Performance**: برای 1000+ کاربر، ممکنه 5-10 دقیقه طول بکشه
4. **Logging**: تمام عملیات‌ها با `CreatedBy = 'MigrationScript'` قابل شناسایی هستند
---
## 🎯 Post-Migration Checklist
- [ ] شمارش کاربران مهاجرت شده = تعداد موردانتظار
- [ ] تمام اعضای جدید 4 ویژگی دارند (`UserClubFeatures.Count = 4`)
- [ ] همه `ClubMembershipHistories` با `Action = 0` ثبت شده‌اند
- [ ] مجموع `InitialContribution` با `ClubMemberships.Count × 25M` برابره
- [ ] هیچ خطایی در گزارش نهایی نیست (`@ErrorCount = 0`)
---
**Last Updated**: 2025-12-09
**Author**: Migration Script Generator
**Version**: 1.0
+310
View File
@@ -0,0 +1,310 @@
# مستندات سیستم کمیسیون (Commission System)
> **آخرین بروزرسانی**: ۲۹ آذر ۱۴۰۴ (19 December 2025)
---
## 📋 خلاصه
سیستم کمیسیون مسئول محاسبه، ذخیره و پرداخت کمیسیون‌های کاربران بر اساس ساختار شبکه بازاریابی است.
---
## 🗄️ موجودیت‌ها (Entities)
### WeekDefinition
جدول مرجع برای تعریف هفته‌های مالی:
```csharp
public class WeekDefinition : BaseAuditableEntity
{
public int WeekOrder { get; set; } // شماره ترتیبی هفته
public int Year { get; set; } // سال میلادی
public int PersianYear { get; set; } // سال شمسی
public DateTime StartDate { get; set; } // تاریخ شروع
public DateTime EndDate { get; set; } // تاریخ پایان
public string StartDatePersian { get; set; } // تاریخ شروع شمسی
public string EndDatePersian { get; set; } // تاریخ پایان شمسی
public bool IsActive { get; set; } // آیا هفته جاری است
}
```
### NetworkWeeklyBalance
تعادل هفتگی شاخه چپ و راست کاربر:
```csharp
public class NetworkWeeklyBalance : BaseAuditableEntity
{
public long UserId { get; set; }
public long WeekDefinitionId { get; set; } // FK به WeekDefinition
public long LeftBalance { get; set; } // امتیاز شاخه چپ
public long RightBalance { get; set; } // امتیاز شاخه راست
// Navigation Properties
public virtual User User { get; set; }
public virtual WeekDefinition WeekDefinition { get; set; }
}
```
**Index**: `(UserId, WeekDefinitionId)` - Unique
### WeeklyCommissionPool
استخر کمیسیون هفتگی:
```csharp
public class WeeklyCommissionPool : BaseAuditableEntity
{
public long WeekDefinitionId { get; set; } // FK به WeekDefinition
public long TotalPoolAmount { get; set; } // مجموع استخر
public long DistributedAmount { get; set; } // مقدار توزیع شده
public int TotalBalances { get; set; } // تعداد کل تعادل‌ها
public long PerBalanceAmount { get; set; } // مبلغ هر تعادل
public bool IsFinalized { get; set; } // آیا نهایی شده
// Navigation Property
public virtual WeekDefinition WeekDefinition { get; set; }
}
```
### UserCommissionPayout
رکورد پرداخت کمیسیون به کاربر:
```csharp
public class UserCommissionPayout : BaseAuditableEntity
{
public long UserId { get; set; }
public long WeekDefinitionId { get; set; } // FK به WeekDefinition
public int BalancesEarned { get; set; } // تعداد تعادل‌های کسب شده
public long Amount { get; set; } // مبلغ کمیسیون
public CommissionPayoutStatus Status { get; set; } // وضعیت پرداخت
public DateTime? PaidAt { get; set; } // تاریخ پرداخت
// Navigation Properties
public virtual User User { get; set; }
public virtual WeekDefinition WeekDefinition { get; set; }
}
```
**وضعیت‌ها (Status)**:
- `Created` - ایجاد شده
- `Paid` - پرداخت به کیف پول
- `WithdrawalRequested` - درخواست برداشت
- `Withdrawn` - برداشت شده
- `Cancelled` - لغو شده
### CommissionPayoutHistory
تاریخچه تغییرات وضعیت پرداخت:
```csharp
public class CommissionPayoutHistory : BaseAuditableEntity
{
public long UserCommissionPayoutId { get; set; }
public long WeekDefinitionId { get; set; } // FK به WeekDefinition
public CommissionPayoutStatus FromStatus { get; set; }
public CommissionPayoutStatus ToStatus { get; set; }
public string? Notes { get; set; }
// Navigation Properties
public virtual UserCommissionPayout UserCommissionPayout { get; set; }
public virtual WeekDefinition WeekDefinition { get; set; }
}
```
### WorkerExecutionLog
لاگ اجرای Worker های محاسبه کمیسیون:
```csharp
public class WorkerExecutionLog : BaseAuditableEntity
{
public string WorkerName { get; set; } // نام Worker
public long? WeekDefinitionId { get; set; } // FK به WeekDefinition (nullable)
public DateTime StartedAt { get; set; } // زمان شروع
public DateTime? CompletedAt { get; set; } // زمان پایان
public bool IsSuccess { get; set; } // موفقیت
public string? ErrorMessage { get; set; } // پیام خطا
public int ProcessedCount { get; set; } // تعداد پردازش شده
// Navigation Property
public virtual WeekDefinition? WeekDefinition { get; set; }
}
```
---
## 🔗 روابط (Relationships)
```
WeekDefinition (1) ─────┬──── (*) NetworkWeeklyBalance
├──── (*) WeeklyCommissionPool
├──── (*) UserCommissionPayout
├──── (*) CommissionPayoutHistory
└──── (*) WorkerExecutionLog
User (1) ───────────────┬──── (*) NetworkWeeklyBalance
└──── (*) UserCommissionPayout
UserCommissionPayout (1) ──── (*) CommissionPayoutHistory
```
---
## 📡 Proto Models
### UserCommissionPayoutModel
```protobuf
message UserCommissionPayoutModel {
int64 id = 1;
int64 user_id = 2;
string user_full_name = 3;
int64 week_definition_id = 4; // شناسه هفته
int32 balances_earned = 5; // تعداد تعادل
int64 amount = 6; // مبلغ
int32 status = 7; // وضعیت
google.protobuf.Timestamp paid_at = 8;
google.protobuf.Timestamp created = 9;
string mobile = 10;
string week_display_name = 11; // نام نمایشی هفته
}
```
### UserWeeklyBalanceModel
```protobuf
message UserWeeklyBalanceModel {
int64 user_id = 1;
int64 week_definition_id = 2; // شناسه هفته
int64 left_balance = 3;
int64 right_balance = 4;
string start_date_persian = 5;
string end_date_persian = 6;
int32 year = 7;
int32 week_order = 8;
bool is_active = 9;
string week_display_name = 10; // نام نمایشی هفته
}
```
---
## 🎯 نام‌گذاری فیلدها
### قبل از مایگریشن (Legacy)
```
WeekNumber: "2025-01" (string)
GregorianWeekNumber: "2025-01" (string)
PersianWeekNumber: "1403-40" (string)
WeekLabel: "هفته 1 - 1403/10/01"
```
### بعد از مایگریشن (Current)
```
WeekDefinitionId: 42 (long) // FK به جدول WeekDefinition
WeekDisplayName: "هفته 1 - 1403/10/01" // ساخته شده از WeekDefinition
```
**فرمول WeekDisplayName**:
```csharp
$"هفته {WeekDefinition.WeekOrder} - {WeekDefinition.StartDatePersian}"
```
---
## 📊 Query Examples
### دریافت کمیسیون‌های کاربر
```csharp
var payouts = await _context.UserCommissionPayouts
.Include(p => p.WeekDefinition)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekDefinition.WeekOrder)
.Select(p => new {
p.Id,
p.WeekDefinitionId,
WeekDisplayName = $"هفته {p.WeekDefinition.WeekOrder} - {p.WeekDefinition.StartDatePersian}",
p.BalancesEarned,
p.Amount,
p.Status
})
.ToListAsync();
```
### دریافت تعادل هفتگی
```csharp
var balance = await _context.NetworkWeeklyBalances
.Include(b => b.WeekDefinition)
.Where(b => b.UserId == userId && b.WeekDefinitionId == weekDefinitionId)
.Select(b => new {
b.WeekDefinitionId,
WeekDisplayName = $"هفته {b.WeekDefinition.WeekOrder} - {b.WeekDefinition.StartDatePersian}",
b.LeftBalance,
b.RightBalance,
b.WeekDefinition.StartDatePersian,
b.WeekDefinition.EndDatePersian
})
.FirstOrDefaultAsync();
```
---
## ⚠️ ملاحظات مایگریشن
### EF Migration
```bash
# ایجاد migration
dotnet ef migrations add MigrateWeekNumberToWeekDefinitionId \
-p CMSMicroservice.Infrastructure \
-s CMSMicroservice.WebApi
# اجرای migration
dotnet ef database update \
-p CMSMicroservice.Infrastructure \
-s CMSMicroservice.WebApi
```
### Data Migration Script
```sql
-- Step 1: Add new column
ALTER TABLE NetworkWeeklyBalances ADD WeekDefinitionId BIGINT NULL;
-- Step 2: Populate from WeekDefinitions
UPDATE nwb
SET nwb.WeekDefinitionId = wd.Id
FROM NetworkWeeklyBalances nwb
INNER JOIN WeekDefinitions wd ON
CONCAT(wd.Year, '-', RIGHT('0' + CAST(wd.WeekOrder AS VARCHAR), 2)) = nwb.WeekNumber;
-- Step 3: Add FK constraint
ALTER TABLE NetworkWeeklyBalances
ADD CONSTRAINT FK_NetworkWeeklyBalances_WeekDefinitions
FOREIGN KEY (WeekDefinitionId) REFERENCES WeekDefinitions(Id);
-- Step 4: Drop old column (after verification)
ALTER TABLE NetworkWeeklyBalances DROP COLUMN WeekNumber;
```
---
## 📝 تغییرات API
### Request Changes
```
// قبل
GET /api/commission/payouts?weekNumber=2025-01
// بعد
GET /api/commission/payouts?weekDefinitionId=42
```
### Response Changes
```json
// قبل
{
"weekNumber": "2025-01",
"weekLabel": "هفته 1 - 1403/10/01"
}
// بعد
{
"weekDefinitionId": 42,
"weekDisplayName": "هفته 1 - 1403/10/01"
}
```
+49 -1
View File
@@ -21,7 +21,53 @@
---
## 🆕 Recent Updates (2024-12-04)
## 🆕 Recent Updates (2025-12-09)
### ✅ Club Membership Auto-Features Enhancement
**Date**: 2025-12-09
**Feature**: اختصاص خودکار ویژگی‌های باشگاه به اعضای جدید
**Changes**:
1. **`ActivateClubMembershipCommandHandler.cs`**:
- بعد از ایجاد `ClubMembership` و ثبت `ClubMembershipHistory`
- به‌طور خودکار 4 ویژگی باشگاه (`ClubFeatureId IN (1,2,3,4)`) در جدول `UserClubFeatures` ثبت می‌شود
- فقط برای عضویت‌های جدید (`isNewMembership = true`)
- با `Notes = "اعطا شده به‌طور خودکار هنگام فعالسازی"`
2. **Migration Script**:
- `MigrateUsersToClubMembership.sql`: اسکریپت مهاجرت کاربران با ≥56M شارژ به باشگاه
- شامل:
- ایجاد `ClubMembership` (با `ActivatedAt` = تاریخ اولین شارژ)
- ثبت `ClubMembershipHistory` (با `Action = 0` = Activated)
- ایجاد 4 رکورد `UserClubFeatures` برای هر کاربر
- نسخه Simple: بر اساس موجودی فعلی (`UserWallets.Balance`)
**Business Logic**:
```csharp
// بعد از SaveChanges برای History:
if (isNewMembership)
{
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && new long[] { 1, 2, 3, 4 }.Contains(f.Id))
.ToListAsync(cancellationToken);
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = entity.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
Notes = "اعطا شده به‌طور خودکار هنگام فعالسازی"
}).ToList();
_context.UserClubFeatures.AddRange(userClubFeatures);
}
```
---
## 🆕 Previous Updates (2024-12-04)
### ✅ Phase 9: Club Discount Shop System Implementation (Complete)
@@ -642,6 +688,8 @@ if (vatEnabled) {
- ✅ `ActivateClubMembershipCommand` - Activate user's club membership
- Creates new or reactivates existing membership
- Records history with Activated action
- **اضافه شده 2025-12-09**: اختصاص خودکار 4 ویژگی باشگاه (`UserClubFeatures`) برای اعضای جدید
- `ClubFeatureId IN (1, 2, 3, 4)` به‌طور خودکار ثبت می‌شوند
- ✅ `DeactivateClubMembershipCommand` - Deactivate membership
- Sets IsActive = false, records history
- ✅ `UpdateClubMembershipCommand` - Update membership details
@@ -0,0 +1,642 @@
# Network Tree - Activation Week Feature
## نمای کلی (Overview)
این سند تغییرات مربوط به افزودن قابلیت فیلتر و نمایش هفته فعال‌سازی در درخت شبکه را توضیح می‌دهد.
**تاریخ پیاده‌سازی:** دسامبر 2025
**تغییرات کلیدی:**
- اضافه شدن فیلد `IsActivatedInTargetWeek` برای flagging (به جای filtering)
- حذف فیلتر سمت Backend و انتقال به UI
- نمایش بصری وضعیت فعال‌سازی در درخت
---
## منطق کسب‌وکار (Business Logic)
### رویکرد قبلی (❌ Removed)
- فیلتر می‌کرد و فقط نودهایی که در هفته هدف فعال شده‌اند نمایش داده می‌شدند
- مشکل: کاربران نمی‌توانستند کل ساختار شبکه را ببینند
### رویکرد جدید (✅ Current)
- **همه نودها نمایش داده می‌شوند** (بدون فیلتر در دیتابیس)
- هر نود یک flag دارد: `IsActivatedInTargetWeek`
- UI از این flag برای نمایش بصری استفاده می‌کند
### محاسبه هفته فعال‌سازی
```csharp
private static int CalculateWeekNumber(DateTimeOffset date)
{
var persianCalendar = new PersianCalendar();
int year = persianCalendar.GetYear(date.DateTime);
int dayOfYear = persianCalendar.GetDayOfYear(date.DateTime);
int weekNumber = (dayOfYear - 1) / 7 + 1;
return int.Parse($"{year}{weekNumber:D2}");
// مثال: 140352 = سال 1403، هفته 52
}
```
---
## تغییرات Backend
### 1. DTO Changes
**فایل:** `CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkTree/NetworkTreeDto.cs`
```csharp
public class NetworkTreeDto
{
// ... existing fields
public string? ActivationWeekNumber { get; set; }
public bool IsActivatedInTargetWeek { get; set; } // ✅ NEW
public DateTimeOffset UserCreated { get; set; }
public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; }
}
```
### 2. Query Handler Changes
**فایل:** `CMSMicroservice.Application/NetworkMembershipCQ/Queries/GetNetworkTree/GetNetworkTreeQueryHandler.cs`
#### تغییر در BuildTree Method
```csharp
private NetworkTreeDto BuildTree(
User user,
int currentDepth,
int maxDepth,
string? requestActivationWeekNumber) // ✅ پارامتر اضافه شد
{
// محاسبه هفته فعال‌سازی
string? activationWeekNumber = null;
bool isActivatedInTargetWeek = false;
if (user.ClubMembership?.ActivatedAt != null)
{
activationWeekNumber = CalculateWeekNumber(user.ClubMembership.ActivatedAt.Value)
.ToString();
// چک کردن اینکه آیا در هفته هدف فعال شده
if (!string.IsNullOrEmpty(requestActivationWeekNumber))
{
isActivatedInTargetWeek = activationWeekNumber == requestActivationWeekNumber;
}
}
var node = new NetworkTreeDto
{
// ... existing fields
ActivationWeekNumber = activationWeekNumber,
IsActivatedInTargetWeek = isActivatedInTargetWeek, // ✅ تنظیم flag
};
// ... recursive calls
}
```
#### حذف فیلتر از GetFilteredChildren
**قبل (❌):**
```csharp
private IEnumerable<User> GetFilteredChildren(
IEnumerable<User> children,
bool? isClubActive,
string? activationWeekNumber)
{
var query = children.AsQueryable();
if (isClubActive.HasValue)
{
query = query.Where(u => u.ClubMembership != null &&
u.ClubMembership.IsActive == isClubActive.Value);
}
if (!string.IsNullOrEmpty(activationWeekNumber))
{
// ❌ فیلتر می‌کرد
query = query.Where(u => /* filter logic */);
}
return query.ToList();
}
```
**بعد (✅):**
```csharp
private IEnumerable<User> GetFilteredChildren(
IEnumerable<User> children,
bool? isClubActive)
{
var query = children.AsQueryable();
// فقط فیلتر IsClubActive باقی ماند
if (isClubActive.HasValue)
{
query = query.Where(u => u.ClubMembership != null &&
u.ClubMembership.IsActive == isClubActive.Value);
}
return query.ToList();
}
```
### 3. Proto Definition
**فایل:** `CMSMicroservice.Protobuf/Protos/networkmembership.proto`
```protobuf
message NetworkTreeNodeModel {
int64 user_id = 1;
string user_name = 2;
optional int64 parent_id = 3;
optional int32 network_leg = 4;
optional int32 network_level = 5;
optional bool is_active = 6;
optional google.protobuf.Timestamp joined_at = 7;
optional google.protobuf.Timestamp club_activated_at = 8;
bool is_club_active = 9;
string activation_week_number = 10;
bool is_activated_in_target_week = 11; // ✅ NEW
google.protobuf.Timestamp user_created = 12;
}
```
### 4. Mapping
**فایل:** `CMSMicroservice.WebApi/Common/Mappings/NetworkMembershipProfile.cs`
```csharp
var protoNode = new NetworkTreeNodeModel
{
UserId = node.UserId,
UserName = node.UserName,
ParentId = node.ParentId,
NetworkLeg = node.NetworkLeg,
NetworkLevel = node.NetworkLevel,
IsActive = node.IsActive,
JoinedAt = node.JoinedAt.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(node.JoinedAt.Value, DateTimeKind.Utc))
: null,
ClubActivatedAt = node.ClubActivatedAt.HasValue
? Timestamp.FromDateTime(DateTime.SpecifyKind(node.ClubActivatedAt.Value, DateTimeKind.Utc))
: null,
IsClubActive = node.IsClubActive,
ActivationWeekNumber = node.ActivationWeekNumber ?? string.Empty,
IsActivatedInTargetWeek = node.IsActivatedInTargetWeek, // ✅ NEW
UserCreated = Timestamp.FromDateTime(DateTime.SpecifyKind(node.UserCreated, DateTimeKind.Utc))
};
```
---
## تغییرات BFF
### Proto & Mapping
همان تغییرات در CMS در BFF هم اعمال شد:
**فایل‌ها:**
- `BackOffice.BFF.Application/NetworkMembershipCQ/Queries/GetNetworkTree/GetNetworkTreeResponseDto.cs`
- `BackOffice.BFF.WebApi/Common/Mappings/NetworkMembershipProfile.cs`
- `Protobufs/networkmembership.proto`
```csharp
public class NetworkTreeNodeDto
{
// ... existing properties
public bool IsActivatedInTargetWeek { get; set; } // ✅ NEW
public string ActivationWeekNumber { get; set; } = string.Empty;
}
```
---
## تغییرات Frontend
### 1. Razor Component
**فایل:** `BackOffice/Pages/Network/NetworkTreeViewer.razor`
#### تغییر در ستون "وضعیت"
**قبل (❌):**
```razor
<PropertyColumn Property="x => x.IsActive" Title="وضعیت">
<CellTemplate>
@if (context.Item.IsActive!=null) {
<MudChip Color="@((bool)context.Item.IsActive ? Color.Success : Color.Error)">
@((bool)context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
}
</CellTemplate>
</PropertyColumn>
```
**بعد (✅):**
```razor
<PropertyColumn Property="x => x.IsClubActive" Title="وضعیت">
<CellTemplate>
<MudChip T="string"
Color="@(context.Item.IsClubActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(context.Item.IsClubActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
```
#### ارسال داده به JavaScript
```csharp
private async Task RenderTree()
{
if (_treeData == null || !_treeData.Nodes.Any()) return;
var jsNodes = _treeData.Nodes.Select(n => new
{
userId = n.UserId,
userName = n.UserName,
parentId = n.ParentId,
networkLevel = n.NetworkLevel,
networkLeg = n.NetworkLeg,
isActive = n.IsClubActive, // ✅ تغییر به IsClubActive
isClubActive = n.IsClubActive,
isActivatedInTargetWeek = n.IsActivatedInTargetWeek, // ✅ NEW
activationWeekNumber = _activationWeekFilter ?? "", // ✅ فیلتر UI
clubActivatedAt = n.ClubActivatedAt?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? "",
userCreated = n.UserCreated?.ToDateTime().ToLocalTime().ToString("yyyy/MM/dd") ?? ""
}).ToArray();
await JS.InvokeVoidAsync("NetworkTreeViewer.initialize", "network-tree-container", jsNodes);
}
```
**نکته مهم:** `activationWeekNumber` از فیلتر UI گرفته می‌شود (`_activationWeekFilter`) نه از Backend.
### 2. JavaScript Visualization
**فایل:** `BackOffice/wwwroot/js/network-tree.js`
#### منطق رنگ نود (دایره)
```javascript
node.append('circle')
.attr('r', 8)
.style('fill', d => {
// اگر هفته‌ای انتخاب نشده، همه سبز
if (!d.data.activationWeekNumber || d.data.activationWeekNumber === '') {
return '#4caf50';
}
// اگر در هفته هدف فعال شده، سبز، وگرنه قرمز
return d.data.isActivatedInTargetWeek ? '#4caf50' : '#f44336';
})
.style('stroke', '#fff')
.style('stroke-width', 2)
.style('cursor', 'pointer');
```
#### منطق رنگ تایتل (نام کاربر)
```javascript
node.append('text')
.attr('dy', -15)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('fill', d => d.data.isClubActive ? '#424242' : '#9e9e9e')
.text(d => d.data.userName || `User ${d.data.userId}`);
```
#### اضافه کردن فیلدها به buildHierarchy
```javascript
buildHierarchy: function(nodes) {
// ...
const nodeMap = new Map();
nodes.forEach(node => {
nodeMap.set(node.userId, {
userId: node.userId,
userName: node.userName,
parentId: node.parentId,
level: node.networkLevel,
networkLeg: node.networkLeg,
isActive: node.isActive,
isClubActive: node.isClubActive, // ✅ NEW
isActivatedInTargetWeek: node.isActivatedInTargetWeek, // ✅ NEW
activationWeekNumber: node.activationWeekNumber, // ✅ NEW
clubActivatedAt: node.clubActivatedAt,
userCreated: node.userCreated,
children: []
});
});
// ...
}
```
#### Legend (راهنمای رنگ‌ها)
```javascript
// Legend for title colors (club status)
legend.append('text')
.attr('x', 0)
.attr('y', 0)
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('fill', '#424242')
.text('باشگاه فعال');
legend.append('text')
.attr('x', 0)
.attr('y', 20)
.style('font-size', '12px')
.style('font-weight', 'bold')
.style('fill', '#9e9e9e')
.text('باشگاه غیرفعال');
// Legend for circles (week status)
legend.append('circle')
.attr('cx', 0)
.attr('cy', 50)
.attr('r', 6)
.style('fill', '#4caf50');
legend.append('text')
.attr('x', 12)
.attr('y', 54)
.style('font-size', '12px')
.text('فعال در هفته هدف');
legend.append('circle')
.attr('cx', 0)
.attr('cy', 75)
.attr('r', 6)
.style('fill', '#f44336');
legend.append('text')
.attr('x', 12)
.attr('y', 79)
.style('font-size', '12px')
.text('خارج از هفته هدف');
```
---
## رفتار UI
### حالت 1: بدون فیلتر هفته
**وضعیت:** `_activationWeekFilter` خالی است
**رفتار:**
- **دایره‌ها:** همه سبز (#4caf50)
- **تایتل:** مشکی (#424242) برای باشگاه فعال، خاکستری (#9e9e9e) برای باشگاه غیرفعال
### حالت 2: با فیلتر هفته
**وضعیت:** مثلاً `_activationWeekFilter = "140352"`
**رفتار:**
- **دایره‌ها:**
- سبز (#4caf50) → کاربران فعال شده در هفته 52 سال 1403
- قرمز (#f44336) → کاربران فعال شده در هفته‌های دیگر
- **تایتل:** همچنان بر اساس `isClubActive`
### حالت 3: فیلتر IsClubActive
این فیلتر در سمت Backend اعمال می‌شود و نودهای غیرفعال را حذف می‌کند.
---
## Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ User Interface │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ IsClubActive │ │ActivationWeek │ │
│ │ Filter │ │ Filter │ │
│ └────────┬───────┘ └────────┬─────────┘ │
└───────────┼──────────────────┼────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (CMS) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ GetNetworkTreeQueryHandler │ │
│ │ │ │
│ │ 1. GetFilteredChildren (IsClubActive filter only) │ │
│ │ 2. BuildTree (calculate IsActivatedInTargetWeek) │ │
│ │ 3. Return ALL nodes with flags │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BFF Layer │
│ - Proto mapping │
│ - Pass-through to Frontend │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Frontend (Blazor) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ NetworkTreeViewer.razor │ │
│ │ │ │
│ │ - Prepare data with UI filter (_activationWeekFilter)│ │
│ │ - Send to JavaScript │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ JavaScript (D3.js) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ network-tree.js │ │
│ │ │ │
│ │ - Apply visual logic: │ │
│ │ * Circle color by activationWeekNumber + flag │ │
│ │ * Title color by isClubActive │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Data Model
### Request
```csharp
public class GetNetworkTreeRequest
{
public long UserId { get; set; }
public int? MaxDepth { get; set; }
public bool? IsClubActive { get; set; } // Backend filter
public string? ActivationWeekNumber { get; set; } // For flag calculation only
}
```
### Response
```csharp
public class NetworkTreeDto
{
public long UserId { get; set; }
public string UserName { get; set; }
public long? ParentId { get; set; }
public int? NetworkLeg { get; set; }
public int? NetworkLevel { get; set; }
public bool? IsActive { get; set; } // Deprecated
public DateTime? JoinedAt { get; set; }
public DateTime? ClubActivatedAt { get; set; }
public bool IsClubActive { get; set; } // ✅ Use this
public string? ActivationWeekNumber { get; set; }
public bool IsActivatedInTargetWeek { get; set; } // ✅ NEW
public DateTimeOffset UserCreated { get; set; }
public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; }
}
```
---
## Testing Scenarios
### Test 1: بدون فیلتر
**Input:**
- `IsClubActive`: null
- `ActivationWeekNumber`: null
**Expected:**
- همه نودها نمایش داده شوند
- همه دایره‌ها سبز
- تایتل‌ها بر اساس IsClubActive
### Test 2: فیلتر باشگاه فعال
**Input:**
- `IsClubActive`: true
- `ActivationWeekNumber`: null
**Expected:**
- فقط نودهای با باشگاه فعال
- همه دایره‌ها سبز
- همه تایتل‌ها مشکی
### Test 3: فیلتر هفته
**Input:**
- `IsClubActive`: null
- `ActivationWeekNumber`: "140352"
**Expected:**
- همه نودها نمایش داده شوند
- دایره سبز: فعال شده در هفته 52
- دایره قرمز: فعال شده در هفته‌های دیگر
- تایتل‌ها بر اساس IsClubActive
### Test 4: ترکیب فیلترها
**Input:**
- `IsClubActive`: true
- `ActivationWeekNumber`: "140352"
**Expected:**
- فقط نودهای با باشگاه فعال
- دایره سبز: فعال شده در هفته 52
- دایره قرمز: فعال شده در هفته‌های دیگر
- همه تایتل‌ها مشکی (چون همه باشگاه فعال دارند)
---
## Performance Considerations
### Database Query
- ✅ فیلتر `ActivationWeekNumber` از Query حذف شد
- ✅ فقط فیلتر `IsClubActive` در سمت دیتابیس
- ⚠️ ممکن است تعداد نودهای بیشتری بازگردانده شود
### Memory
- Backend همه نودها را می‌فرستد
- Frontend/JavaScript فیلتر بصری اعمال می‌کند
- برای درخت‌های بسیار بزرگ (>1000 نود) ممکن است نیاز به pagination باشد
### UI Rendering
- D3.js برای درخت‌های متوسط (<500 نود) عملکرد خوبی دارد
- برای بهبود عملکرد می‌توان از virtualization استفاده کرد
---
## Migration Notes
### Breaking Changes
-`IsActive` deprecated است → استفاده از `IsClubActive`
- ✅ فیلد جدید `IsActivatedInTargetWeek` اضافه شد
### Backward Compatibility
- Proto field numbers حفظ شده‌اند
- Response structure تغییر نکرده (فقط فیلد جدید اضافه شده)
### Deployment Steps
1. Deploy Backend (CMS) با Proto جدید
2. Deploy BFF با Proto جدید
3. Deploy Frontend با visualization جدید
4. تست تمام scenarios
---
## نکات مهم (Key Points)
### ✅ Do's
- از `IsClubActive` برای وضعیت باشگاه استفاده کنید
- `IsActivatedInTargetWeek` فقط برای نمایش بصری است
- فیلتر UI را از Razor به JS بفرستید (`_activationWeekFilter`)
### ❌ Don'ts
- از `IsActive` استفاده نکنید (deprecated)
- `ActivationWeekNumber` را از Backend برای UI filtering استفاده نکنید
- فیلتر `ActivationWeekNumber` را در Query اعمال نکنید
### 💡 Best Practices
- همیشه فیلتر UI و Backend flag را sync نگه دارید
- برای درخت‌های بزرگ از lazy loading استفاده کنید
- Legend را همیشه با منطق UI sync کنید
---
## فایل‌های تغییر یافته
### Backend (CMS)
-`NetworkTreeDto.cs` - اضافه `IsActivatedInTargetWeek`
-`GetNetworkTreeQueryHandler.cs` - محاسبه flag + حذف فیلتر
-`networkmembership.proto` - اضافه field 11
-`NetworkMembershipProfile.cs` - mapping فیلد جدید
### BFF
-`GetNetworkTreeResponseDto.cs` - اضافه property
-`NetworkMembershipProfile.cs` - mapping
-`networkmembership.proto` - sync با CMS
### Frontend
-`NetworkTreeViewer.razor` - تغییر `IsActive``IsClubActive`
-`NetworkTreeViewer.razor` - اضافه `isActivatedInTargetWeek` به jsNodes
-`network-tree.js` - منطق رنگ نود بر اساس flag
-`network-tree.js` - منطق رنگ تایتل بر اساس `isClubActive`
-`network-tree.js` - Legend جدید
---
## مراجع (References)
- [Binary Tree Guide](../../01-BUSINESS/binary-tree-guide.md)
- [Network Commission System](../../01-BUSINESS/network-commission-system.md)
- [CMS API Coverage](./api-coverage.md)
---
**تاریخ ایجاد:** 14 دسامبر 2025
**آخرین به‌روزرسانی:** 14 دسامبر 2025
**نویسنده:** Development Team
+3 -1
View File
@@ -59,6 +59,7 @@ FrontOffice.BFF/
- `GetMyNetworkPosition` - موقعیت کاربر در شبکه
- `GetMyNetworkStatistics` - آمار شبکه
- `GetMyNetworkTree` - درخت شبکه
- `GetSubordinateTree` - درخت زیرمجموعه (NEW - ۲۸ آذر)
### ClubMembershipCQ
عضویت باشگاه مشتریان
@@ -107,4 +108,5 @@ dotnet run --project FrontOffice.BFF.WebApi
```
## Last Updated
January 2025 - Added DiscountShop integration (4 gRPC clients, 7 handlers, Proto service)
- **28 آذر ۱۴۰۴**: Added `GetSubordinateTree` handler for viewing subordinate network trees
- **January 2025**: Added DiscountShop integration (4 gRPC clients, 7 handlers, Proto service)