31 Commits

Author SHA1 Message Date
masoodafar-web e383c64e5b update
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:47:41 +03:30
masoodafar-web f5642f88fb update
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:33:25 +03:30
masoodafar-web 14b5db645b chore: format appsettings.json for consistency
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:30:45 +03:30
masoodafar-web 14b4022de9 chore: update database connection string in appsettings.json
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:25:54 +03:30
masoodafar-web d24f326fbc chore: update cms-stage workflow branch to stage_new
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:21:28 +03:30
masoodafar-web 50ba9e3794 chore: remove duplicate cms-stage workflow file 2025-12-20 04:21:15 +03:30
masoodafar-web c5915bb074 feat: add GitHub Actions workflow for NuGet and Docker image deployment
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:19:22 +03:30
masoodafar-web 25476ba120 feat: implement commission calculation strategy with ORM and SP options
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m43s
2025-12-20 03:15:28 +03:30
masoodafar-web 9864302bab feat: implement user wallet charging and transaction logging for Daya loan processing 2025-12-19 23:14:42 +03:30
masoodafar-web 8b784101be feat: refactor week number handling to use WeekDefinitionId across multiple entities and queries 2025-12-19 10:41:22 +03:30
masoodafar-web 3cb95dc349 feat: enhance user club feature management with sorting and creation timestamps 2025-12-19 00:30:47 +03:30
masoodafar-web c7211fb9a7 feat: update entity configurations to specify column types for cities, states, and countries 2025-12-18 03:49:47 +03:30
masoodafar-web ead0cf4235 feat: add geography entities with countries, states and cities 2025-12-18 00:43:55 +03:30
masoodafar-web 26e1243cfe feat: add base package payment initiation and verification
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m39s
2025-12-16 02:34:57 +03:30
masoodafar-web 189812e874 feat: update Daya API base address to production
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m18s
2025-12-14 20:49:31 +03:30
masoodafar-web 730e5a252b feat: add target week activation flag to network membership
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m28s
2025-12-14 03:35:40 +03:30
masoodafar-web cf75f14003 feat: add club membership filters and activation details to network tree
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m13s
2025-12-14 02:56:04 +03:30
masoodafar-web 6ae1b0cd70 feat: update commission payout model with user names and timestamps
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m11s
2025-12-12 19:31:32 +03:30
masoodafar-web 2ae6034fbb refactor: remove admin user id from manual payment command
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m13s
2025-12-12 10:22:09 +03:30
masoodafar-web 094846ce8b feat: add manual membership payment processing with wallet and order management
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m8s
2025-12-12 10:07:14 +03:30
masoodafar-web b19cf5e32e fix: use Gregorian week number for API and Persian for display
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m18s
2025-12-12 09:39:20 +03:30
masoodafar-web b1c3fcfd66 feat: add Persian calendar support for week numbers and dates
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m35s
2025-12-12 09:18:31 +03:30
masoodafar-web 1f6c5a1f45 fix: update default sorting from descending to ascending Created
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m43s
2025-12-12 07:49:54 +03:30
masoodafar-web 99b217d5b5 fix: change weekly balance sorting from WeekNumber to Created
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m39s
2025-12-12 07:32:22 +03:30
masoodafar-web 8ee3fe6f7b fix: update week calculation to start from Saturday
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m2s
2025-12-12 07:07:28 +03:30
masoodafar-web 3c7ac68eeb feat: enhance network membership response with detailed stats and hierarchy
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m14s
2025-12-12 06:07:23 +03:30
masoodafar-web f27418cff4 fix: adjust network tree depth limit and update commission mapping
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m5s
2025-12-12 05:42:19 +03:30
masoodafar-web aba534e07c fix: update week calculation to use Saturday as the start of the week
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m12s
2025-12-12 04:37:34 +03:30
masoodafar-web af3a29ed27 fix: adjust week calculation to FirstDay and Saturday
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m9s
2025-12-12 04:23:14 +03:30
masoodafar-web db951699f8 fix: handle null pool and adjust week calculation rules
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2025-12-12 04:22:16 +03:30
masoodafar-web bb6b7c709c feat: add search text filter for user query
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m40s
2025-12-12 03:18:18 +03:30
180 changed files with 33660 additions and 599 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ name: Push nuget and docker image Actions Workflow
on:
push:
branches:
- stage
- stage_new
jobs:
Deploy:
runs-on: windows
@@ -12,7 +12,10 @@
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CMSMicroservice.Domain\CMSMicroservice.Domain.csproj" />
@@ -0,0 +1,42 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public record GetAllCitiesByFilterQuery : IRequest<GetAllCitiesByFilterResponseDto>
{
/// <summary>
/// موقعیت صفحه بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// فیلتر
/// </summary>
public GetAllCitiesByFilterFilter? Filter { get; init; }
}
public class GetAllCitiesByFilterFilter
{
/// <summary>
/// شناسه
/// </summary>
public long? Id { get; set; }
/// <summary>
/// نام شهر (Contains)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// نام بومی شهر (Contains)
/// </summary>
public string? Native { get; set; }
/// <summary>
/// شناسه استان
/// </summary>
public long? StateId { get; set; }
}
@@ -0,0 +1,43 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryHandler : IRequestHandler<GetAllCitiesByFilterQuery, GetAllCitiesByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllCitiesByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllCitiesByFilterResponseDto> Handle(
GetAllCitiesByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.Cities
.Include(c => c.State)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.Id == null || x.Id == request.Filter.Id)
.Where(x => request.Filter.Name == null || x.Name.Contains(request.Filter.Name))
.Where(x => request.Filter.Native == null || x.Native.Contains(request.Filter.Native))
.Where(x => request.Filter.StateId == null || x.StateId == request.Filter.StateId);
}
return new GetAllCitiesByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.ProjectToType<GetAllCitiesByFilterResponseModel>()
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryValidator : AbstractValidator<GetAllCitiesByFilterQuery>
{
public GetAllCitiesByFilterQueryValidator()
{
// Validation rules اگر نیاز باشد
}
public async Task<bool> Validate(object model, string propertyName)
{
var result = await ValidateAsync(
ValidationContext<GetAllCitiesByFilterQuery>.CreateWithOptions(
(GetAllCitiesByFilterQuery)model,
x => x.IncludeProperties(propertyName)));
return result.IsValid;
}
}
@@ -0,0 +1,62 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterResponseDto
{
/// <summary>
/// متادیتا
/// </summary>
public MetaData MetaData { get; set; } = null!;
/// <summary>
/// مدل خروجی
/// </summary>
public List<GetAllCitiesByFilterResponseModel>? Models { get; set; }
}
public class GetAllCitiesByFilterResponseModel
{
/// <summary>
/// شناسه
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام شهر
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// نام بومی شهر (فارسی)
/// </summary>
public string Native { get; set; } = null!;
/// <summary>
/// عرض جغرافیایی
/// </summary>
public string Latitude { get; set; } = null!;
/// <summary>
/// طول جغرافیایی
/// </summary>
public string Longitude { get; set; } = null!;
/// <summary>
/// شناسه استان
/// </summary>
public long StateId { get; set; }
/// <summary>
/// نام استان
/// </summary>
public string StateName { get; set; } = null!;
/// <summary>
/// نام بومی استان
/// </summary>
public string StateNative { get; set; } = null!;
}
@@ -31,8 +31,11 @@ public class GetUserClubFeaturesQueryHandler : IRequestHandler<GetUserClubFeatur
FeatureDescription = ucf.ClubFeature.Description,
IsActive = ucf.IsActive,
GrantedAt = ucf.GrantedAt,
Notes = ucf.Notes
Notes = ucf.Notes,
SortOrder = ucf.ClubFeature.SortOrder,
CreatedAt = ucf.Created
})
.OrderBy(ucf => ucf.SortOrder)
.ToListAsync(cancellationToken);
return userClubFeatures;
@@ -31,7 +31,7 @@ public class UserClubFeatureDto
public string FeatureTitle { get; set; }
/// <summary>
/// توضیحات ویژگی
/// توضیحات کوتاه ویژگی
/// </summary>
public string? FeatureDescription { get; set; }
@@ -46,7 +46,17 @@ public class UserClubFeatureDto
public DateTime GrantedAt { get; set; }
/// <summary>
/// یادداشت
/// یادداشت (برای نمایش در مدال جزئیات)
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// ترتیب نمایش
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// تاریخ ایجاد رکورد
/// </summary>
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Command برای پذیرش قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand : IRequest<AcceptClubMembershipContractResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// کد OTP برای تایید
/// </summary>
public string OtpCode { get; init; }
/// <summary>
/// شناسه یکتای امضا (GUID)
/// </summary>
public string SignGuid { get; init; }
/// <summary>
/// محتوای HTML قرارداد
/// </summary>
public string ContractHtml { get; init; }
}
/// <summary>
/// DTO پاسخ پذیرش قرارداد باشگاه مشتریان
/// </summary>
public class AcceptClubMembershipContractResponseDto
{
/// <summary>
/// آیا عملیات موفق بود؟
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام نتیجه
/// </summary>
public string Message { get; set; }
/// <summary>
/// شناسه قرارداد ثبت شده
/// </summary>
public long ContractId { get; set; }
}
@@ -0,0 +1,196 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Handler برای پذیرش قرارداد باشگاه مشتریان
/// این handler:
/// 1. کد OTP را تایید می‌کند
/// 2. قرارداد را در جدول UserContract ثبت می‌کند
/// 3. باشگاه مشتری را فعال می‌کند (IsActive = true)
/// </summary>
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IConfiguration _cfg;
private readonly IHashService _hashService;
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
private const int MaxAttempts = 5;
private const string OtpPurpose = "signClubContract";
public AcceptClubMembershipContractCommandHandler(
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_logger = logger;
}
public async Task<AcceptClubMembershipContractResponseDto> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
request.UserId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی خرید پکیج
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "برای امضای قرارداد باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید"
};
}
// 3. بررسی عدم فعال بودن قبلی باشگاه
if (user.ClubMembership?.IsActive == true)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "شما قبلاً عضو باشگاه مشتریان شده‌اید"
};
}
// 4. تایید OTP
var otpResult = await VerifyOtpAsync(user.Mobile, request.OtpCode, cancellationToken);
if (!otpResult.Success)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = otpResult.Message
};
}
// 5. ثبت قرارداد در جدول UserContract
var contract = await _context.Contracts
.FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);
if (contract == null)
{
// اگر قرارداد وجود ندارد، یک قرارداد پیش‌فرض ایجاد کنید
contract = new Contract
{
Title = "قرارداد باشگاه مشتریان",
Description = "قوانین و مقررات باشگاه مشتریان کارابازار",
HtmlContent = request.ContractHtml,
Type = ContractType.ClubMembership
};
await _context.Contracts.AddAsync(contract, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
var userContract = new UserContract
{
UserId = user.Id,
ContractId = contract.Id,
SignGuid = request.SignGuid,
SignedPdfFile = request.ContractHtml
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. فعال‌سازی باشگاه مشتریان
if (user.ClubMembership == null)
{
user.ClubMembership = new Domain.Entities.Club.ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000,
GiftValue = 25_200_000,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
}
else
{
user.ClubMembership.IsActive = true;
user.ClubMembership.ActivatedAt = DateTime.Now;
_context.ClubMemberships.Update(user.ClubMembership);
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
request.UserId,
userContract.Id
);
return new AcceptClubMembershipContractResponseDto
{
Success = true,
Message = "قرارداد باشگاه مشتریان با موفقیت ثبت شد و عضویت شما فعال گردید",
ContractId = userContract.Id
};
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
CancellationToken cancellationToken)
{
var normalizedMobile = mobile.NormalizeIranMobile();
var now = DateTime.Now;
var otp = await _context.OtpTokens
.Where(o => o.Mobile == normalizedMobile
&& o.Purpose == OtpPurpose
&& !o.IsUsed
&& o.ExpiresAt > now)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (otp == null)
{
return (false, "کد تایید پیدا نشد یا منقضی شده است");
}
if (otp.Attempts >= MaxAttempts)
{
return (false, "تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کد جدید دریافت کنید");
}
otp.Attempts++;
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not configured");
if (!_hashService.VerifyHmacSha256Hex(code, otp.CodeHash, secret))
{
await _context.SaveChangesAsync(cancellationToken);
return (false, "کد تایید نادرست است");
}
// کد صحیح است - علامت‌گذاری به عنوان استفاده شده
otp.IsUsed = true;
await _context.SaveChangesAsync(cancellationToken);
return (true, "کد تایید صحیح است");
}
}
@@ -0,0 +1,25 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandValidator : AbstractValidator<AcceptClubMembershipContractCommand>
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
.Length(6)
.WithMessage("کد تایید باید ۶ رقم باشد");
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضا الزامی است");
RuleFor(x => x.ContractHtml)
.NotEmpty()
.WithMessage("محتوای قرارداد الزامی است");
}
}
@@ -17,15 +17,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly ILogger<ActivateClubMembershipCommandHandler> _logger;
public ActivateClubMembershipCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<ActivateClubMembershipCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger;
}
@@ -250,16 +253,16 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken);
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekNumber = GetCurrentWeekNumber();
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekNumber == currentWeekNumber, cancellationToken);
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
{
// ایجاد Pool جدید برای این هفته
weeklyPool = new WeeklyCommissionPool
{
WeekNumber = currentWeekNumber,
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
@@ -270,8 +273,8 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for {WeekNumber} with initial amount: {Amount}",
currentWeekNumber,
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
giftValue
);
}
@@ -282,9 +285,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for {WeekNumber}. New total: {NewTotal}",
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekNumber,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
@@ -346,13 +349,15 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
}
/// <summary>
/// دریافت شماره هفته جاری (مثال: "2025-W50")
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private string GetCurrentWeekNumber()
private long GetCurrentWeekDefinitionId()
{
var now = DateTime.Now;
var calendar = CultureInfo.CurrentCulture.Calendar;
var weekOfYear = calendar.GetWeekOfYear(now, CalendarWeekRule.FirstDay, DayOfWeek.Saturday);
return $"{now.Year}-W{weekOfYear:D2}";
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
}
@@ -12,6 +12,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken)
{
var query = _context.ClubMemberships
.Include(x => x.User)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
@@ -33,6 +34,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
{
Id = x.Id,
UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt,
InitialContribution = x.InitialContribution,
@@ -10,6 +10,7 @@ public class GetAllClubMembershipsResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; }
@@ -25,7 +25,7 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
query = query.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); // پیش‌فرض: جدیدترین اول
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیش‌فرض: جدیدترین اول
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -8,7 +8,7 @@ public record CalculateWeeklyBalancesCommand : IRequest<int>
/// <summary>
/// شماره هفته (فرمت: YYYY-Www مثل 2025-W01)
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
/// <summary>
/// آیا محاسبه مجدد انجام شود؟ (پیش‌فرض: false)
@@ -3,22 +3,33 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalan
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public CalculateWeeklyBalancesCommandHandler(IApplicationDbContext context)
public CalculateWeeklyBalancesCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{
_context = context;
_weekRepository = weekRepository;
}
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود محاسبه قبلی
var existingBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
if (existingBalances.Any() && !request.ForceRecalculate)
{
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekDefinitionId} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
}
// حذف محاسبات قبلی در صورت ForceRecalculate
@@ -43,16 +54,22 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
.ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber);
var previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == previousWeekNumber)
.Select(x => new
{
x.UserId,
x.LeftLegRemainder,
x.RightLegRemainder
})
.ToDictionaryAsync(x => x.UserId, cancellationToken);
var previousWeekDefinitionId = GetPreviousWeekDefinitionId(request.WeekDefinitionId);
Dictionary<long, (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers;
if (previousWeekDefinitionId.HasValue)
{
previousWeekCarryovers = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value)
.ToDictionaryAsync(
x => x.UserId,
x => (x.LeftLegRemainder, x.RightLegRemainder),
cancellationToken);
}
else
{
previousWeekCarryovers = new Dictionary<long, (int, int)>();
}
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
@@ -85,8 +102,8 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
}
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber, maxNetworkLevel, cancellationToken);
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekDefinitionId, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekDefinitionId, maxNetworkLevel, cancellationToken);
// محاسبه مجموع هر پا (جدید + باقیمانده)
var leftTotal = leftNewMembers + leftCarryover;
@@ -124,7 +141,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var balance = new NetworkWeeklyBalance
{
UserId = user.Id,
WeekNumber = request.WeekNumber,
WeekDefinitionId = request.WeekDefinitionId,
// اطلاعات جدید
LeftLegNewMembers = leftNewMembers,
@@ -189,36 +206,30 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
}
/// <summary>
/// شماره هفته قبل را محاسبه می‌کند
/// شناسه هفته قبل را از WeekDefinitionRepository می‌گیرد
/// </summary>
private string GetPreviousWeekNumber(string currentWeekNumber)
private long? GetPreviousWeekDefinitionId(long currentWeekDefinitionId)
{
// مثال: "2025-W48" -> "2025-W47"
var parts = currentWeekNumber.Split('-');
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
var currentWeekNumber = _weekRepository.GetGregorianWeekNumber(currentWeekDefinitionId);
if (currentWeekNumber == null) return null;
week--;
if (week < 1)
{
year--;
week = 52; // یا 53 بسته به سال
}
var previousWeekNumber = _weekRepository.GetPreviousWeekNumber(currentWeekNumber);
if (previousWeekNumber == null) return null;
return $"{year}-W{week:D2}";
return _weekRepository.GetWeekDefinitionId(previousWeekNumber);
}
/// <summary>
/// شمارش اعضای جدیدی که در این هفته به یک پا اضافه شدند
/// تا maxLevel لول پایین‌تر شمارش می‌شود
/// </summary>
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, string weekNumber, int maxLevel, CancellationToken cancellationToken)
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, long WeekDefinitionId, int maxLevel, CancellationToken cancellationToken)
{
// تبدیل WeekNumber به بازه تاریخی
var (startDate, endDate) = GetWeekDateRange(weekNumber);
var startDateEndDate =_weekRepository.GetWeekDateRange(WeekDefinitionId);
// شمارش تمام اعضای زیرمجموعه که در این هفته فعال شدند (تا maxLevel لول)
var count = await CountNewMembersRecursive(userId, leg, startDate, endDate, 0, maxLevel, cancellationToken);
var count = await CountNewMembersRecursive(userId, leg, startDateEndDate.Value.startDate, startDateEndDate.Value.endDate, 0, maxLevel, cancellationToken);
return count;
}
@@ -270,24 +281,31 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
}
/// <summary>
/// تبدیل شماره هفته به بازه تاریخی
/// تبدیل شماره هفته به بازه تاریخی از WeekDefinitionRepository
/// </summary>
private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
{
// مثال: "2025-W48"
var parts = weekNumber.Split('-');
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
// محاسبه اولین روز هفته (شنبه)
var jan1 = new DateTime(year, 1, 1);
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek;
var firstSaturday = jan1.AddDays(daysOffset);
var weekStart = firstSaturday.AddDays((week - 1) * 7);
var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
return (weekStart, weekEnd);
}
// private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
// {
// var dateRange = _weekRepository.GetWeekDateRange(weekNumber);
// if (dateRange.HasValue)
// {
// return dateRange.Value;
// }
//
// // Fallback: محاسبه دستی اگر در کش نبود
// var parts = weekNumber.Split('-');
// var year = int.Parse(parts[0]);
// var week = int.Parse(parts[1].Replace("W", ""));
//
// var jan1 = new DateTime(year, 1, 1);
// var jan1DayOfWeek = (int)jan1.DayOfWeek;
// var daysToFirstSaturday = jan1DayOfWeek == 6 ? 0 : (6 - jan1DayOfWeek + 7) % 7;
// var firstSaturday = jan1.AddDays(daysToFirstSaturday);
//
// var weekStart = firstSaturday.AddDays((week - 1) * 7);
// var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
//
// return (weekStart, weekEnd);
// }
/// <summary>
/// محاسبه مجموع تعادل‌های زیرمجموعه یک کاربر تا maxLevel لول پایین‌تر
@@ -4,11 +4,11 @@ public class CalculateWeeklyBalancesCommandValidator : AbstractValidator<Calcula
{
public CalculateWeeklyBalancesCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد (مثل 2025-W01)");
;
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -8,10 +8,10 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
/// <summary>
/// شماره هفته (فرمت: YYYY-Www)
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
/// <summary>
/// آیا محاسبه مجدد انجام شود؟
/// </summary>
public bool ForceRecalculate { get; init; }
public bool ForceRecalculate { get; init; }
}
@@ -3,39 +3,50 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommi
public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<CalculateWeeklyCommissionPoolCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public CalculateWeeklyCommissionPoolCommandHandler(IApplicationDbContext context)
public CalculateWeeklyCommissionPoolCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{
_context = context;
_weekRepository = weekRepository;
}
public async Task<long> Handle(CalculateWeeklyCommissionPoolCommand request, CancellationToken cancellationToken)
{
// تبدیل WeekNumber به WeekDefinitionId
var weekDefinitionId = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
if (weekDefinitionId == null)
{
throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken);
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
if (existingPool == null)
{
throw new InvalidOperationException(
$"Pool هفته {request.WeekNumber} وجود ندارد. " +
$"Pool هفته {request.WeekDefinitionId} وجود ندارد. " +
"Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد"
);
}
if (existingPool.IsCalculated && !request.ForceRecalculate)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} قبلاً محاسبه شده است");
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekDefinitionId} قبلاً محاسبه شده است");
}
// بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
if (!weeklyBalances.Any())
{
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekDefinitionId} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
}
// ⭐ Pool از قبل پُر شده (توسط ActivateClubMembership)
@@ -66,14 +77,17 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
if (request.ForceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekNumber == request.WeekNumber)
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
if (oldPayouts.Any())
{
var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList();
// ⭐ اول باید تاریخچه‌ها حذف بشن (به خاطر FK constraint)
// ⭐ ۱. برگرداندن NetworkBalance به حالت قبل (Revert)
await RevertUserWalletsAsync(oldPayoutIds, cancellationToken);
// ⭐ ۲. حذف تاریخچه‌ها (به خاطر FK constraint)
var oldHistories = await _context.CommissionPayoutHistories
.Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId))
.ToListAsync(cancellationToken);
@@ -83,7 +97,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
_context.CommissionPayoutHistories.RemoveRange(oldHistories);
}
// بعد پرداخت‌ها حذف می‌شن
// ⭐ ۳. حذف پرداخت‌ها
_context.UserCommissionPayouts.RemoveRange(oldPayouts);
await _context.SaveChangesAsync(cancellationToken);
}
@@ -110,7 +124,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
var payout = new UserCommissionPayout
{
UserId = balance.UserId,
WeekNumber = request.WeekNumber,
WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = existingPool.Id,
BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance,
@@ -138,7 +152,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = request.WeekNumber,
WeekDefinitionId = request.WeekDefinitionId,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
@@ -153,8 +167,141 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
await _context.CommissionPayoutHistories.AddRangeAsync(historyList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// ⭐ شارژ کیف پول کاربران (NetworkBalance) و ثبت لاگ
await ChargeUserWalletsAsync(payouts, request.WeekDefinitionId, cancellationToken);
}
return existingPool.Id;
}
/// <summary>
/// شارژ کیف پول کاربران با مبلغ کمیسیون (به NetworkBalance اضافه می‌شود)
/// و ثبت لاگ تغییرات کیف پول
/// </summary>
private async Task ChargeUserWalletsAsync(
List<UserCommissionPayout> payouts,
long weekDefinitionId,
CancellationToken cancellationToken)
{
var userIds = payouts.Select(p => p.UserId).Distinct().ToList();
// دریافت کیف پول‌های موجود
var existingWallets = await _context.UserWallets
.Where(w => userIds.Contains(w.UserId))
.ToDictionaryAsync(w => w.UserId, cancellationToken);
var newWallets = new List<UserWallet>();
var walletLogs = new List<UserWalletChangeLog>();
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
UserWallet wallet;
// پیدا کردن یا ایجاد کیف پول
if (existingWallets.TryGetValue(payout.UserId, out var existingWallet))
{
wallet = existingWallet;
}
else
{
// ایجاد کیف پول جدید
wallet = new UserWallet
{
UserId = payout.UserId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
newWallets.Add(wallet);
existingWallets[payout.UserId] = wallet; // برای جلوگیری از تکرار
}
}
// ذخیره کیف پول‌های جدید
if (newWallets.Any())
{
await _context.UserWallets.AddRangeAsync(newWallets, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// شارژ کیف پول‌ها و ثبت لاگ
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
var wallet = existingWallets[payout.UserId];
// ذخیره موجودی قبلی برای لاگ
var previousNetworkBalance = wallet.NetworkBalance;
// شارژ NetworkBalance
wallet.NetworkBalance += payout.TotalAmount;
// ایجاد لاگ تغییر کیف پول
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0, // Balance اصلی تغییر نمی‌کند
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = payout.TotalAmount,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = payout.Id // ارجاع به UserCommissionPayout
};
walletLogs.Add(walletLog);
}
// ذخیره تغییرات کیف پول و لاگ‌ها
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// برگرداندن NetworkBalance به حالت قبل از شارژ کمیسیون
/// این متد قبل از محاسبه مجدد (ForceRecalculate) اجرا می‌شود
/// </summary>
private async Task RevertUserWalletsAsync(
List<long> oldPayoutIds,
CancellationToken cancellationToken)
{
// پیدا کردن لاگ‌های کیف پول مرتبط با پرداخت‌های قبلی
var oldWalletLogs = await _context.UserWalletChangeLogs
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
.ToListAsync(cancellationToken);
if (!oldWalletLogs.Any()) return;
// پیدا کردن کیف پول‌های مرتبط
var walletIds = oldWalletLogs.Select(l => l.WalletId).Distinct().ToList();
var wallets = await _context.UserWallets
.Where(w => walletIds.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, cancellationToken);
// برگرداندن NetworkBalance
foreach (var log in oldWalletLogs)
{
if (wallets.TryGetValue(log.WalletId, out var wallet))
{
// کم کردن مبلغ شارژ شده قبلی از NetworkBalance
wallet.NetworkBalance -= log.ChangeNerworkValue;
// اطمینان از منفی نشدن
if (wallet.NetworkBalance < 0)
{
wallet.NetworkBalance = 0;
}
}
}
// حذف لاگ‌های قبلی
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
await _context.SaveChangesAsync(cancellationToken);
}
}
@@ -4,11 +4,10 @@ public class CalculateWeeklyCommissionPoolCommandValidator : AbstractValidator<C
{
public CalculateWeeklyCommissionPoolCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -8,7 +8,7 @@ public record ProcessUserPayoutsCommand : IRequest<int>
/// <summary>
/// شماره هفته
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
/// <summary>
/// آیا پرداخت مجدد انجام شود؟
@@ -3,31 +3,41 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public ProcessUserPayoutsCommandHandler(IApplicationDbContext context)
public ProcessUserPayoutsCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{
_context = context;
_weekRepository = weekRepository;
}
public async Task<int> Handle(ProcessUserPayoutsCommand request, CancellationToken cancellationToken)
{
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر
var pool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken);
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
if (pool == null || !pool.IsCalculated)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} هنوز محاسبه نشده است");
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekDefinitionId} هنوز محاسبه نشده است");
}
// بررسی پرداخت قبلی
var existingPayouts = await _context.UserCommissionPayouts
.Where(x => x.WeekNumber == request.WeekNumber)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
if (existingPayouts.Any() && !request.ForceReprocess)
{
throw new InvalidOperationException($"پرداخت‌های هفته {request.WeekNumber} قبلاً انجام شده است");
throw new InvalidOperationException($"پرداخت‌های هفته {request.WeekDefinitionId} قبلاً انجام شده است");
}
// حذف پرداخت‌های قبلی در صورت ForceReprocess
@@ -46,12 +56,12 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToDictionaryAsync(x => x.UserId, cancellationToken);
// دریافت کاربرانی که تعادل > 0 دارند (یا زیرمجموعه‌شان دارد)
var usersWithBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber && x.TotalBalances > 0)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId && x.TotalBalances > 0)
.Select(x => x.UserId)
.ToListAsync(cancellationToken);
@@ -82,7 +92,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
// ⭐ محاسبه مجموع تعادل‌های زیرمجموعه تا maxNetworkLevel لول
var subordinateBalances = await CalculateSubordinateBalancesAsync(
userId,
request.WeekNumber,
request.WeekDefinitionId,
allWeeklyBalances,
maxNetworkLevel,
cancellationToken
@@ -103,7 +113,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
var payout = new UserCommissionPayout
{
UserId = userId,
WeekNumber = request.WeekNumber,
WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = pool.Id,
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
ValuePerBalance = pool.ValuePerBalance,
@@ -129,7 +139,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = request.WeekNumber,
WeekDefinitionId = request.WeekDefinitionId,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
@@ -180,7 +190,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
/// </summary>
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
string weekNumber,
long WeekDefinitionId,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
@@ -4,11 +4,10 @@ public class ProcessUserPayoutsCommandValidator : AbstractValidator<ProcessUserP
{
public ProcessUserPayoutsCommandValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -73,7 +73,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
UserId = payout.UserId,
Iban = payout.IbanNumber ?? throw new InvalidOperationException("شماره شبا یافت نشد"),
AccountHolderName = $"{payout.User.FirstName} {payout.User.LastName}",
Description = $"برداشت کمیسیون هفته {payout.WeekNumber}",
Description = $"برداشت کمیسیون هفته {payout.WeekDefinitionId}",
InternalRefId = $"PAYOUT-{payout.Id}"
};
@@ -120,7 +120,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
@@ -147,7 +147,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
@@ -48,7 +48,7 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekNumber = payout.WeekNumber,
WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = oldStatus,
@@ -5,7 +5,7 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
/// <summary>
/// شماره هفته (فرمت: "YYYY-Www")
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
/// <summary>
/// اگر true باشد، محاسبات قبلی را حذف و دوباره محاسبه می‌کند
@@ -8,13 +8,16 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
{
private readonly IApplicationDbContext _context;
private readonly IMediator _mediator;
private readonly ICommissionCalculationStrategyFactory _strategyFactory;
public TriggerWeeklyCalculationCommandHandler(
IApplicationDbContext context,
IMediator mediator)
IMediator mediator,
ICommissionCalculationStrategyFactory strategyFactory)
{
_context = context;
_mediator = mediator;
_strategyFactory = strategyFactory;
}
public async Task<TriggerWeeklyCalculationResponseDto> Handle(
@@ -27,7 +30,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
try
{
// Validate week number format
if (string.IsNullOrWhiteSpace(request.WeekNumber))
if (request.WeekDefinitionId <= 0)
{
return new TriggerWeeklyCalculationResponseDto
{
@@ -39,33 +42,37 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
}
var steps = new List<string>();
// ⭐ دریافت استراتژی براساس Config (ORM یا SP)
var strategy = await _strategyFactory.CreateStrategyAsync(cancellationToken);
var strategyName = strategy.GetType().Name.Contains("StoredProcedure") ? "SP" : "ORM";
// Step 1: Calculate Weekly Balances (تا 15 لول)
if (!request.SkipBalances)
{
await _mediator.Send(new CalculateWeeklyBalancesCommand
{
WeekNumber = request.WeekNumber,
ForceRecalculate = request.ForceRecalculate
}, cancellationToken);
steps.Add("محاسبه امتیازات هفتگی");
var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
request.WeekDefinitionId,
request.ForceRecalculate,
cancellationToken);
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
}
// Step 2: Calculate Pool & Process Payouts (محاسبه استخر + پرداخت کاربران)
if (!request.SkipPayouts)
{
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand
{
WeekNumber = request.WeekNumber,
ForceRecalculate = request.ForceRecalculate
}, cancellationToken);
steps.Add("محاسبه استخر و پرداخت کاربران");
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
request.WeekDefinitionId,
request.ForceRecalculate,
cancellationToken);
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
}
return new TriggerWeeklyCalculationResponseDto
{
Success = true,
Message = $"محاسبات هفته {request.WeekNumber} با موفقیت انجام شد. مراحل: {string.Join(", ", steps)}",
Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد [{strategyName}]. مراحل: {string.Join(", ", steps)}",
ExecutionId = executionId,
StartedAt = startedAt
};
@@ -0,0 +1,27 @@
using CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
public class TriggerWeeklyCalculationCommandValidator : AbstractValidator<TriggerWeeklyCalculationCommand>
{
public TriggerWeeklyCalculationCommandValidator()
{
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<TriggerWeeklyCalculationCommand>.CreateWithOptions(
(TriggerWeeklyCalculationCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -8,12 +8,12 @@ public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
/// <summary>
/// از هفته (فیلتر اختیاری)
/// </summary>
public string? FromWeek { get; init; }
public int? FromWeekOrder { get; init; }
/// <summary>
/// تا هفته (فیلتر اختیاری)
/// </summary>
public string? ToWeek { get; init; }
public int? ToWeekOrder { get; init; }
/// <summary>
/// فقط Pool های محاسبه شده
@@ -3,25 +3,31 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQuery, GetAllWeeklyPoolsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetAllWeeklyPoolsQueryHandler(IApplicationDbContext context)
public GetAllWeeklyPoolsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetAllWeeklyPoolsResponseDto> Handle(GetAllWeeklyPoolsQuery request, CancellationToken cancellationToken)
{
var query = _context.WeeklyCommissionPools.AsNoTracking();
var query = _context.WeeklyCommissionPools
.Include(i=> i.WeekDefinition)
.AsNoTracking();
// Apply filters
if (!string.IsNullOrWhiteSpace(request.FromWeek))
if (request.FromWeekOrder!=null)
{
query = query.Where(x => string.Compare(x.WeekNumber, request.FromWeek) >= 0);
query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
}
if (!string.IsNullOrWhiteSpace(request.ToWeek))
if (request.ToWeekOrder!=null)
{
query = query.Where(x => string.Compare(x.WeekNumber, request.ToWeek) <= 0);
query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
}
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
@@ -30,7 +36,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
}
// Order by week number descending (newest first)
query = query.OrderByDescending(x => x.WeekNumber);
query = query.OrderByDescending(x => x.WeekDefinitionId);
// Count total
var totalCount = await query.CountAsync(cancellationToken);
@@ -42,7 +48,8 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
.Select(x => new WeeklyCommissionPoolDto
{
Id = x.Id,
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition.PersianWeekNumber,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
@@ -52,6 +59,19 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
})
.ToListAsync(cancellationToken);
// Populate WeekDisplayName from cache
// foreach (var pool in pools)
// {
// // WeeklyCommissionPoolDto is a record, need to create new instance with display name
// // Since records are immutable, we can't modify them directly
// }
// // Create new list with WeekDisplayName populated
// var poolsWithDisplayName = pools.Select(p => p with
// {
// WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(p.WeekNumber)
// }).ToList();
return new GetAllWeeklyPoolsResponseDto
{
MetaData = new MetaDataDto
@@ -9,7 +9,8 @@ public record GetAllWeeklyPoolsResponseDto
public record WeeklyCommissionPoolDto
{
public long Id { get; init; }
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
public string WeekDisplayName { get; init; } = string.Empty;
public long TotalPoolAmount { get; init; }
public int TotalBalances { get; init; }
public long ValuePerBalance { get; init; }
@@ -2,29 +2,36 @@ using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using DateTimeConverterCL;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQuery, GetAvailableWeeksResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetAvailableWeeksQueryHandler(IApplicationDbContext context)
public GetAvailableWeeksQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetAvailableWeeksResponseDto> Handle(
GetAvailableWeeksQuery request,
CancellationToken cancellationToken)
{
var currentDate = DateTime.Now;
var currentWeekNumber = GetWeekNumber(currentDate);
var currentWeekDef = _weekDefinitionRepository.GetCurrentWeek();
// var currentWeekNumber = currentWeekDef != null
// ? $"{currentWeekDef.GregorianYear}-W{currentWeekDef.GregorianWeekNumber:D2}"
// : _weekDefinitionRepository.CalculateGregorianWeekNumber(DateTime.Now);
// دریافت هفته‌های محاسبه شده از دیتابیس
var calculatedPools = await _context.WeeklyCommissionPools
.Where(p => p.IsCalculated)
.OrderByDescending(p => p.WeekNumber)
.OrderByDescending(p => p.WeekDefinitionId)
.Take(request.PastWeeksCount)
.ToListAsync(cancellationToken);
@@ -32,7 +39,7 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
var executionLogs = await _context.WorkerExecutionLogs
.Where(log => log.Status == WorkerExecutionStatus.Success ||
log.Status == WorkerExecutionStatus.Failed)
.GroupBy(log => log.WeekNumber)
.GroupBy(log => log.WeekDefinitionId)
.Select(g => new
{
WeekNumber = g.Key,
@@ -43,15 +50,16 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
var allWeeks = new List<WeekInfoDto>();
// هفته جاری
var currentWeekInfo = CreateWeekInfo(currentDate, currentWeekNumber, calculatedPools, executionLogs);
var currentDate = DateTime.Now;
var currentWeekInfo = CreateWeekInfo(currentDate, currentWeekDef.Id, calculatedPools, executionLogs);
// هفته‌های گذشته (12 هفته)
var pastWeeks = new List<WeekInfoDto>();
for (int i = 1; i <= request.PastWeeksCount; i++)
{
var pastDate = currentDate.AddDays(-7 * i);
var weekNumber = GetWeekNumber(pastDate);
pastWeeks.Add(CreateWeekInfo(pastDate, weekNumber, calculatedPools, executionLogs));
var weekNumber = GetWeekNumberUsingRepository(pastDate);
pastWeeks.Add(CreateWeekInfo(pastDate, currentWeekDef.Id, calculatedPools, executionLogs));
}
// هفته‌های آینده (4 هفته)
@@ -59,8 +67,8 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
for (int i = 1; i <= request.FutureWeeksCount; i++)
{
var futureDate = currentDate.AddDays(7 * i);
var weekNumber = GetWeekNumber(futureDate);
futureWeeks.Add(CreateWeekInfo(futureDate, weekNumber, calculatedPools, executionLogs));
var weekNumber = GetWeekNumberUsingRepository(futureDate);
futureWeeks.Add(CreateWeekInfo(futureDate, currentWeekDef.Id, calculatedPools, executionLogs));
}
// تفکیک به calculated و pending
@@ -76,18 +84,54 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
};
}
private string GetWeekNumberUsingRepository(DateTime date)
{
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
if (weekDef != null)
{
return $"{weekDef.GregorianYear}-W{weekDef.GregorianWeekNumber:D2}";
}
// Fallback to manual calculation if not in cache
return _weekDefinitionRepository.CalculateGregorianWeekNumber(date);
}
private WeekInfoDto CreateWeekInfo(
DateTime date,
string weekNumber,
long weekDefinitionId,
List<Domain.Entities.Commission.WeeklyCommissionPool> calculatedPools,
Dictionary<string, Domain.Entities.Commission.WorkerExecutionLog> executionLogs)
Dictionary<long, Domain.Entities.Commission.WorkerExecutionLog> executionLogs)
{
// Get the actual WeekDefinition for this date
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
var actualWeekDefId = weekDef?.Id ?? weekDefinitionId;
var (startDate, endDate) = GetWeekRange(date);
var pool = calculatedPools.FirstOrDefault(p => p.WeekNumber == weekNumber);
var log = executionLogs.GetValueOrDefault(weekNumber);
var pool = calculatedPools.FirstOrDefault(p => p.WeekDefinitionId == actualWeekDefId);
var log = executionLogs.GetValueOrDefault(actualWeekDefId);
var isCalculated = pool != null && pool.IsCalculated;
var displayText = $"{weekNumber} ({startDate:yyyy/MM/dd} - {endDate:yyyy/MM/dd})";
//
// // تبدیل تاریخ به شمسی برای نمایش
// var persianCalendar = new PersianCalendar();
// var startDatePersian = $"{persianCalendar.GetYear(startDate):D4}/{persianCalendar.GetMonth(startDate):D2}/{persianCalendar.GetDayOfMonth(startDate):D2}";
// var endDatePersian = $"{persianCalendar.GetYear(endDate):D4}/{persianCalendar.GetMonth(endDate):D2}/{persianCalendar.GetDayOfMonth(endDate):D2}";
// استفاده از DisplayName از WeekDefinition
var displayName = weekDef?.DisplayName ?? GetDisplayName(startDate);
string displayText;
if (!string.IsNullOrEmpty(displayName))
{
// اگر DisplayName وجود داشت، از آن استفاده کن
displayText = $"{displayName} ({startDate.MiladiToJalali()} - {endDate.MiladiToJalali()})";
}
else
{
// تبدیل weekNumber به شمسی فقط برای نمایش (fallback)
var persianWeekNumber = _weekDefinitionRepository.GetWeekById(actualWeekDefId);
displayText = $"{persianWeekNumber?.PersianWeekNumber} ({startDate.MiladiToJalali()} - {endDate.MiladiToJalali()})";
}
if (isCalculated)
{
@@ -96,7 +140,8 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
return new WeekInfoDto
{
WeekNumber = weekNumber,
WeekDefinitionId = actualWeekDefId,
DisplayName = weekDef?.DisplayName ?? _weekDefinitionRepository.GetWeekById(actualWeekDefId)?.DisplayName ?? "", // میلادی (برای API)
StartDate = startDate,
EndDate = endDate,
IsCalculated = isCalculated,
@@ -104,29 +149,63 @@ public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQu
LastExecutionStatus = log?.Status.ToString(),
TotalPoolAmount = pool?.TotalPoolAmount,
EligibleUsersCount = pool?.UserCommissionPayouts?.Count ?? 0,
DisplayText = displayText
DisplayText = displayText // شمسی (برای نمایش)
};
}
private static string GetWeekNumber(DateTime date)
private (DateTime startDate, DateTime endDate) GetWeekRange(DateTime date)
{
var calendar = CultureInfo.InvariantCulture.Calendar;
var weekOfYear = calendar.GetWeekOfYear(
date,
CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
// First try to get from repository
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
if (weekDef != null)
{
return (weekDef.StartDate, weekDef.EndDate);
}
return $"{date.Year}-W{weekOfYear:D2}";
}
private static (DateTime startDate, DateTime endDate) GetWeekRange(DateTime date)
{
// Fallback: manual calculation
var dayOfWeek = (int)date.DayOfWeek;
var daysToMonday = dayOfWeek == 0 ? 6 : dayOfWeek - 1; // اگر یکشنبه باشد، 6 روز عقب برو
// محاسبه تعداد روزهایی که باید عقب برویم تا به شنبه برسیم
// شنبه = 6, یکشنبه = 0, دوشنبه = 1, ..., جمعه = 5
var daysToSaturday = dayOfWeek == 6 ? 0 : (dayOfWeek + 1) % 7;
var startDate = date.Date.AddDays(-daysToMonday);
var endDate = startDate.AddDays(6);
var startDate = date.Date.AddDays(-daysToSaturday);
var endDate = startDate.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
return (startDate, endDate);
}
private string GetDisplayName(DateTime date)
{
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
return weekDef?.DisplayName ?? "";
}
// private static string ConvertWeekNumberToPersian(string gregorianWeekNumber, DateTime weekStartDate)
// {
// // ورودی: "2025-W48"
// var persianCalendar = new PersianCalendar();
// var persianYear = persianCalendar.GetYear(weekStartDate);
//
// // محاسبه شماره هفته شمسی
// var jan1Persian = persianCalendar.ToDateTime(persianYear, 1, 1, 0, 0, 0, 0);
//
// // پیدا کردن اولین شنبه سال شمسی
// var jan1DayOfWeek = (int)jan1Persian.DayOfWeek;
// var daysToFirstSaturday = jan1DayOfWeek == 6 ? 0 : (6 - jan1DayOfWeek + 7) % 7;
// var firstSaturday = jan1Persian.AddDays(daysToFirstSaturday);
//
// // محاسبه شماره هفته
// int weekNum;
// if (weekStartDate < firstSaturday)
// {
// weekNum = 1;
// }
// else
// {
// var daysSinceFirstSaturday = (weekStartDate - firstSaturday).Days;
// weekNum = (daysSinceFirstSaturday / 7) + 1;
// }
//
// return $"{persianYear}-W{weekNum:D2}";
// }
}
@@ -17,8 +17,11 @@ public class GetAvailableWeeksResponseDto
public class WeekInfoDto
{
/// <summary>شناسه یکتای هفته</summary>
public required long WeekDefinitionId { get; init; }
/// <summary>شماره هفته (YYYY-Www)</summary>
public required string WeekNumber { get; init; }
public required string DisplayName { get; init; }
/// <summary>تاریخ شروع هفته</summary>
public required DateTime StartDate { get; init; }
@@ -18,7 +18,7 @@ public record GetCommissionPayoutHistoryQuery : IRequest<GetCommissionPayoutHist
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public string? WeekNumber { get; init; }
public long? WeekDefinitionId { get; init; }
/// <summary>
/// مرتب‌سازی
@@ -3,15 +3,20 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHi
public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetCommissionPayoutHistoryQueryHandler(IApplicationDbContext context)
public GetCommissionPayoutHistoryQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetCommissionPayoutHistoryResponseDto> Handle(GetCommissionPayoutHistoryQuery request, CancellationToken cancellationToken)
{
var query = _context.CommissionPayoutHistories
.Include(i=>i.WeekDefinition)
.AsNoTracking()
.AsQueryable();
@@ -26,12 +31,12 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
if (request.WeekDefinitionId!=null)
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -42,7 +47,8 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
Id = x.Id,
UserCommissionPayoutId = x.UserCommissionPayoutId,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition!.DisplayName,
AmountBefore = x.AmountBefore,
AmountAfter = x.AmountAfter,
OldStatus = x.OldStatus,
@@ -54,6 +60,12 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
})
.ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var model in models)
// {
// model.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(model.WeekNumber);
// }
return new GetCommissionPayoutHistoryResponseDto
{
MetaData = meta,
@@ -13,11 +13,10 @@ public class GetCommissionPayoutHistoryQueryValidator : AbstractValidator<GetCom
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -11,7 +11,8 @@ public class GetCommissionPayoutHistoryResponseModel
public long Id { get; set; }
public long UserCommissionPayoutId { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long AmountBefore { get; set; }
public long AmountAfter { get; set; }
public CommissionPayoutStatus? OldStatus { get; set; }
@@ -18,7 +18,7 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public string? WeekNumber { get; init; }
public long? WeekDefinitionId { get; init; }
/// <summary>
/// مرتب‌سازی
@@ -3,15 +3,21 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayo
public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetUserCommissionPayoutsQueryHandler(IApplicationDbContext context)
public GetUserCommissionPayoutsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
{
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.AsNoTracking()
.AsQueryable();
@@ -26,12 +32,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
query = query.Where(x => x.Status == request.Status.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
if (request.WeekDefinitionId!=null)
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -41,7 +47,10 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
{
Id = x.Id,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
FirstName = x.User.FirstName,
LastName = x.User.LastName,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
WeeklyPoolId = x.WeeklyPoolId,
BalancesEarned = x.BalancesEarned,
ValuePerBalance = x.ValuePerBalance,
@@ -55,6 +64,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
})
.ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var model in models)
// {
// model.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(model.WeekNumber);
// }
return new GetUserCommissionPayoutsResponseDto
{
MetaData = meta,
@@ -14,10 +14,10 @@ public class GetUserCommissionPayoutsQueryValidator : AbstractValidator<GetUserC
.WithMessage("وضعیت معتبر نیست")
.When(x => x.Status.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
RuleFor(x => x.WeekDefinitionId)
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند صفر باشد")
.When(x => x.WeekDefinitionId.HasValue);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -10,7 +10,10 @@ public class GetUserCommissionPayoutsResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long WeeklyPoolId { get; set; }
public long BalancesEarned { get; set; }
public decimal ValuePerBalance { get; set; }
@@ -11,9 +11,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
public long? UserId { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// شناسه تعریف هفته (اختیاری) - روش ترجیحی
/// </summary>
public string? WeekNumber { get; init; }
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
@@ -3,15 +3,20 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances
public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetUserWeeklyBalancesQueryHandler(IApplicationDbContext context)
public GetUserWeeklyBalancesQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
{
var query = _context.NetworkWeeklyBalances
.Include(x => x.WeekDefinition)
.AsNoTracking()
.AsQueryable();
@@ -21,17 +26,20 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
if (request.WeekDefinitionId.HasValue)
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
if (request.OnlyActive.HasValue && request.OnlyActive.Value)
{
query = query.Where(x => !x.IsExpired);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekNumber");
// مرتب‌سازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -41,7 +49,8 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{
Id = x.Id,
UserId = x.UserId,
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
LeftLegBalances = x.LeftLegBalances,
RightLegBalances = x.RightLegBalances,
TotalBalances = x.TotalBalances,
@@ -9,10 +9,10 @@ public class GetUserWeeklyBalancesQueryValidator : AbstractValidator<GetUserWeek
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x.WeekNumber)
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
RuleFor(x => x.WeekDefinitionId)
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند صفر باشد")
.When(x => x.WeekDefinitionId.HasValue);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -10,7 +10,9 @@ public class GetUserWeeklyBalancesResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public int LeftLegBalances { get; set; }
public int RightLegBalances { get; set; }
public int TotalBalances { get; set; }
@@ -0,0 +1,45 @@
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// Query برای دریافت لیست هفته‌ها از کش (برای dropdown)
/// </summary>
public record GetWeekDefinitionsQuery : IRequest<GetWeekDefinitionsResponseDto>
{
//موقعیت صفحه بندی
public PaginationState? PaginationState { get; init; }
//مرتب سازی بر اساس
public string? SortBy { get; init; }
//فیلتر
public GetWeekDefinitionsFilter? Filter { get; init; }
}
/// <summary>
/// فیلترهای جستجوی هفته‌ها
/// </summary>
public class GetWeekDefinitionsFilter
{
//جستجوی متنی روی DisplayName (مثل "هفته یک")
public string? SearchText { get; set; }
//شماره ترتیب هفته
public int? WeekOrder { get; set; }
//شماره هفته میلادی (2025-W46)
public string? GregorianWeekNumber { get; set; }
//شماره هفته شمسی (1404-W35)
public string? PersianWeekNumber { get; set; }
//سال میلادی
public int? GregorianYear { get; set; }
//سال شمسی
public int? PersianYear { get; set; }
//فقط هفته‌های فعال
public bool? IsActive { get; set; }
}
@@ -0,0 +1,130 @@
using CMSMicroservice.Application.Common.Interfaces;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// Handler برای دریافت لیست هفته‌ها از کش
/// </summary>
public class GetWeekDefinitionsQueryHandler : IRequestHandler<GetWeekDefinitionsQuery, GetWeekDefinitionsResponseDto>
{
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWeekDefinitionsQueryHandler(IWeekDefinitionRepository weekDefinitionRepository)
{
_weekDefinitionRepository = weekDefinitionRepository;
}
public Task<GetWeekDefinitionsResponseDto> Handle(
GetWeekDefinitionsQuery request,
CancellationToken cancellationToken)
{
// گرفتن همه هفته‌ها از کش
var allWeeks = _weekDefinitionRepository.GetAllWeeks();
// اعمال فیلترها
var filteredWeeks = allWeeks.AsEnumerable();
if (request.Filter != null)
{
var filter = request.Filter;
// جستجوی متنی روی DisplayName
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
filteredWeeks = filteredWeeks.Where(w =>
w.DisplayName.Contains(filter.SearchText, StringComparison.OrdinalIgnoreCase));
}
// فیلتر بر اساس WeekOrder
if (filter.WeekOrder.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.WeekOrder == filter.WeekOrder.Value);
}
// فیلتر بر اساس GregorianWeekNumber
if (!string.IsNullOrWhiteSpace(filter.GregorianWeekNumber))
{
filteredWeeks = filteredWeeks.Where(w =>
w.GregorianWeekNumber == filter.GregorianWeekNumber);
}
// فیلتر بر اساس PersianWeekNumber
if (!string.IsNullOrWhiteSpace(filter.PersianWeekNumber))
{
filteredWeeks = filteredWeeks.Where(w =>
w.PersianWeekNumber == filter.PersianWeekNumber);
}
// فیلتر بر اساس سال میلادی
if (filter.GregorianYear.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.GregorianYear == filter.GregorianYear.Value);
}
// فیلتر بر اساس سال شمسی
if (filter.PersianYear.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.PersianYear == filter.PersianYear.Value);
}
// فیلتر بر اساس فعال بودن
if (filter.IsActive.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.IsActive == filter.IsActive.Value);
}
}
// مرتب‌سازی
filteredWeeks = request.SortBy?.ToLower() switch
{
"weekorder_desc" => filteredWeeks.OrderByDescending(w => w.WeekOrder),
"displayname" => filteredWeeks.OrderBy(w => w.DisplayName),
"displayname_desc" => filteredWeeks.OrderByDescending(w => w.DisplayName),
"startdate" => filteredWeeks.OrderBy(w => w.StartDate),
"startdate_desc" => filteredWeeks.OrderByDescending(w => w.StartDate),
_ => filteredWeeks.OrderBy(w => w.WeekOrder) // پیش‌فرض: weekorder
};
// تعداد کل قبل از صفحه‌بندی
var weeksList = filteredWeeks.ToList();
var totalCount = weeksList.Count;
// صفحه‌بندی
if (request.PaginationState != null)
{
var pageNumber = request.PaginationState.PageNumber > 0 ? request.PaginationState.PageNumber : 1;
var pageSize = request.PaginationState.PageSize > 0 ? request.PaginationState.PageSize : 20;
weeksList = weeksList
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
}
// گرفتن هفته جاری برای مشخص کردن IsCurrentWeek
var currentWeek = _weekDefinitionRepository.GetCurrentWeek();
// تبدیل به DTO
var weekDtos = weeksList.Select(w => new WeekDefinitionItemDto
{
WeekOrder = w.WeekOrder,
DisplayName = w.DisplayName,
GregorianWeekNumber = w.GregorianWeekNumber,
PersianWeekNumber = w.PersianWeekNumber,
StartDate = w.StartDate,
EndDate = w.EndDate,
GregorianYear = w.GregorianYear,
PersianYear = w.PersianYear,
IsActive = w.IsActive,
IsCurrentWeek = currentWeek != null && w.WeekOrder == currentWeek.WeekOrder
}).ToList();
var response = new GetWeekDefinitionsResponseDto
{
Data = weekDtos,
TotalCount = totalCount
};
return Task.FromResult(response);
}
}
@@ -0,0 +1,49 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// DTO برای پاسخ لیست هفته‌ها
/// </summary>
public class GetWeekDefinitionsResponseDto
{
//لیست هفته‌ها
public List<WeekDefinitionItemDto> Data { get; set; } = new();
//تعداد کل
public int TotalCount { get; set; }
}
/// <summary>
/// آیتم هفته برای dropdown
/// </summary>
public class WeekDefinitionItemDto
{
//شماره ترتیب هفته (1, 2, 3, ...)
public int WeekOrder { get; set; }
//نام نمایشی هفته (هفته یکم، هفته دوم، ...)
public string DisplayName { get; set; } = string.Empty;
//شماره هفته میلادی (2025-W46)
public string GregorianWeekNumber { get; set; } = string.Empty;
//شماره هفته شمسی (1404-W35)
public string PersianWeekNumber { get; set; } = string.Empty;
//تاریخ شروع هفته
public DateTime StartDate { get; set; }
//تاریخ پایان هفته
public DateTime EndDate { get; set; }
//سال میلادی
public int GregorianYear { get; set; }
//سال شمسی
public int PersianYear { get; set; }
//فعال بودن
public bool IsActive { get; set; }
//آیا هفته جاری است؟
public bool IsCurrentWeek { get; set; }
}
@@ -8,5 +8,5 @@ public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
/// <summary>
/// شماره هفته
/// </summary>
public string WeekNumber { get; init; } = string.Empty;
public long WeekDefinitionId { get; init; }
}
@@ -3,21 +3,27 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPo
public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyCommissionPoolQuery, WeeklyCommissionPoolDto?>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWeeklyCommissionPoolQueryHandler(IApplicationDbContext context)
public GetWeeklyCommissionPoolQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{
var pool = await _context.WeeklyCommissionPools
var pool = await _context.WeeklyCommissionPools
.Include(x => x.WeekDefinition)
.AsNoTracking()
.Where(x => x.WeekNumber == request.WeekNumber)
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.Select(x => new WeeklyCommissionPoolDto
{
Id = x.Id,
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
@@ -27,6 +33,11 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
})
.FirstOrDefaultAsync(cancellationToken);
return pool;
// if (pool != null)
// {
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
// }
return pool ?? new WeeklyCommissionPoolDto();
}
}
@@ -4,11 +4,9 @@ public class GetWeeklyCommissionPoolQueryValidator : AbstractValidator<GetWeekly
{
public GetWeeklyCommissionPoolQueryValidator()
{
RuleFor(x => x.WeekNumber)
.NotEmpty()
.WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
RuleFor(x => x.WeekDefinitionId)
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند صفر باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -6,7 +6,8 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPo
public class WeeklyCommissionPoolDto
{
public long Id { get; set; }
public string WeekNumber { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long TotalPoolAmount { get; set; }
public long TotalBalances { get; set; }
public decimal ValuePerBalance { get; set; }
@@ -4,7 +4,7 @@ public class GetWithdrawalRequestsQuery : IRequest<GetWithdrawalRequestsResponse
{
public int? Status { get; set; } // CommissionPayoutStatus enum
public long? UserId { get; set; }
public string? WeekNumber { get; set; }
public long? WeekDefinitionId { get; init; }
public string? IbanNumber { get; set; }
public PaginationState? PaginationState { get; set; }
public string? SortBy { get; set; }
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests
public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRequestsQuery, GetWithdrawalRequestsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWithdrawalRequestsQueryHandler(IApplicationDbContext context)
public GetWithdrawalRequestsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetWithdrawalRequestsResponseDto> Handle(GetWithdrawalRequestsQuery request, CancellationToken cancellationToken)
@@ -14,6 +18,7 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
var query = _context.UserCommissionPayouts
.AsNoTracking()
.Include(x => x.User)
.Include(x => x.WeekDefinition)
.Where(x => x.WithdrawalMethod != null) // Only requests with withdrawal method
.AsQueryable();
@@ -28,9 +33,9 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (!string.IsNullOrEmpty(request.WeekNumber))
if (request.WeekDefinitionId!= null && request.WeekDefinitionId >0)
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
if (!string.IsNullOrWhiteSpace(request.IbanNumber))
@@ -38,7 +43,7 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
query = query.Where(x => x.IbanNumber != null && x.IbanNumber.Contains(request.IbanNumber));
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -51,7 +56,8 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
Id = x.Id,
UserId = x.UserId,
UserName = x.User != null ? (x.User.FirstName + " " + x.User.LastName).Trim() : x.User?.Mobile ?? "N/A",
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName =x.WeekDefinition.DisplayName,
Amount = x.TotalAmount,
Status = (int)x.Status,
WithdrawalMethod = x.WithdrawalMethod.HasValue ? (int)x.WithdrawalMethod.Value : 0,
@@ -11,7 +11,8 @@ public class WithdrawalRequestModel
public long Id { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long Amount { get; set; }
public int Status { get; set; } // CommissionPayoutStatus enum
public int? WithdrawalMethod { get; set; }
@@ -4,7 +4,7 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLog
public record GetWorkerExecutionLogsQuery : IRequest<GetWorkerExecutionLogsResponseDto>
{
public string? WeekNumber { get; init; }
public long? WeekDefinitionId { get; init; }
public string? ExecutionId { get; init; }
public bool? SuccessOnly { get; init; }
public bool? FailedOnly { get; init; }
@@ -6,10 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLog
public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecutionLogsQuery, GetWorkerExecutionLogsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWorkerExecutionLogsQueryHandler(IApplicationDbContext context)
public GetWorkerExecutionLogsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetWorkerExecutionLogsResponseDto> Handle(
@@ -17,12 +21,14 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
CancellationToken cancellationToken)
{
// Query from database
var query = _context.WorkerExecutionLogs.AsQueryable();
var query = _context.WorkerExecutionLogs
.Include(i=> i.WeekDefinition)
.AsQueryable();
// Apply filters
if (!string.IsNullOrEmpty(request.WeekNumber))
if (request.WeekDefinitionId!=null && request.WeekDefinitionId > 0)
{
query = query.Where(x => x.WeekNumber == request.WeekNumber);
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
if (request.SuccessOnly == true)
@@ -49,7 +55,8 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
.Select(x => new WorkerExecutionLogModel
{
ExecutionId = x.ExecutionId.ToString(),
WeekNumber = x.WeekNumber,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition.DisplayName,
Step = "Full", // We only have full execution now
Success = x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings,
@@ -62,6 +69,12 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
})
.ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var log in logs)
// {
// log.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(log.WeekNumber);
// }
return new GetWorkerExecutionLogsResponseDto
{
MetaData = new MetaData
@@ -11,7 +11,8 @@ public class GetWorkerExecutionLogsResponseDto
public class WorkerExecutionLogModel
{
public string ExecutionId { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public string Step { get; set; } = string.Empty; // "Balances" | "Pool" | "Payouts" | "Full"
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
@@ -5,10 +5,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery, GetWorkerStatusResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWorkerStatusQueryHandler(IApplicationDbContext context)
public GetWorkerStatusQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetWorkerStatusResponseDto> Handle(
@@ -19,13 +23,19 @@ public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery,
// فعلاً mock data برمی‌گرداند
await Task.CompletedTask;
var currentWeek = _weekDefinitionRepository.GetCurrentWeek();
var currentWeekNumber = currentWeek != null
? $"{currentWeek.GregorianYear}-W{currentWeek.GregorianWeekNumber:D2}"
: null;
return new GetWorkerStatusResponseDto
{
IsRunning = false,
IsEnabled = true,
CurrentExecutionId = null,
CurrentWeekNumber = null,
CurrentWeekNumber = currentWeekNumber,
CurrentWeekDisplayName = currentWeek?.DisplayName,
CurrentStep = "Idle",
LastRunAt = DateTime.Now.AddHours(-24),
NextScheduledRun = DateTime.Now.AddDays(7),
@@ -6,6 +6,7 @@ public class GetWorkerStatusResponseDto
public bool IsEnabled { get; set; }
public string? CurrentExecutionId { get; set; }
public string? CurrentWeekNumber { get; set; }
public string? CurrentWeekDisplayName { get; set; }
public string? CurrentStep { get; set; } // "Balances" | "Pool" | "Payouts" | "Idle"
public DateTime? LastRunAt { get; set; }
public DateTime? NextScheduledRun { get; set; }
@@ -1,6 +1,7 @@
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Geography;
namespace CMSMicroservice.Application.Common.Interfaces;
@@ -39,6 +40,7 @@ public interface IApplicationDbContext
DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
DbSet<NetworkMembershipHistory> NetworkMembershipHistories { get; }
DbSet<WeekDefinition> WeekDefinitions { get; }
DbSet<WeeklyCommissionPool> WeeklyCommissionPools { get; }
DbSet<UserCommissionPayout> UserCommissionPayouts { get; }
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
@@ -53,5 +55,10 @@ public interface IApplicationDbContext
DbSet<DiscountOrder> DiscountOrders { get; }
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
// ============= Geography =============
DbSet<Country> Countries { get; }
DbSet<State> States { get; }
DbSet<City> Cities { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// استراتژی محاسبه کمیسیون هفتگی
/// دو پیاده‌سازی دارد: ORM و Stored Procedure
/// </summary>
public interface ICommissionCalculationStrategy
{
/// <summary>
/// محاسبه تعادل‌های هفتگی (مرحله 1)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>تعداد رکوردهای محاسبه شده</returns>
Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
/// <summary>
/// محاسبه استخر کمیسیون و پرداخت‌ها (مرحله 2)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>شناسه Pool ایجاد شده</returns>
Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نوع استراتژی محاسبه کمیسیون
/// </summary>
public enum CommissionCalculationStrategyType
{
/// <summary>
/// استفاده از Entity Framework (ORM) - پیش‌فرض
/// </summary>
Orm = 0,
/// <summary>
/// استفاده از Stored Procedure - بهینه‌تر برای داده‌های زیاد
/// </summary>
StoredProcedure = 1
}
@@ -0,0 +1,20 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Factory برای ایجاد Strategy مناسب براساس Config
/// </summary>
public interface ICommissionCalculationStrategyFactory
{
/// <summary>
/// ایجاد Strategy براساس تنظیمات سیستم
/// </summary>
/// <returns>استراتژی مناسب (ORM یا SP)</returns>
Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default);
/// <summary>
/// ایجاد Strategy مشخص
/// </summary>
/// <param name="strategyType">نوع استراتژی</param>
/// <returns>استراتژی درخواستی</returns>
ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType);
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Interface for sending token-related notifications via SignalR
/// </summary>
public interface ITokenNotificationService
{
/// <summary>
/// Notify that a user's token has been revoked and they should refresh their token
/// </summary>
/// <param name="userId">The user ID whose token was revoked</param>
/// <param name="reason">The reason for revocation</param>
Task NotifyTokenRevokedAsync(long userId, string reason);
/// <summary>
/// Notify that a user should refresh their token due to profile changes
/// </summary>
/// <param name="userId">The user ID whose profile changed</param>
Task NotifyForceRefreshAsync(long userId);
/// <summary>
/// Broadcast a message to all connected clients
/// </summary>
/// <param name="message">The message to broadcast</param>
Task BroadcastMessageAsync(string message);
}
@@ -0,0 +1,163 @@
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// اینترفیس ریپازیتوری هفته‌ها با کش در حافظه
/// این سرویس موقع استارت برنامه از دیتابیس لود می‌شود و در حافظه نگهداری می‌شود
/// </summary>
public interface IWeekDefinitionRepository
{
/// <summary>
/// گرفتن هفته جاری
/// </summary>
WeekDefinition? GetCurrentWeek();
/// <summary>
/// گرفتن هفته جاری (async برای سازگاری)
/// </summary>
Task<WeekDefinition?> GetCurrentWeekAsync(CancellationToken cancellationToken = default);
/// <summary>
/// گرفتن هفته بعد
/// </summary>
WeekDefinition? GetNextWeek();
/// <summary>
/// گرفتن هفته بعد از هفته مشخص
/// </summary>
WeekDefinition? GetNextWeek(WeekDefinition currentWeek);
/// <summary>
/// گرفتن هفته قبل
/// </summary>
WeekDefinition? GetPreviousWeek();
/// <summary>
/// گرفتن هفته قبل از هفته مشخص
/// </summary>
WeekDefinition? GetPreviousWeek(WeekDefinition currentWeek);
/// <summary>
/// گرفتن هفته بر اساس تاریخ میلادی
/// </summary>
WeekDefinition? GetWeekByDate(DateTime date);
/// <summary>
/// گرفتن هفته بر اساس شماره هفته میلادی (مثال: "2025-W46")
/// </summary>
WeekDefinition? GetWeekByGregorianWeekNumber(string gregorianWeekNumber);
/// <summary>
/// گرفتن هفته بر اساس شماره هفته شمسی (مثال: "1404-W35")
/// </summary>
WeekDefinition? GetWeekByPersianWeekNumber(string persianWeekNumber);
/// <summary>
/// گرفتن هفته بر اساس شماره ترتیبی
/// </summary>
WeekDefinition? GetWeekByOrder(int weekOrder);
/// <summary>
/// گرفتن لیست همه هفته‌ها
/// </summary>
IReadOnlyList<WeekDefinition> GetAllWeeks();
/// <summary>
/// گرفتن هفته‌های یک سال میلادی خاص
/// </summary>
IReadOnlyList<WeekDefinition> GetWeeksByGregorianYear(int year);
/// <summary>
/// گرفتن هفته‌های یک سال شمسی خاص
/// </summary>
IReadOnlyList<WeekDefinition> GetWeeksByPersianYear(int year);
/// <summary>
/// گرفتن شماره هفته میلادی جاری (مثال: "2025-W51")
/// </summary>
string GetCurrentGregorianWeekNumber();
/// <summary>
/// گرفتن شماره هفته شمسی جاری (مثال: "1404-W39")
/// </summary>
string GetCurrentPersianWeekNumber();
/// <summary>
/// گرفتن نام نمایشی هفته جاری (مثال: "هفته ششم")
/// </summary>
string GetCurrentWeekDisplayName();
/// <summary>
/// آیا تاریخ مشخص در هفته جاری است؟
/// </summary>
bool IsDateInCurrentWeek(DateTime date);
/// <summary>
/// محاسبه شماره هفته میلادی برای یک تاریخ (حتی اگر در کش نباشد)
/// </summary>
string CalculateGregorianWeekNumber(DateTime date);
/// <summary>
/// محاسبه شماره هفته شمسی برای یک تاریخ (حتی اگر در کش نباشد)
/// </summary>
string CalculatePersianWeekNumber(DateTime date);
/// <summary>
/// بارگذاری مجدد کش از دیتابیس
/// </summary>
Task ReloadCacheAsync(CancellationToken cancellationToken = default);
/// <summary>
/// گرفتن بازه تاریخی یک هفته بر اساس شماره هفته میلادی
/// </summary>
(DateTime startDate, DateTime endDate)? GetWeekDateRange(string gregorianWeekNumber);
(DateTime startDate, DateTime endDate)? GetWeekDateRange(long WeekDefinitionId);
/// <summary>
/// گرفتن شماره هفته قبل از هفته مشخص
/// </summary>
string? GetPreviousWeekNumber(string gregorianWeekNumber);
/// <summary>
/// گرفتن شماره هفته بعد از هفته مشخص
/// </summary>
string? GetNextWeekNumber(string gregorianWeekNumber);
/// <summary>
/// گرفتن DisplayName یک هفته بر اساس شماره هفته میلادی (مثل "هفته یکم")
/// </summary>
string GetDisplayNameByGregorianWeekNumber(string gregorianWeekNumber);
/// <summary>
/// جستجوی هفته‌ها بر اساس DisplayName (برای dropdown)
/// اگر فیلتر خالی باشد همه هفته‌ها برگردانده می‌شود
/// </summary>
IEnumerable<WeekDefinition> SearchWeeksByDisplayName(string? filter = null);
/// <summary>
/// گرفتن WeekDefinitionId از شماره هفته میلادی
/// </summary>
/// <param name="gregorianWeekNumber">شماره هفته میلادی (مثال: "2025-W46")</param>
/// <returns>شناسه WeekDefinition یا null اگر پیدا نشد</returns>
long? GetWeekDefinitionId(string gregorianWeekNumber);
WeekDefinition? GetWeekById(long weekDefinitionId);
/// <summary>
/// گرفتن شماره هفته میلادی از WeekDefinitionId
/// </summary>
/// <param name="weekDefinitionId">شناسه WeekDefinition</param>
/// <returns>شماره هفته میلادی یا null اگر پیدا نشد</returns>
string? GetGregorianWeekNumber(long weekDefinitionId);
/// <summary>
/// آیا کش لود شده؟
/// </summary>
bool IsCacheLoaded { get; }
/// <summary>
/// تعداد هفته‌های موجود در کش
/// </summary>
int CachedWeeksCount { get; }
}
@@ -22,7 +22,7 @@ public class GetConfigurationHistoryQueryHandler : IRequestHandler<GetConfigurat
var query = _context.SystemConfigurationHistories
.Where(x => x.ConfigurationId == request.ConfigurationId)
.ApplyOrder(sortBy: request.SortBy ?? "-Created") // پیش‌فرض: جدیدترین اول
.ApplyOrder(sortBy: request.SortBy ?? "Created") // پیش‌فرض: جدیدترین اول
.AsNoTracking()
.AsQueryable();
@@ -10,6 +10,11 @@ public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanSt
private readonly IApplicationDbContext _context;
private readonly IDayaLoanApiService _dayaApiService;
private readonly ILogger<CheckDayaLoanStatusCommandHandler> _logger;
/// <summary>
/// مبلغ وام دایا - 56 میلیون ریال
/// </summary>
private const long DAYA_LOAN_AMOUNT = 56_000_000;
public CheckDayaLoanStatusCommandHandler(
IApplicationDbContext context,
@@ -48,9 +53,24 @@ public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanSt
if (existingContract != null)
{
var previousStatus = existingContract.Status;
existingContract.LastCheckDate = DateTime.Now;
existingContract.Status = dayaResult.Status;
existingContract.ContractNumber = dayaResult.ContractNumber;
// بررسی تغییر وضعیت به PendingReceive یا Received
// فقط اگر قبلاً پردازش نشده باشد
if (!existingContract.IsProcessed &&
!string.IsNullOrEmpty(dayaResult.ContractNumber) &&
(dayaResult.Status == DayaLoanStatus.PendingReceive || dayaResult.Status == DayaLoanStatus.Received))
{
await ProcessDayaLoanReceivedAsync(existingContract.UserId, dayaResult.ContractNumber, cancellationToken);
existingContract.IsProcessed = true;
_logger.LogInformation(
"Daya loan processed for User {UserId}, ContractNumber: {ContractNumber}, Amount: {Amount}",
existingContract.UserId, dayaResult.ContractNumber, DAYA_LOAN_AMOUNT);
}
}
else
{
@@ -59,6 +79,20 @@ public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanSt
if (user != null)
{
var isProcessed = false;
// اگر وضعیت PendingReceive یا Received بود و قرارداد دارد، فوری پردازش کن
if (!string.IsNullOrEmpty(dayaResult.ContractNumber) &&
(dayaResult.Status == DayaLoanStatus.PendingReceive || dayaResult.Status == DayaLoanStatus.Received))
{
await ProcessDayaLoanReceivedAsync(user.Id, dayaResult.ContractNumber, cancellationToken);
isProcessed = true;
_logger.LogInformation(
"Daya loan processed for new contract - User {UserId}, ContractNumber: {ContractNumber}, Amount: {Amount}",
user.Id, dayaResult.ContractNumber, DAYA_LOAN_AMOUNT);
}
var newContract = new DayaLoanContract
{
UserId = user.Id,
@@ -66,7 +100,7 @@ public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanSt
Status = dayaResult.Status,
ContractNumber = dayaResult.ContractNumber,
LastCheckDate = DateTime.Now,
IsProcessed = false
IsProcessed = isProcessed
};
await _context.DayaLoanContracts.AddAsync(newContract, cancellationToken);
@@ -105,4 +139,101 @@ public class CheckDayaLoanStatusCommandHandler : IRequestHandler<CheckDayaLoanSt
SuccessCount = results.Count(r => r.ContractNumber != null)
};
}
/// <summary>
/// پردازش دریافت وام دایا برای کاربر
/// 1. شارژ کیف پول (Balance + DiscountBalance)
/// 2. ثبت تراکنش
/// 3. ثبت لاگ کیف پول
/// 4. به‌روزرسانی وضعیت کاربر
/// </summary>
private async Task ProcessDayaLoanReceivedAsync(long userId, string contractNumber, CancellationToken cancellationToken)
{
// پیدا کردن کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User {UserId} not found for Daya loan processing", userId);
return;
}
// بررسی اینکه قبلاً دریافت نکرده باشد
if (user.HasReceivedDayaCredit)
{
_logger.LogWarning("User {UserId} has already received Daya credit", userId);
return;
}
// پیدا کردن یا ایجاد کیف پول کاربر
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == userId, cancellationToken);
if (wallet == null)
{
wallet = new UserWallet
{
UserId = userId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
await _context.UserWallets.AddAsync(wallet, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// 1. شارژ کیف پول - هم موجودی اصلی و هم موجودی تخفیف
var previousBalance = wallet.Balance;
var previousDiscountBalance = wallet.DiscountBalance;
wallet.Balance += DAYA_LOAN_AMOUNT;
wallet.DiscountBalance += DAYA_LOAN_AMOUNT;
// 2. ثبت تراکنش - RefId = شماره قرارداد، Status = 0 (Success)، Type = 2 (DepositExternal1)
var transaction = new Transaction
{
Amount = DAYA_LOAN_AMOUNT,
Description = $"شارژ کیف پول از وام دایا - قرارداد {contractNumber}",
PaymentStatus = PaymentStatus.Success, // 0
PaymentDate = DateTime.Now,
RefId = contractNumber,
Type = TransactionType.DepositExternal1 // 2
};
await _context.Transactions.AddAsync(transaction, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// 3. ثبت لاگ کیف پول با شناسه تراکنش
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = DAYA_LOAN_AMOUNT,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = DAYA_LOAN_AMOUNT,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(walletLog, cancellationToken);
// 4. به‌روزرسانی وضعیت کاربر
user.HasReceivedDayaCredit = true;
user.DayaCreditReceivedAt = DateTime.Now;
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan; // 1
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Daya loan fully processed for User {UserId}: " +
"Wallet Balance {PreviousBalance} -> {NewBalance}, " +
"DiscountBalance {PreviousDiscountBalance} -> {NewDiscountBalance}, " +
"Transaction Id: {TransactionId}",
userId, previousBalance, wallet.Balance,
previousDiscountBalance, wallet.DiscountBalance,
transaction.Id);
}
}
@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.ProcessManualMembershipPayment;
public record ProcessManualMembershipPaymentCommand : IRequest<ProcessManualMembershipPaymentResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// مبلغ پرداختی (ریال)
/// </summary>
public long Amount { get; init; }
/// <summary>
/// شماره مرجع تراکنش
/// </summary>
public string ReferenceNumber { get; init; } = string.Empty;
/// <summary>
/// توضیحات (اختیاری)
/// </summary>
public string? Description { get; init; }
}
@@ -0,0 +1,185 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.ProcessManualMembershipPayment;
public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<ProcessManualMembershipPaymentCommand, ProcessManualMembershipPaymentResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly ILogger<ProcessManualMembershipPaymentCommandHandler> _logger;
public ProcessManualMembershipPaymentCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
ILogger<ProcessManualMembershipPaymentCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_logger = logger;
}
public async Task<ProcessManualMembershipPaymentResponseDto> Handle(
ProcessManualMembershipPaymentCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Processing manual membership payment for UserId: {UserId}, Amount: {Amount}",
request.UserId, request.Amount);
// 1. دریافت شناسه ادمین از کاربر جاری
var currentUserId = _currentUser.UserId;
if (string.IsNullOrEmpty(currentUserId))
{
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
}
if (!long.TryParse(currentUserId, out var adminUserId))
{
throw new UnauthorizedAccessException("شناسه کاربر نامعتبر است");
}
// 2. بررسی وجود کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogError("User not found: {UserId}", request.UserId);
throw new NotFoundException($"کاربر با شناسه {request.UserId} یافت نشد");
}
// 3. ایجاد ManualPayment با وضعیت Approved
var manualPayment = new ManualPayment
{
UserId = request.UserId,
Amount = request.Amount,
Type = ManualPaymentType.CashDeposit,
Description = request.Description ?? "پرداخت دستی عضویت",
ReferenceNumber = request.ReferenceNumber,
Status = ManualPaymentStatus.Approved,
RequestedBy = adminUserId,
ApprovedBy = adminUserId,
ApprovedAt = DateTime.Now
};
_context.ManualPayments.Add(manualPayment);
await _context.SaveChangesAsync(cancellationToken);
// 4. پیدا کردن یا ایجاد کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد");
}
// 6. ثبت تراکنش
var transaction = new Transaction
{
Amount = request.Amount,
Description = $"پرداخت دستی عضویت - {manualPayment.Description} - مرجع: {request.ReferenceNumber}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = request.ReferenceNumber,
Type = TransactionType.DepositExternal1
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
// 7. اعمال تغییرات بر کیف پول
var oldBalance = wallet.Balance;
var oldDiscountBalance = wallet.DiscountBalance;
wallet.Balance += request.Amount;
wallet.DiscountBalance += request.Amount;
// 8. ثبت لاگ Balance
var balanceLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = request.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = request.Amount,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(balanceLog, cancellationToken);
// 10. به‌روزرسانی ManualPayment با TransactionId
manualPayment.TransactionId = transaction.Id;
await _context.SaveChangesAsync(cancellationToken);
// 11. پیدا کردن یا ایجاد آدرس پیشفرض کاربر
var userAddress = await _context.UserAddresses
.Where(a => a.UserId == request.UserId)
.OrderByDescending(a => a.IsDefault)
.ThenBy(a => a.Id)
.FirstOrDefaultAsync(cancellationToken);
if (userAddress == null)
{
userAddress = new UserAddress
{
UserId = request.UserId,
Title = "آدرس پیشفرض",
Address = "پرداخت دستی عضویت - آدرس موقت",
PostalCode = "0000000000",
IsDefault = true,
CityId = 1
};
await _context.UserAddresses.AddAsync(userAddress, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// 12. ثبت سفارش
var order = new UserOrder
{
UserId = request.UserId,
Amount = request.Amount,
TransactionId = transaction.Id,
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
PaymentMethod = PaymentMethod.Deposit,
DeliveryStatus = DeliveryStatus.None,
UserAddressId = userAddress.Id,
DeliveryDescription = $"پرداخت دستی عضویت - مرجع: {request.ReferenceNumber}"
};
_context.UserOrders.Add(order);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Manual membership payment processed successfully. UserId: {UserId}, Amount: {Amount}, ManualPaymentId: {ManualPaymentId}, TransactionId: {TransactionId}, OrderId: {OrderId}, AdminUserId: {AdminUserId}",
request.UserId, request.Amount, manualPayment.Id, transaction.Id, order.Id, adminUserId);
return new ProcessManualMembershipPaymentResponseDto
{
TransactionId = transaction.Id,
OrderId = order.Id,
NewWalletBalance = wallet.Balance,
Message = "پرداخت دستی با موفقیت ثبت شد"
};
}
catch (Exception ex) when (ex is not NotFoundException)
{
_logger.LogError(ex,
"Error processing manual membership payment for UserId: {UserId}, Amount: {Amount}",
request.UserId, request.Amount);
throw;
}
}
}
@@ -0,0 +1,21 @@
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.ProcessManualMembershipPayment;
public class ProcessManualMembershipPaymentCommandValidator : AbstractValidator<ProcessManualMembershipPaymentCommand>
{
public ProcessManualMembershipPaymentCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("مبلغ باید بزرگتر از صفر باشد");
RuleFor(x => x.ReferenceNumber)
.NotEmpty()
.WithMessage("شماره مرجع الزامی است")
.MaximumLength(50)
.WithMessage("شماره مرجع نباید بیشتر از 50 کاراکتر باشد");
}
}
@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.ProcessManualMembershipPayment;
public class ProcessManualMembershipPaymentResponseDto
{
/// <summary>
/// شناسه تراکنش ثبت شده
/// </summary>
public long TransactionId { get; set; }
/// <summary>
/// شناسه سفارش ثبت شده
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// موجودی جدید کیف پول
/// </summary>
public long NewWalletBalance { get; set; }
/// <summary>
/// پیام موفقیت
/// </summary>
public string Message { get; set; } = "پرداخت دستی با موفقیت ثبت شد";
}
@@ -11,7 +11,7 @@ public record GetNetworkMembershipHistoryQuery : IRequest<GetNetworkMembershipHi
public long? UserId { get; init; }
/// <summary>
/// مرتب‌سازی (پیش‌فرض: -Created)
/// مرتب‌سازی (پیش‌فرض: Created)
/// </summary>
public string? SortBy { get; init; }
@@ -20,7 +20,7 @@ public class GetNetworkMembershipHistoryQueryHandler : IRequestHandler<GetNetwor
query = query.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -56,16 +56,20 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatis
// Monthly growth (last 6 months) - using Created date
var sixMonthsAgo = DateTime.Now.AddMonths(-6);
var monthlyGrowth = await _context.Users
var monthlyGrowthRaw = await _context.Users
.Where(x => x.NetworkParentId != null && x.Created >= sixMonthsAgo)
.GroupBy(x => new { x.Created.Year, x.Created.Month })
.Select(x => new { x.Created.Year, x.Created.Month })
.ToListAsync(cancellationToken);
var monthlyGrowth = monthlyGrowthRaw
.GroupBy(x => new { x.Year, x.Month })
.Select(g => new MonthlyGrowthModel
{
Month = $"{g.Key.Year}-{g.Key.Month:D2}",
NewMembers = g.Count()
})
.OrderBy(x => x.Month)
.ToListAsync(cancellationToken);
.ToList();
// Top users by total children count
var topUsers = await _context.Users
@@ -14,4 +14,14 @@ public record GetNetworkTreeQuery : IRequest<NetworkTreeDto?>
/// تعداد سطوح (Depth) که می‌خواهیم نمایش دهیم (پیش‌فرض: 3)
/// </summary>
public int MaxDepth { get; init; } = 3;
/// <summary>
/// فیلتر براساس وضعیت فعال بودن باشگاه مشتریان
/// </summary>
public bool? IsClubActive { get; init; }
/// <summary>
/// فیلتر براساس شماره هفته فعالسازی (مثال: 2025-W05)
/// </summary>
public long? ActivationWeekDefinitionId { get; init; }
}
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree
public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, NetworkTreeDto?>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetNetworkTreeQueryHandler(IApplicationDbContext context)
public GetNetworkTreeQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken)
@@ -20,13 +24,15 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
return null;
}
var tree = await BuildTree(rootUser.Id, request.MaxDepth, 0, cancellationToken);
var tree = await BuildTree(rootUser.Id, request.MaxDepth, 0, cancellationToken, request);
return tree;
}
private async Task<NetworkTreeDto> BuildTree(long userId, int maxDepth, int currentDepth, CancellationToken cancellationToken)
private async Task<NetworkTreeDto> BuildTree(long userId, int maxDepth, int currentDepth, CancellationToken cancellationToken, GetNetworkTreeQuery request)
{
// دریافت کاربر با اطلاعات باشگاه مشتریان
var user = await _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
@@ -35,6 +41,25 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
throw new NotFoundException(nameof(User), userId);
}
// محاسبه شماره هفته فعال‌سازی
long? activationWeekDefinitionId = null;
string? activationWeekDisplayName = null;
bool isActivatedInTargetWeek = false;
if (user.ClubMembership?.ActivatedAt != null)
{
// activationWeekDefinitionId = CalculateWeekNumber(user.ClubMembership.ActivatedAt.Value);
var week = _weekDefinitionRepository.GetWeekByDate(user.ClubMembership.ActivatedAt.Value);
activationWeekDefinitionId = week.Id;
activationWeekDisplayName = week.DisplayName;
// بررسی آیا در هفته هدف فعال شده است
if (request.ActivationWeekDefinitionId!=null)
{
isActivatedInTargetWeek = activationWeekDefinitionId == request.ActivationWeekDefinitionId;
}
}
var node = new NetworkTreeDto
{
UserId = user.Id,
@@ -42,7 +67,13 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
FirstName = user.FirstName,
LastName = user.LastName,
LegPosition = user.LegPosition,
CurrentDepth = currentDepth
CurrentDepth = currentDepth,
ClubActivatedAt = user.ClubMembership?.ActivatedAt,
IsClubActive = user.ClubMembership?.IsActive ?? false,
ActivationWeekDefinitionId = activationWeekDefinitionId,
ActivationWeekDisplayName = activationWeekDisplayName,
IsActivatedInTargetWeek = isActivatedInTargetWeek,
UserCreated = user.Created
};
// اگر به حداکثر عمق رسیدیم، دیگر فرزندان را نمی‌خوانیم
@@ -51,28 +82,63 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
return node;
}
// پیدا کردن فرزند چپ
var leftChild = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Left,
cancellationToken);
// پیدا کردن فرزندان (چپ و راست)
var children = await GetFilteredChildren(userId, request, cancellationToken);
var leftChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Left);
if (leftChild != null)
{
node.LeftChild = await BuildTree(leftChild.Id, maxDepth, currentDepth + 1, cancellationToken);
node.LeftChild = await BuildTree(leftChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
// پیدا کردن فرزند راست
var rightChild = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Right,
cancellationToken);
var rightChild = children.FirstOrDefault(c => c.LegPosition == NetworkLeg.Right);
if (rightChild != null)
{
node.RightChild = await BuildTree(rightChild.Id, maxDepth, currentDepth + 1, cancellationToken);
node.RightChild = await BuildTree(rightChild.Id, maxDepth, currentDepth + 1, cancellationToken, request);
}
return node;
}
/// <summary>
/// دریافت فرزندان با اعمال فیلترها
/// </summary>
private async Task<List<User>> GetFilteredChildren(long parentId, GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var query = _context.Users
.Include(u => u.ClubMembership)
.AsNoTracking()
.Where(x => x.NetworkParentId == parentId);
// اعمال فیلتر IsClubActive
if (request.IsClubActive.HasValue)
{
query = query.Where(u =>
u.ClubMembership != null &&
u.ClubMembership.IsDeleted == false &&
u.ClubMembership.IsActive == request.IsClubActive.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <summary>
/// محاسبه شماره هفته از تاریخ
/// </summary>
// private long CalculateWeekNumber(DateTime date)
// {
// // First try to get from repository cache
// var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
// if (weekDef != null)
// {
// return weekDef.Id;
// }
//
// // Fallback: use repository's calculation method
// // return _weekDefinitionRepository.CalculateGregorianWeekNumber(date);
// return 0;
// }
}
@@ -9,8 +9,8 @@ public class GetNetworkTreeQueryValidator : AbstractValidator<GetNetworkTreeQuer
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.MaxDepth)
.InclusiveBetween(1, 10)
.WithMessage("عمق درخت باید بین 1 تا 10 باشد");
.InclusiveBetween(1, 20)
.WithMessage("عمق درخت باید بین 1 تا 20 باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -11,6 +11,37 @@ public class NetworkTreeDto
public string? LastName { get; set; }
public NetworkLeg? LegPosition { get; set; }
public int CurrentDepth { get; set; }
/// <summary>
/// تاریخ فعال‌سازی در باشگاه مشتریان
/// </summary>
public DateTime? ClubActivatedAt { get; set; }
/// <summary>
/// وضعیت فعال بودن در باشگاه مشتریان
/// </summary>
public bool IsClubActive { get; set; }
/// <summary>
/// شماره هفته فعال‌سازی
/// </summary>
public long? ActivationWeekDefinitionId { get; set; }
/// <summary>
/// نام نمایشی هفته فعال‌سازی (مثل: هفته یکم)
/// </summary>
public string? ActivationWeekDisplayName { get; set; }
/// <summary>
/// آیا کاربر در هفته هدف فعال شده است؟
/// </summary>
public bool IsActivatedInTargetWeek { get; set; }
/// <summary>
/// تاریخ ایجاد کاربر
/// </summary>
public DateTimeOffset UserCreated { get; set; }
public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; }
}
@@ -11,6 +11,7 @@ public class GetUserNetworkPositionQueryHandler : IRequestHandler<GetUserNetwork
public async Task<UserNetworkPositionDto?> Handle(GetUserNetworkPositionQuery request, CancellationToken cancellationToken)
{
// واکشی اطلاعات اصلی کاربر
var user = await _context.Users
.AsNoTracking()
.Where(x => x.Id == request.UserId)
@@ -20,8 +21,17 @@ public class GetUserNetworkPositionQueryHandler : IRequestHandler<GetUserNetwork
x.Mobile,
x.FirstName,
x.LastName,
x.Email,
x.NationalCode,
x.ReferralCode,
x.IsMobileVerified,
x.BirthDate,
x.NetworkParentId,
x.LegPosition
x.LegPosition,
x.HasReceivedDayaCredit,
x.DayaCreditReceivedAt,
x.PackagePurchaseMethod,
x.Created
})
.FirstOrDefaultAsync(cancellationToken);
@@ -30,41 +40,196 @@ public class GetUserNetworkPositionQueryHandler : IRequestHandler<GetUserNetwork
return null;
}
// شمارش فرزندان
var childrenCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId, cancellationToken);
var leftChildCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId && x.LegPosition == NetworkLeg.Left,
cancellationToken);
var rightChildCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId && x.LegPosition == NetworkLeg.Right,
cancellationToken);
// اطلاعات والد
string? parentMobile = null;
string? parentFullName = null;
if (user.NetworkParentId.HasValue)
{
parentMobile = await _context.Users
var parent = await _context.Users
.Where(x => x.Id == user.NetworkParentId)
.Select(x => x.Mobile)
.Select(x => new { x.Mobile, x.FirstName, x.LastName })
.FirstOrDefaultAsync(cancellationToken);
if (parent != null)
{
parentMobile = parent.Mobile;
parentFullName = $"{parent.FirstName} {parent.LastName}".Trim();
}
}
// شمارش فرزندان مستقیم
var directChildren = await _context.Users
.Where(x => x.NetworkParentId == request.UserId)
.Select(x => new { x.Id, x.LegPosition, x.FirstName, x.LastName, x.Mobile, x.Created })
.ToListAsync(cancellationToken);
var leftChild = directChildren.FirstOrDefault(x => x.LegPosition == NetworkLeg.Left);
var rightChild = directChildren.FirstOrDefault(x => x.LegPosition == NetworkLeg.Right);
// محاسبه تعداد کل اعضای هر شاخه (با استفاده از CTE برای عملکرد بهتر)
var leftLegCount = await GetLegMemberCountAsync(request.UserId, NetworkLeg.Left, cancellationToken);
var rightLegCount = await GetLegMemberCountAsync(request.UserId, NetworkLeg.Right, cancellationToken);
var totalNetworkSize = leftLegCount + rightLegCount;
// محاسبه حداکثر عمق شبکه
var maxDepth = await GetMaxNetworkDepthAsync(request.UserId, cancellationToken);
// آمار اعضای فعال/غیرفعال (کسانی که پکیج خریده‌اند)
var allDescendantIds = await GetAllDescendantIdsAsync(request.UserId, cancellationToken);
var activeCount = await _context.Users
.CountAsync(x => allDescendantIds.Contains(x.Id) &&
x.PackagePurchaseMethod != PackagePurchaseMethod.None,
cancellationToken);
var inactiveCount = allDescendantIds.Count - activeCount;
// آمار کمیسیون‌های کاربر
var commissionStats = await _context.UserCommissionPayouts
.Where(x => x.UserId == request.UserId)
.GroupBy(x => x.UserId)
.Select(g => new
{
TotalBalances = g.Sum(x => x.BalancesEarned),
TotalAmount = g.Sum(x => x.TotalAmount),
PaidAmount = g.Where(x => x.Status == CommissionPayoutStatus.Paid).Sum(x => x.TotalAmount),
PendingAmount = g.Where(x => x.Status == CommissionPayoutStatus.Pending).Sum(x => x.TotalAmount)
})
.FirstOrDefaultAsync(cancellationToken);
return new UserNetworkPositionDto
{
// اطلاعات اصلی
UserId = user.Id,
Mobile = user.Mobile,
FirstName = user.FirstName,
LastName = user.LastName,
Email = user.Email,
NationalCode = user.NationalCode,
ReferralCode = user.ReferralCode,
IsMobileVerified = user.IsMobileVerified,
BirthDate = user.BirthDate,
JoinedAt = user.Created,
// اطلاعات شبکه
NetworkParentId = user.NetworkParentId,
ParentMobile = parentMobile,
ParentFullName = parentFullName,
LegPosition = user.LegPosition,
TotalChildren = childrenCount,
LeftChildCount = leftChildCount,
RightChildCount = rightChildCount,
IsInNetwork = user.NetworkParentId.HasValue
IsInNetwork = user.NetworkParentId.HasValue,
// آمار فرزندان مستقیم
TotalChildren = directChildren.Count,
LeftChildCount = leftChild != null ? 1 : 0,
RightChildCount = rightChild != null ? 1 : 0,
// اطلاعات فرزند چپ
LeftChildId = leftChild?.Id,
LeftChildFullName = leftChild != null ? $"{leftChild.FirstName} {leftChild.LastName}".Trim() : null,
LeftChildMobile = leftChild?.Mobile,
LeftChildJoinedAt = leftChild?.Created,
// اطلاعات فرزند راست
RightChildId = rightChild?.Id,
RightChildFullName = rightChild != null ? $"{rightChild.FirstName} {rightChild.LastName}".Trim() : null,
RightChildMobile = rightChild?.Mobile,
RightChildJoinedAt = rightChild?.Created,
// آمار کل شبکه
TotalLeftLegMembers = leftLegCount,
TotalRightLegMembers = rightLegCount,
TotalNetworkSize = totalNetworkSize,
MaxNetworkDepth = maxDepth,
// اطلاعات پکیج و دایا
HasReceivedDayaCredit = user.HasReceivedDayaCredit,
DayaCreditReceivedAt = user.DayaCreditReceivedAt,
PackagePurchaseMethod = user.PackagePurchaseMethod,
HasPurchasedGoldenPackage = user.PackagePurchaseMethod != PackagePurchaseMethod.None,
// آمار مالی
TotalEarnedCommission = commissionStats?.TotalAmount ?? 0,
TotalPaidCommission = commissionStats?.PaidAmount ?? 0,
PendingCommission = commissionStats?.PendingAmount ?? 0,
TotalBalancesEarned = commissionStats?.TotalBalances ?? 0,
// آمار فعالیت
ActiveMembersInNetwork = activeCount,
InactiveMembersInNetwork = inactiveCount
};
}
/// <summary>
/// محاسبه تعداد اعضای یک شاخه (چپ یا راست) به صورت بازگشتی
/// </summary>
private async Task<int> GetLegMemberCountAsync(long userId, NetworkLeg leg, CancellationToken cancellationToken)
{
var directChild = await _context.Users
.Where(x => x.NetworkParentId == userId && x.LegPosition == leg)
.Select(x => x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (directChild == 0)
return 0;
// تعداد کل زیرمجموعه این فرزند + خود فرزند
var descendants = await GetAllDescendantIdsAsync(directChild, cancellationToken);
return descendants.Count + 1; // +1 برای خود فرزند
}
/// <summary>
/// محاسبه حداکثر عمق شبکه
/// </summary>
private async Task<int> GetMaxNetworkDepthAsync(long userId, CancellationToken cancellationToken)
{
var maxDepth = 0;
var currentLevelIds = new List<long> { userId };
while (currentLevelIds.Any())
{
var nextLevelIds = await _context.Users
.Where(x => currentLevelIds.Contains(x.NetworkParentId ?? 0))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (nextLevelIds.Any())
{
maxDepth++;
currentLevelIds = nextLevelIds;
}
else
{
break;
}
}
return maxDepth;
}
/// <summary>
/// دریافت تمام ID های زیرمجموعه یک کاربر
/// </summary>
private async Task<List<long>> GetAllDescendantIdsAsync(long userId, CancellationToken cancellationToken)
{
var allDescendants = new List<long>();
var currentLevelIds = new List<long> { userId };
while (currentLevelIds.Any())
{
var children = await _context.Users
.Where(x => currentLevelIds.Contains(x.NetworkParentId ?? 0))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (children.Any())
{
allDescendants.AddRange(children);
currentLevelIds = children;
}
else
{
break;
}
}
return allDescendants;
}
}
@@ -5,15 +5,62 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetwork
/// </summary>
public class UserNetworkPositionDto
{
// اطلاعات اصلی کاربر
public long UserId { get; set; }
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public string? NationalCode { get; set; }
public string ReferralCode { get; set; } = string.Empty;
public bool IsMobileVerified { get; set; }
public DateTime? BirthDate { get; set; }
public DateTime JoinedAt { get; set; }
// اطلاعات شبکه
public long? NetworkParentId { get; set; }
public string? ParentMobile { get; set; }
public string? ParentFullName { get; set; }
public NetworkLeg? LegPosition { get; set; }
public bool IsInNetwork { get; set; }
// آمار فرزندان مستقیم
public int TotalChildren { get; set; }
public int LeftChildCount { get; set; }
public int RightChildCount { get; set; }
public bool IsInNetwork { get; set; }
// آمار کل زیرمجموعه (تمام سطوح)
public int TotalLeftLegMembers { get; set; }
public int TotalRightLegMembers { get; set; }
public int TotalNetworkSize { get; set; }
// اطلاعات فرزندان مستقیم
public long? LeftChildId { get; set; }
public string? LeftChildFullName { get; set; }
public string? LeftChildMobile { get; set; }
public DateTime? LeftChildJoinedAt { get; set; }
public long? RightChildId { get; set; }
public string? RightChildFullName { get; set; }
public string? RightChildMobile { get; set; }
public DateTime? RightChildJoinedAt { get; set; }
// اطلاعات پکیج و دایا
public bool HasReceivedDayaCredit { get; set; }
public DateTime? DayaCreditReceivedAt { get; set; }
public PackagePurchaseMethod PackagePurchaseMethod { get; set; }
public bool HasPurchasedGoldenPackage { get; set; }
// آمار مالی
public decimal TotalEarnedCommission { get; set; }
public decimal TotalPaidCommission { get; set; }
public decimal PendingCommission { get; set; }
public int TotalBalancesEarned { get; set; }
// عمق شبکه
public int MaxNetworkDepth { get; set; }
// آمار فعالیت
public int ActiveMembersInNetwork { get; set; }
public int InactiveMembersInNetwork { get; set; }
}
@@ -57,11 +57,21 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
if (request.ParentReferralCode == null)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "کد معرف الزامی است." };
var parent = await _context.Users.FirstOrDefaultAsync(u => u.ReferralCode == request.ParentReferralCode,
cancellationToken: cancellationToken);
var parent = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.ReferralCode == request.ParentReferralCode,
cancellationToken: cancellationToken);
if (parent == null)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "معرف وجود ندارد." };
// بررسی فعالیت باشگاه معرف
if (parent.ClubMembership == null || !parent.ClubMembership.IsActive)
return new VerifyOtpTokenResponseDto()
{
Success = false,
Message = "لینک دعوت معرف فعال نیست. لطفاً از کد دعوت معتبر دیگری استفاده کنید."
};
if (await _context.Users.CountAsync(x => x.NetworkParentId == parent.Id, cancellationToken: cancellationToken) > 1)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
@@ -0,0 +1,43 @@
using MediatR;
namespace CMSMicroservice.Application.PackageCQ.Commands.InitiateBasePackagePayment;
/// <summary>
/// ثبت اولیه تراکنش و سفارش پکیج پایه (56 میلیون تومان)
/// این Command توسط BFF فراخوانی می‌شود قبل از ارسال کاربر به درگاه
/// </summary>
public record InitiateBasePackagePaymentCommand : IRequest<InitiateBasePackagePaymentResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
}
public class InitiateBasePackagePaymentResponseDto
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// شناسه سفارش
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// شناسه تراکنش CMS
/// </summary>
public long TransactionId { get; set; }
/// <summary>
/// مبلغ پکیج
/// </summary>
public long Amount { get; set; }
}
@@ -0,0 +1,173 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ValidationException = FluentValidation.ValidationException;
namespace CMSMicroservice.Application.PackageCQ.Commands.InitiateBasePackagePayment;
/// <summary>
/// Handler برای ثبت اولیه تراکنش و سفارش پکیج پایه
/// این Handler فقط Transaction و Order را با وضعیت Pending ایجاد می‌کند
/// BFF سپس کاربر را به درگاه PYMS هدایت می‌کند
/// </summary>
public class InitiateBasePackagePaymentCommandHandler : IRequestHandler<InitiateBasePackagePaymentCommand, InitiateBasePackagePaymentResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<InitiateBasePackagePaymentCommandHandler> _logger;
/// <summary>
/// مبلغ پکیج پایه (56 میلیون تومان)
/// </summary>
private const long BasePackageAmount = 56_000_000;
/// <summary>
/// شناسه پکیج پایه در دیتابیس
/// </summary>
private const long BasePackageId = 4;
public InitiateBasePackagePaymentCommandHandler(
IApplicationDbContext context,
ILogger<InitiateBasePackagePaymentCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<InitiateBasePackagePaymentResponseDto> Handle(
InitiateBasePackagePaymentCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Initiating base package payment for UserId: {UserId}",
request.UserId);
// 1. پیدا کردن کاربر
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found. UserId: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی عدم خرید قبلی پکیج پایه
if (user.PackagePurchaseMethod != PackagePurchaseMethod.None)
{
_logger.LogWarning(
"User {UserId} has already purchased base package via {Method}",
request.UserId,
user.PackagePurchaseMethod);
return new InitiateBasePackagePaymentResponseDto
{
Success = false,
Message = "شما قبلاً پکیج پایه را خریداری کرده‌اید."
};
}
// 3. بررسی عدم وجود سفارش Pending
var pendingOrder = await _context.UserOrders
.FirstOrDefaultAsync(o =>
o.UserId == request.UserId &&
o.PaymentStatus == PaymentStatus.Pending &&
o.PackageId == BasePackageId,
cancellationToken);
if (pendingOrder != null)
{
_logger.LogWarning(
"User {UserId} has pending order {OrderId}",
request.UserId,
pendingOrder.Id);
// برگرداندن سفارش موجود برای ادامه پرداخت
var existingTransaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == pendingOrder.TransactionId, cancellationToken);
return new InitiateBasePackagePaymentResponseDto
{
Success = true,
Message = "سفارش قبلی در انتظار پرداخت یافت شد.",
OrderId = pendingOrder.Id,
TransactionId = existingTransaction?.Id ?? 0,
Amount = BasePackageAmount
};
}
// 4. پیدا کردن آدرس پیش‌فرض کاربر
var defaultAddress = await _context.UserAddresses
.Where(a => a.UserId == request.UserId)
.OrderByDescending(a => a.Created)
.FirstOrDefaultAsync(cancellationToken);
if (defaultAddress == null)
{
_logger.LogWarning("No address found for user {UserId}", request.UserId);
throw new ValidationException("لطفاً ابتدا یک آدرس برای خود ثبت کنید.");
}
// 5. ایجاد Transaction با وضعیت Pending
var transaction = new Transaction
{
Amount = BasePackageAmount,
Description = $"خرید پکیج پایه ۵۶ میلیونی - کاربر #{user.Id}",
PaymentStatus = PaymentStatus.Pending,
Type = TransactionType.DepositIpg
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created Transaction {TransactionId} with Pending status for UserId {UserId}",
transaction.Id,
request.UserId);
// 6. ایجاد UserOrder با وضعیت Pending
var order = new UserOrder
{
UserId = user.Id,
PackageId = BasePackageId,
Amount = BasePackageAmount,
PaymentStatus = PaymentStatus.Pending,
DeliveryStatus = DeliveryStatus.None,
UserAddressId = defaultAddress.Id,
PaymentMethod = PaymentMethod.IPG,
TransactionId = transaction.Id
};
_context.UserOrders.Add(order);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created UserOrder {OrderId} for UserId {UserId}, TransactionId: {TransactionId}",
order.Id,
request.UserId,
transaction.Id);
return new InitiateBasePackagePaymentResponseDto
{
Success = true,
Message = "تراکنش و سفارش با موفقیت ثبت شد.",
OrderId = order.Id,
TransactionId = transaction.Id,
Amount = BasePackageAmount
};
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in InitiateBasePackagePaymentCommand for UserId: {UserId}",
request.UserId);
throw;
}
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace CMSMicroservice.Application.PackageCQ.Commands.InitiateBasePackagePayment;
public class InitiateBasePackagePaymentCommandValidator : AbstractValidator<InitiateBasePackagePaymentCommand>
{
public InitiateBasePackagePaymentCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست.");
}
}
@@ -0,0 +1,72 @@
using MediatR;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyBasePackagePayment;
/// <summary>
/// تکمیل یا رد پرداخت پکیج پایه (فراخوانی توسط BFF بعد از Callback)
/// </summary>
public record VerifyBasePackagePaymentCommand : IRequest<VerifyBasePackagePaymentResponseDto>
{
/// <summary>
/// شناسه سفارش CMS
/// </summary>
public long OrderId { get; init; }
/// <summary>
/// شناسه تراکنش CMS
/// </summary>
public long TransactionId { get; init; }
/// <summary>
/// آیا پرداخت موفق بوده؟ (نتیجه Verify از BFF)
/// </summary>
public bool PaymentSuccess { get; init; }
/// <summary>
/// کد پیگیری بانکی (در صورت موفقیت)
/// </summary>
public string? RefId { get; init; }
/// <summary>
/// پیام از درگاه
/// </summary>
public string? Message { get; init; }
}
public class VerifyBasePackagePaymentResponseDto
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// شناسه سفارش
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// شناسه تراکنش
/// </summary>
public long TransactionId { get; set; }
/// <summary>
/// کد پیگیری بانکی
/// </summary>
public string? ReferenceCode { get; set; }
/// <summary>
/// موجودی کیف پول بعد از شارژ
/// </summary>
public long WalletBalance { get; set; }
/// <summary>
/// موجودی تخفیف بعد از شارژ
/// </summary>
public long DiscountBalance { get; set; }
}
@@ -0,0 +1,209 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyBasePackagePayment;
/// <summary>
/// Handler برای تکمیل یا رد پرداخت پکیج پایه
/// فراخوانی توسط BFF بعد از اینکه نتیجه Verify از PYMS مشخص شد
///
/// اگر PaymentSuccess = true:
/// - شارژ کیف پول (Balance + DiscountBalance)
/// - ثبت لاگ تغییر کیف پول
/// - به‌روزرسانی سفارش و کاربر
///
/// اگر PaymentSuccess = false:
/// - فقط آپدیت وضعیت تراکنش و سفارش به Reject
/// </summary>
public class VerifyBasePackagePaymentCommandHandler : IRequestHandler<VerifyBasePackagePaymentCommand, VerifyBasePackagePaymentResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<VerifyBasePackagePaymentCommandHandler> _logger;
/// <summary>
/// مبلغ پکیج پایه (56 میلیون تومان)
/// </summary>
private const long BasePackageAmount = 56_000_000;
public VerifyBasePackagePaymentCommandHandler(
IApplicationDbContext context,
ILogger<VerifyBasePackagePaymentCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<VerifyBasePackagePaymentResponseDto> Handle(
VerifyBasePackagePaymentCommand request,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Processing base package payment result. OrderId: {OrderId}, TransactionId: {TransactionId}, PaymentSuccess: {PaymentSuccess}",
request.OrderId,
request.TransactionId,
request.PaymentSuccess);
// 1. پیدا کردن سفارش با کاربر
var order = await _context.UserOrders
.Include(o => o.User)
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
if (order == null)
{
_logger.LogWarning("Order not found. OrderId: {OrderId}", request.OrderId);
throw new NotFoundException(nameof(UserOrder), request.OrderId);
}
// 2. پیدا کردن تراکنش
var transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == request.TransactionId, cancellationToken);
if (transaction == null)
{
_logger.LogWarning("Transaction not found. TransactionId: {TransactionId}", request.TransactionId);
throw new NotFoundException(nameof(Transaction), request.TransactionId);
}
// 3. بررسی Idempotency - اگر قبلاً تایید شده باشد
if (order.PaymentStatus == PaymentStatus.Success)
{
_logger.LogInformation(
"Order {OrderId} already verified successfully",
request.OrderId);
var existingWallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
return new VerifyBasePackagePaymentResponseDto
{
Success = true,
Message = "پرداخت قبلاً با موفقیت تایید شده است.",
OrderId = order.Id,
TransactionId = transaction.Id,
ReferenceCode = transaction.RefId,
WalletBalance = existingWallet?.Balance ?? 0,
DiscountBalance = existingWallet?.DiscountBalance ?? 0
};
}
// 4. اگر پرداخت ناموفق بود - فقط وضعیت را Reject می‌کنیم
if (!request.PaymentSuccess)
{
_logger.LogWarning(
"Payment failed. OrderId: {OrderId}, Message: {Message}",
request.OrderId,
request.Message);
transaction.PaymentStatus = PaymentStatus.Reject;
order.PaymentStatus = PaymentStatus.Reject;
await _context.SaveChangesAsync(cancellationToken);
return new VerifyBasePackagePaymentResponseDto
{
Success = false,
Message = request.Message ?? "پرداخت ناموفق بود.",
OrderId = order.Id,
TransactionId = transaction.Id
};
}
// 5. پرداخت موفق - پیدا کردن یا ایجاد کیف پول
var userWallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
if (userWallet == null)
{
_logger.LogInformation("Creating new wallet for UserId: {UserId}", order.UserId);
userWallet = new UserWallet
{
UserId = order.UserId,
Balance = 0,
DiscountBalance = 0,
NetworkBalance = 0
};
_context.UserWallets.Add(userWallet);
await _context.SaveChangesAsync(cancellationToken);
}
// 6. شارژ کیف پول (هم Balance و هم DiscountBalance)
var oldBalance = userWallet.Balance;
var oldDiscountBalance = userWallet.DiscountBalance;
userWallet.Balance += BasePackageAmount;
userWallet.DiscountBalance += BasePackageAmount;
_logger.LogInformation(
"Charging wallet for user {UserId}. Balance: {OldBalance} -> {NewBalance}, DiscountBalance: {OldDiscount} -> {NewDiscount}",
order.UserId,
oldBalance,
userWallet.Balance,
oldDiscountBalance,
userWallet.DiscountBalance);
// 7. به‌روزرسانی Transaction
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.Now;
transaction.RefId = request.RefId;
// 8. ثبت لاگ تغییر کیف پول
var changeLog = new UserWalletChangeLog
{
WalletId = userWallet.Id,
CurrentBalance = userWallet.Balance,
ChangeValue = BasePackageAmount,
CurrentNetworkBalance = userWallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = userWallet.DiscountBalance,
ChangeDiscountValue = BasePackageAmount,
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(changeLog, cancellationToken);
// 9. به‌روزرسانی Order
order.TransactionId = transaction.Id;
order.PaymentStatus = PaymentStatus.Success;
order.PaymentDate = DateTime.Now;
order.PaymentMethod = PaymentMethod.IPG;
// 10. به‌روزرسانی User
order.User.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Base package payment completed successfully. OrderId: {OrderId}, UserId: {UserId}, TransactionId: {TransactionId}, RefId: {RefId}",
order.Id,
order.UserId,
transaction.Id,
request.RefId);
return new VerifyBasePackagePaymentResponseDto
{
Success = true,
Message = "پرداخت با موفقیت تایید شد. کیف پول شما شارژ گردید.",
OrderId = order.Id,
TransactionId = transaction.Id,
ReferenceCode = request.RefId,
WalletBalance = userWallet.Balance,
DiscountBalance = userWallet.DiscountBalance
};
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error in VerifyBasePackagePaymentCommand. OrderId: {OrderId}",
request.OrderId);
throw;
}
}
}
@@ -0,0 +1,17 @@
using FluentValidation;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyBasePackagePayment;
public class VerifyBasePackagePaymentCommandValidator : AbstractValidator<VerifyBasePackagePaymentCommand>
{
public VerifyBasePackagePaymentCommandValidator()
{
RuleFor(x => x.OrderId)
.GreaterThan(0)
.WithMessage("شناسه سفارش معتبر نیست.");
RuleFor(x => x.TransactionId)
.GreaterThan(0)
.WithMessage("شناسه تراکنش معتبر نیست.");
}
}
@@ -0,0 +1,22 @@
using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
using CMSMicroservice.Domain.Entities.Geography;
using Mapster;
namespace CMSMicroservice.Application.Profiles;
public class CityProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
config.NewConfig<City, GetAllCitiesByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.ExternalId, src => src.ExternalId)
.Map(dest => dest.Name, src => src.Name)
.Map(dest => dest.Native, src => src.Native)
.Map(dest => dest.Latitude, src => src.Latitude)
.Map(dest => dest.Longitude, src => src.Longitude)
.Map(dest => dest.StateId, src => src.StateId)
.Map(dest => dest.StateName, src => src.State.Name)
.Map(dest => dest.StateNative, src => src.State.Native);
}
}
@@ -4,7 +4,7 @@ public record UpdateUserAddressCommand : IRequest<Unit>
//شناسه
public long Id { get; init; }
//شناسه کاربر
public long UserId { get; init; }
//عنوان
public string Title { get; init; }
//آدرس
@@ -5,8 +5,7 @@ public class UpdateUserAddressCommandValidator : AbstractValidator<UpdateUserAdd
{
RuleFor(model => model.Id)
.NotNull();
RuleFor(model => model.UserId)
.NotNull();
RuleFor(model => model.Title)
.NotEmpty();
RuleFor(model => model.Address)
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// درخواست رفرش توکن JWT
/// </summary>
public sealed record RefreshTokenCommand : IRequest<RefreshTokenResponseDto>
{
/// <summary>
/// توکن فعلی کاربر
/// </summary>
public string CurrentToken { get; init; } = null!;
}
@@ -0,0 +1,82 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// هندلر رفرش توکن - توکن جدید با آخرین اطلاعات کاربر تولید می‌کند
/// </summary>
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, RefreshTokenResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IGenerateJwtToken _generateJwt;
public RefreshTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt)
{
_context = context;
_generateJwt = generateJwt;
}
public async Task<RefreshTokenResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
try
{
// Extract user id from current token
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(request.CurrentToken);
var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
if (userIdClaim == null || !long.TryParse(userIdClaim.Value, out var userId))
{
return new RefreshTokenResponseDto
{
Success = false,
Message = "توکن نامعتبر است",
Token = string.Empty
};
}
// Get user with all required relations
var user = await _context.Users
.Include(u => u.UserContracts)
.ThenInclude(u => u.Contract)
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user == null)
{
return new RefreshTokenResponseDto
{
Success = false,
Message = "کاربر یافت نشد",
Token = string.Empty
};
}
// Generate new token
var newToken = await _generateJwt.GenerateJwtToken(user);
return new RefreshTokenResponseDto
{
Success = true,
Message = "توکن با موفقیت رفرش شد",
Token = newToken
};
}
catch (Exception ex)
{
return new RefreshTokenResponseDto
{
Success = false,
Message = $"خطا در رفرش توکن: {ex.Message}",
Token = string.Empty
};
}
}
}
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// پاسخ رفرش توکن
/// </summary>
public class RefreshTokenResponseDto
{
/// <summary>
/// توکن جدید
/// </summary>
public string Token { get; set; } = null!;
/// <summary>
/// آیا عملیات موفق بود؟
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام
/// </summary>
public string Message { get; set; } = null!;
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Logging;
@@ -6,16 +7,23 @@ namespace CMSMicroservice.Application.UserCQ.EventHandlers;
public class UpdateUserEventHandler : INotificationHandler<UpdateUserEvent>
{
private readonly ILogger<UpdateUserEventHandler> _logger;
private readonly ITokenNotificationService _tokenNotificationService;
public UpdateUserEventHandler(ILogger<UpdateUserEventHandler> logger)
public UpdateUserEventHandler(
ILogger<UpdateUserEventHandler> logger,
ITokenNotificationService tokenNotificationService)
{
_logger = logger;
_tokenNotificationService = tokenNotificationService;
}
public Task Handle(UpdateUserEvent notification, CancellationToken cancellationToken)
public async Task Handle(UpdateUserEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Domain Event: {DomainEvent}", notification.GetType().Name);
_logger.LogInformation("Domain Event: {DomainEvent} for User {UserId}",
notification.GetType().Name,
notification.Item.Id);
return Task.CompletedTask;
// Notify connected clients that user profile has changed and token should be refreshed
await _tokenNotificationService.NotifyForceRefreshAsync(notification.Item.Id);
}
}
@@ -22,7 +22,7 @@ public class AdminGetJwtTokenQueryHandler : IRequestHandler<AdminGetJwtTokenQuer
.ThenInclude(u => u.Contract)
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(f => f.Mobile == request.Username, cancellationToken);
if (user == null)
throw new Exception("Invalid username or password.");
@@ -10,6 +10,8 @@ public record GetAllUserByFilterQuery : IRequest<GetAllUserByFilterResponseDto>
}public class GetAllUserByFilterFilter
{
//جستجوی متنی (نام، نام خانوادگی، موبایل، کدملی)
public string? SearchText { get; set; }
//شناسه
public long? Id { get; set; }
//نام
@@ -17,6 +17,11 @@ public class GetAllUserByFilterQueryHandler : IRequestHandler<GetAllUserByFilter
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.SearchText == null ||
x.FirstName.Contains(request.Filter.SearchText) ||
x.LastName.Contains(request.Filter.SearchText) ||
x.Mobile.Contains(request.Filter.SearchText) ||
x.NationalCode.Contains(request.Filter.SearchText))
.Where(x => request.Filter.Id == null || x.Id == request.Filter.Id)
.Where(x => request.Filter.FirstName == null || x.FirstName.Contains(request.Filter.FirstName))
.Where(x => request.Filter.LastName == null || x.LastName.Contains(request.Filter.LastName))
@@ -17,6 +17,7 @@ public class GetJwtTokenQueryHandler : IRequestHandler<GetJwtTokenQuery, GetJwtT
.ThenInclude(u => u.Contract)
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) ?? throw new NotFoundException(nameof(User), request.Id);
return new GetJwtTokenResponseDto()
{
@@ -10,6 +10,7 @@ public class
{
private readonly IApplicationDbContext _context;
private readonly ILogger<SubmitShopBuyOrderCommandHandler> _logger;
private float _vatRate;
public SubmitShopBuyOrderCommandHandler(
IApplicationDbContext context,
@@ -32,17 +33,67 @@ public class
if (user.UserCarts.Count == 0)
throw new NotFoundException("UserCart", request.UserId);
if (user.UserCarts.Sum(s => s.Count * s.Product.Price) != request.TotalAmount)
// چک موجودی محصولات
var outOfStockProducts = new List<string>();
var insufficientStockProducts = new List<(string Title, int Requested, int Available)>();
foreach (var cartItem in user.UserCarts)
{
if (cartItem.Product.RemainingCount <= 0)
{
outOfStockProducts.Add(cartItem.Product.Title);
}
else if (cartItem.Product.RemainingCount < cartItem.Count)
{
insufficientStockProducts.Add((cartItem.Product.Title, cartItem.Count,
cartItem.Product.RemainingCount));
}
}
if (outOfStockProducts.Any())
{
throw new Exception($"محصولات زیر ناموجود شده‌اند: {string.Join("، ", outOfStockProducts)}");
}
if (insufficientStockProducts.Any())
{
var messages = insufficientStockProducts.Select(p =>
$"«{p.Title}»: درخواست {p.Requested} عدد، موجودی {p.Available} عدد");
throw new Exception($"موجودی محصولات زیر کافی نیست: {string.Join(" | ", messages)}");
}
long finalAmount = 0;
var vatIsEnable =
_context.SystemConfigurations
.FirstOrDefault(f => f.Scope == ConfigurationScope.VAT && f.Key == "IsEnabled")?.Value ??
"0";
if (vatIsEnable=="1")
{
_vatRate = float.Parse(
_context.SystemConfigurations
.FirstOrDefault(f => f.Scope == ConfigurationScope.VAT && f.Key == "Shop.VAT")?.Value ??
throw new InvalidOperationException());
finalAmount = AddVAT(user.UserCarts.Sum(s => s.Count * s.Product.Price));
_logger.LogInformation(
"Calculating final amount with VAT. Base Amount: {BaseAmount}, VAT Rate: {VATRate}, Final Amount: {FinalAmount}",
user.UserCarts.Sum(s => s.Count * s.Product.Price), _vatRate, finalAmount);
}
else
{
finalAmount = user.UserCarts.Sum(s => s.Count * s.Product.Price);
}
if (finalAmount != request.TotalAmount)
throw new Exception("مبلغ سفارش با مجموع سبد خرید مطابقت ندارد.");
var userWallet = user.UserWallets.FirstOrDefault();
if (userWallet == null)
throw new Exception("کیف پول کاربر یافت نشد.");
if (userWallet.Balance<=0 || userWallet.Balance<request.TotalAmount)
if (userWallet.Balance <= 0 || userWallet.Balance < request.TotalAmount)
throw new Exception("موجودی کیف پول کاربر برای انجام این تراکنش کافی نیست.");
var newTransaction = new Transaction()
{
Amount = request.TotalAmount,
@@ -54,18 +105,18 @@ public class
};
await _context.Transactions.AddAsync(newTransaction, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
var newWalletLog = new UserWalletChangeLog()
{
CurrentBalance = userWallet.Balance,
CurrentNetworkBalance = userWallet.NetworkBalance,
WalletId = userWallet.Id,
ChangeValue = -1*request.TotalAmount,
ChangeValue = -1 * request.TotalAmount,
IsIncrease = false,
RefrenceId = newTransaction.Id,
};
userWallet.Balance -= request.TotalAmount;
await _context.UserWalletChangeLogs.AddAsync(newWalletLog, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
@@ -83,7 +134,7 @@ public class
};
await _context.UserOrders.AddAsync(newOrder, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// محاسبه و ثبت VAT (اگر فعال باشد)
var vatCreated = await CalculateAndSaveVAT(newOrder.Id, request.TotalAmount, cancellationToken);
if (vatCreated)
@@ -91,25 +142,43 @@ public class
newOrder.HasVAT = true;
await _context.SaveChangesAsync(cancellationToken);
}
var factorDetailsList = user.UserCarts.Select(s => new FactorDetails()
{
ProductId = s.ProductId,
Count = s.Count,
UnitPrice = s.Product.Price,
UnitPrice =vatIsEnable=="1" ?AddVAT(s.Product.Price): s.Product.Price,
OrderId = newOrder.Id
});
await _context.FactorDetails.AddRangeAsync(factorDetailsList, cancellationToken);
// کاهش موجودی محصولات و افزایش تعداد فروش
foreach (var cartItem in user.UserCarts)
{
cartItem.Product.RemainingCount -= cartItem.Count;
cartItem.Product.SaleCount += cartItem.Count;
}
user.UserCarts.Clear();
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Order {OrderId} completed. Stock updated for {ProductCount} products.",
newOrder.Id, factorDetailsList.Count());
var finalResult = new SubmitShopBuyOrderResponseDto()
{
Id = newOrder.Id,
};
return finalResult;
}
public long CalculateVAT(long amount) => (long)(amount * _vatRate);
/// <summary>
/// محاسبه قیمت با احتساب مالیات
/// </summary>
public long AddVAT(long amount) => amount + CalculateVAT(amount);
private async Task<bool> CalculateAndSaveVAT(long orderId, long orderAmount, CancellationToken cancellationToken)
{
try
@@ -126,7 +195,7 @@ public class
// دریافت نرخ VAT
var vatRateConfig = await _context.SystemConfigurations
.FirstOrDefaultAsync(x => x.Scope == ConfigurationScope.VAT && x.Key == "Rate", cancellationToken);
.FirstOrDefaultAsync(x => x.Scope == ConfigurationScope.VAT && x.Key == "Shop.VAT", cancellationToken);
if (vatRateConfig == null || !decimal.TryParse(vatRateConfig.Value, out var vatRate))
{
@@ -161,7 +230,7 @@ public class
vatAmount,
totalAmount
);
return true;
}
catch (Exception ex)
@@ -171,4 +240,4 @@ public class
return false;
}
}
}
}

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