Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c814cd9118 | |||
| 2d23dbc798 | |||
| 8ec0910cdf | |||
| e882f09955 | |||
| 436e5d7aca | |||
| 6a04a6a270 | |||
| 427d2cce1d | |||
| fb9e3d0109 | |||
| 6c90e4caf0 | |||
| 40da1bd8df | |||
| 053c5a7c4f | |||
| 3810146651 | |||
| 82944e11d0 | |||
| ce98efdd68 | |||
| d232399f43 | |||
| bb20ad2c4e | |||
| 246d893b62 | |||
| 99aab3beff | |||
| 728f28f525 | |||
| 89af07c122 | |||
| 58aeddf004 | |||
| 6c3fbe43b1 | |||
| 7570c39e65 | |||
| 46ac1d817e | |||
| db20d95b43 | |||
| f8794bbb63 | |||
| 19be5dc9dc | |||
| 6395f63ab5 | |||
| 758640cce3 | |||
| 5c1e3cf601 | |||
| 37820ea257 | |||
| 5f8e30905d | |||
| d82b7c28a8 | |||
| 02b2966e96 | |||
| 29fa7ff435 | |||
| 3df738e496 | |||
| dba0b63a70 | |||
| 3a9c3438e6 | |||
| 541b0c2ce9 | |||
| 12ebdc9e87 | |||
| 121291eeed | |||
| 9f1bc2bcdb | |||
| d22eb1617f | |||
| f08d3ac78f | |||
| 354ba1dd1b | |||
| 4f2d313d5e | |||
| 034679d915 | |||
| 3b9958a700 | |||
| 9c562b12c7 | |||
| 6414e5663a | |||
| 3c9957463d | |||
| 90d4f65de4 | |||
| 4df71107a9 | |||
| bc52240a41 | |||
| a7fc959d3e | |||
| aac0d4271d | |||
| d96558aa4b | |||
| da1a2fcf8e | |||
| a2d13c72c3 | |||
| 4e8caa6e2c | |||
| cdd124ac25 | |||
| 4d7932f335 | |||
| 18a2299d93 | |||
| 37a017998c | |||
| 6e60ca7c25 | |||
| fd8b37a6b1 | |||
| 683ed370b5 | |||
| 16acb8f3b5 | |||
| 98c2875ae2 | |||
| 34d2354fd4 | |||
| 65db839642 | |||
| f7d6b0e3e7 | |||
| be2b22c50a | |||
| b905c308c1 | |||
| d09db638cc | |||
| 41e7f29a0d | |||
| c4409491c1 | |||
| e4279f3d05 | |||
| b260fd56fa | |||
| c5dd1a2ef5 | |||
| 1e3ba03c5c | |||
| eeb837f32e | |||
| ee7d29015f | |||
| 624ab7ea9f | |||
| d8a3b49f4d | |||
| b053d86006 | |||
| 15044c66cb | |||
| 8518c7b3ec | |||
| 0983cdf88f | |||
| 789aa0b870 | |||
| b4e7902870 | |||
| aa7f77bfec | |||
| a4d04cb7bf | |||
| 85591aec30 | |||
| e93fad3b3d | |||
| 04e1a83838 | |||
| b8120dd1b9 | |||
| 7ce5539793 | |||
| 4a7df2083e | |||
| 9a4f1e6401 | |||
| e77b5f9658 | |||
| b71ddfaf9b | |||
| 4d6d77531d | |||
| 091a04f3aa | |||
| a35e2eeb92 | |||
| 0a66b09e7b | |||
| e946c56631 | |||
| 370f735cf2 | |||
| 0e9d0cad2f | |||
| 62a0f8594d | |||
| fb81aff095 | |||
| 1a0bb83c99 | |||
| 1e7c17f090 | |||
| 01073084ab | |||
| ed2b20a98e | |||
| 721661af0f | |||
| aee7b59fc1 | |||
| 61b7e4f8f2 | |||
| e5bc3a952a | |||
| 10d2ca20d1 | |||
| fdbb91d2e1 | |||
| a1024a3e18 | |||
| 1ac23667e2 | |||
| dcd11351a3 | |||
| aaaf7fc1ca | |||
| 7554d7054a | |||
| ce8e248843 | |||
| 8446e0e3b5 | |||
| 161f796cd4 | |||
| 469d97bb60 | |||
| d19c569aae | |||
| 7176fe44ee | |||
| 607f791b65 | |||
| 0002a5a6f2 | |||
| ccb938e9ba | |||
| 8e5c7c5205 | |||
| a9cd2fd67a | |||
| ae92ab8697 | |||
| fe3edd178d | |||
| 13dd0f552f | |||
| 8b9c317de6 | |||
| 491b8a0d0f | |||
| cbaa20f339 | |||
| be80136ae8 | |||
| 5e9984f0e5 | |||
| 1d07ae4f71 | |||
| e206b71186 | |||
| 775a9bd75d | |||
| 2620a24597 | |||
| def3b94d89 | |||
| 0457ef6c7c | |||
| f3ac5ad7df | |||
| 3ebe0f9f48 | |||
| e72673c184 | |||
| f4e24f7449 | |||
| 9288d0640b | |||
| 540a2ce4fc | |||
| de83c31346 | |||
| e57f01965a | |||
| f8dc4abd04 | |||
| f97c4b0812 | |||
| 2d6c95e6ee | |||
| fd32e15192 | |||
| e41747afe0 | |||
| 68da3f45a3 | |||
| 4456e504d4 | |||
| 3153fd8a74 | |||
| 8cdb197ee7 | |||
| cec1e74f6c | |||
| dd9ac48474 | |||
| 2ca3fd29f9 | |||
| eb1b249869 | |||
| b9a72b4589 | |||
| 5c63dc6e38 | |||
| 52f6af2091 | |||
| e3fbd45739 | |||
| 0fe4db5e77 | |||
| 305b8d3fd8 | |||
| df976b7b80 | |||
| b90aa50fac | |||
| 58d419ced3 | |||
| e8e964f9a0 | |||
| 9050c00297 | |||
| bdd2c51726 | |||
| 2a569a024f | |||
| 04e8c49fa7 | |||
| f968a6c005 | |||
| 6048824b33 | |||
| 62a247f097 | |||
| bc710fdb7b | |||
| fd24dcebcd | |||
| 89e146bf32 | |||
| af12f996f1 | |||
| 27d995faf4 | |||
| 3b0d9eadd3 | |||
| db07838ecb |
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep production config from current branch during merges — never overwrite
|
||||||
|
src/CMSMicroservice.WebApi/appsettings.Production.json merge=ours
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
name: Push nuget and docker image Actions Workflow
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- stage_new
|
|
||||||
jobs:
|
|
||||||
Deploy:
|
|
||||||
runs-on: windows
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: https://git.afrino.co/actions/checkout@v3
|
|
||||||
- name: Setup dotnet
|
|
||||||
uses: https://git.afrino.co/actions/setup-dotnet@v3
|
|
||||||
with:
|
|
||||||
dotnet-version: 7.0.x
|
|
||||||
|
|
||||||
- name: Remove Package Source
|
|
||||||
run: dotnet nuget remove source FourSat
|
|
||||||
continue-on-error: true
|
|
||||||
- name: Add Package Source
|
|
||||||
run: dotnet nuget add source --name FourSat --username systemuser --password sZSA7PTiv3pUSQZ https://git.afrino.co/api/packages/FourSat/nuget/index.json --store-password-in-clear-text
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: dotnet restore ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj"
|
|
||||||
- name: Build
|
|
||||||
run: dotnet build ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --configuration Release --no-restore
|
|
||||||
- name: Test
|
|
||||||
run: dotnet test ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" --no-restore --verbosity normal
|
|
||||||
- name: Recycle Apppool
|
|
||||||
run: |
|
|
||||||
& "C:\Windows\System32\inetsrv\appcmd.exe" recycle apppool /apppool.name:cms.kbs1.ir
|
|
||||||
shell: powershell
|
|
||||||
- name: Stop Website
|
|
||||||
run: |
|
|
||||||
& "C:\Windows\System32\inetsrv\appcmd.exe" stop site /site.name:cms.kbs1.ir
|
|
||||||
shell: powershell
|
|
||||||
- name: Publish
|
|
||||||
run: dotnet publish ".\src\CMSMicroservice.WebApi\CMSMicroservice.WebApi.csproj" -c Release -o publish
|
|
||||||
- name: Copy Publish To IIS Directory
|
|
||||||
run: Get-ChildItem -Path "publish\*" | Copy-Item -Destination "E:\kbs1.ir\cms.kbs1.ir\" -Recurse -Force
|
|
||||||
- name: Start Website
|
|
||||||
run: |
|
|
||||||
& "C:\Windows\System32\inetsrv\appcmd.exe" start site /site.name:cms.kbs1.ir
|
|
||||||
shell: powershell
|
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- name: Publish Protobuf packages
|
- name: Publish Protobuf packages
|
||||||
run: |
|
run: |
|
||||||
echo "📦 Publishing Protobuf packages..."
|
echo "📦 Publishing Protobuf packages..."
|
||||||
docker run --rm --network host -v $(pwd):/src -w /src \
|
docker run --rm --network host -e CI=true -v $(pwd):/src -w /src \
|
||||||
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
||||||
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
||||||
echo "📦 $proj"
|
echo "📦 $proj"
|
||||||
@@ -67,9 +67,9 @@ jobs:
|
|||||||
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
||||||
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
||||||
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
|
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
|
||||||
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
|
--source "foursat-hosted" \
|
||||||
--api-key "admin:87zH26nbqT" \
|
--api-key "admin:87zH26nbqT" \
|
||||||
--skip-duplicate --allow-insecure-connections || true
|
--skip-duplicate --configfile src/NuGet.config || true
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
'
|
'
|
||||||
@@ -86,8 +86,18 @@ jobs:
|
|||||||
- name: Deploy to Kubernetes
|
- name: Deploy to Kubernetes
|
||||||
run: |
|
run: |
|
||||||
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||||
|
|
||||||
|
# Copy K8s manifests to server
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
|
||||||
|
|
||||||
|
# Apply config (Secret) first, then deployment
|
||||||
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||||
kubectl rollout restart deployment/cms
|
kubectl apply -f /tmp/cms-config.yaml &&
|
||||||
kubectl rollout status deployment/cms --timeout=180s
|
kubectl apply -f /tmp/cms-deployment.yaml &&
|
||||||
|
crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true &&
|
||||||
|
kubectl rollout restart deployment/cms &&
|
||||||
|
kubectl rollout status deployment/cms --timeout=180s &&
|
||||||
|
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
|
||||||
"
|
"
|
||||||
echo "✅ Deployed!"
|
echo "✅ Deployed!"
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
mkdir -p /etc/docker
|
mkdir -p /etc/docker
|
||||||
cat > /etc/docker/daemon.json << 'DAEMON'
|
cat > /etc/docker/daemon.json << 'DAEMON'
|
||||||
{
|
{
|
||||||
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082"]
|
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
|
||||||
}
|
}
|
||||||
DAEMON
|
DAEMON
|
||||||
echo "🚀 Starting Docker daemon..."
|
echo "🚀 Starting Docker daemon..."
|
||||||
dockerd &
|
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
|
||||||
|
|
||||||
for i in $(seq 1 90); do
|
for i in $(seq 1 90); do
|
||||||
if docker info >/dev/null 2>&1; then
|
if docker info >/dev/null 2>&1; then
|
||||||
@@ -48,14 +48,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
|
||||||
|
|
||||||
|
- name: Login to Docker registries
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
||||||
|
|
||||||
- name: Publish Protobuf packages
|
- name: Publish Protobuf packages
|
||||||
run: |
|
run: |
|
||||||
echo "📦 Publishing Protobuf packages..."
|
echo "📦 Publishing Protobuf packages..."
|
||||||
docker run --rm -v $(pwd):/src -w /src \
|
docker run --rm --network host -v $(pwd):/src -w /src \
|
||||||
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
|
||||||
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
|
||||||
echo "📦 $proj"
|
echo "📦 $proj"
|
||||||
dotnet restore "$proj"
|
dotnet restore "$proj" --configfile src/NuGet.config
|
||||||
dotnet build "$proj" -c Release --no-restore
|
dotnet build "$proj" -c Release --no-restore
|
||||||
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
|
||||||
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
|
||||||
@@ -70,17 +75,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
DOCKER_BUILDKIT=0 docker build --network host \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
|
||||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Push to Registry
|
- name: Push to Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
|
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod
|
||||||
|
|
||||||
- name: Deploy to Production
|
- name: Deploy to Production
|
||||||
run: |
|
run: |
|
||||||
sshpass -p "${{ secrets.K8S_SSH_PASSWORD }}" ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} \
|
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
|
||||||
"kubectl rollout restart deployment/cms && kubectl rollout status deployment/cms --timeout=5m" || echo "Deployment pending"
|
|
||||||
|
# Copy K8s manifests to server
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
|
||||||
|
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
|
||||||
|
|
||||||
|
# Apply config (Secret) first, then deployment and update image
|
||||||
|
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
|
||||||
|
kubectl apply -f /tmp/cms-config.yaml &&
|
||||||
|
kubectl apply -f /tmp/cms-deployment.yaml &&
|
||||||
|
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} &&
|
||||||
|
kubectl rollout status deployment/cms --timeout=300s &&
|
||||||
|
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
|
||||||
|
"
|
||||||
|
echo "✅ Deployed to Production!"
|
||||||
|
|||||||
@@ -1,445 +1,4 @@
|
|||||||
# CMS Microservice - Network & Club Commission + Inventory Management System
|
# CMS Microservice
|
||||||
|
|
||||||
[]()
|
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
|
||||||
[]()
|
|
||||||
[]()
|
|
||||||
|
|
||||||
## 📊 Project Status (January 2026)
|
|
||||||
|
|
||||||
### 🏪 Inventory Management System - NEW!
|
|
||||||
**Progress**: Phase 2 Complete (50%)
|
|
||||||
**Architecture**: Clean Architecture + CQRS + Repository Pattern
|
|
||||||
|
|
||||||
#### ✅ Completed Phases
|
|
||||||
1. ✅ **Phase 1: Infrastructure & Domain Layer**
|
|
||||||
- Domain Entities: `InventoryItem`, `StockMovement`, `Warehouse`
|
|
||||||
- Domain Enums: `StockMovementType`
|
|
||||||
- EF Core Configurations with proper indexing
|
|
||||||
- Database migration applied
|
|
||||||
|
|
||||||
2. ✅ **Phase 2: Repository Pattern & CQRS**
|
|
||||||
- Repository Interfaces & Implementations
|
|
||||||
- CQRS Commands (17 commands)
|
|
||||||
- CQRS Queries (35 queries)
|
|
||||||
- MediatR Handlers (52 handlers)
|
|
||||||
|
|
||||||
#### 🔄 In Progress
|
|
||||||
3. 🔄 **Phase 3: Business Services Layer**
|
|
||||||
4. ⏳ **Phase 4: DTOs & AutoMapper**
|
|
||||||
5. ⏳ **Phase 5: API Controllers**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 💼 Commission System - Production Ready
|
|
||||||
**Progress**: 85% Complete
|
|
||||||
**MVP Status**: ✅ 100% Complete
|
|
||||||
|
|
||||||
#### ✅ Completed Features
|
|
||||||
- ✅ Binary network tree with automatic placement
|
|
||||||
- ✅ Club membership (Member/Trial) with commission rates
|
|
||||||
- ✅ Weekly commission calculation (Lesser Leg algorithm)
|
|
||||||
- ✅ Background worker with Hangfire
|
|
||||||
- ✅ Email + SMS notifications (MailKit + Kavenegar)
|
|
||||||
- ✅ Health check endpoints (Kubernetes-ready)
|
|
||||||
|
|
||||||
### 🟡 Partially Complete
|
|
||||||
- Phase 10: Withdrawal & Settlement (40%)
|
|
||||||
- ✅ Commands & Database
|
|
||||||
- ❌ Payment Gateway Integration
|
|
||||||
|
|
||||||
### ❌ Not Started
|
|
||||||
- Phase 9: Club Shop & Product Integration (0%)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Recent Updates (January 2026)
|
|
||||||
|
|
||||||
### 🏪 Inventory Management System - NEW! ✅
|
|
||||||
**Complete CQRS-based inventory management with:**
|
|
||||||
|
|
||||||
#### Domain Layer:
|
|
||||||
- ✅ `InventoryItem` - Multi-warehouse product tracking with min/max thresholds
|
|
||||||
- ✅ `StockMovement` - Complete audit trail with 8 movement types
|
|
||||||
- ✅ `Warehouse` - Multi-location support with default warehouse
|
|
||||||
|
|
||||||
#### Repository Pattern:
|
|
||||||
- ✅ `IInventoryItemRepository` - 25+ methods for inventory operations
|
|
||||||
- ✅ `IStockMovementRepository` - Movement tracking & analytics
|
|
||||||
- ✅ `IWarehouseRepository` - Warehouse management & statistics
|
|
||||||
|
|
||||||
#### CQRS Commands (17 total):
|
|
||||||
- **Inventory:** Create, Update, Delete, Reserve, Release, Reduce, Increase
|
|
||||||
- **Movement:** Create, BulkCreate, Delete
|
|
||||||
- **Warehouse:** Create, Update, Delete, SetDefault, Activate, BulkCreate
|
|
||||||
|
|
||||||
#### CQRS Queries (35 total):
|
|
||||||
- **Inventory:** GetById, Search, LowStock, OutOfStock, CheckAvailability
|
|
||||||
- **Movement:** GetHistory, GetByOrder, Search, Analytics, DailyVolume, TopMoving
|
|
||||||
- **Warehouse:** GetById, Search, GetStats, GetLowStock, GetAllStats
|
|
||||||
|
|
||||||
#### Business Features:
|
|
||||||
- ✅ Multi-warehouse inventory management
|
|
||||||
- ✅ Stock reservation system for orders
|
|
||||||
- ✅ Automatic movement tracking
|
|
||||||
- ✅ Low stock & out-of-stock alerts
|
|
||||||
- ✅ Advanced analytics & reporting
|
|
||||||
- ✅ Bulk operations support
|
|
||||||
- ✅ Transaction-safe operations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Email & SMS Notifications - COMPLETED ✅
|
|
||||||
- ✅ **MailKit 4.14.1** for Email (SMTP with HTML templates)
|
|
||||||
- ✅ **Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
|
|
||||||
- ✅ User.Email field added with migration
|
|
||||||
- ✅ 3 notification types: Commission, Club activation, Errors
|
|
||||||
- ✅ Persian RTL templates with rich formatting
|
|
||||||
- ✅ Production configuration guide created
|
|
||||||
|
|
||||||
### Hangfire Job Scheduling - COMPLETED ✅
|
|
||||||
- ✅ Dashboard UI at `/hangfire`
|
|
||||||
- ✅ Cron schedule: Sunday 00:05 UTC
|
|
||||||
- ✅ SQL Server persistence
|
|
||||||
- ✅ Manual trigger API endpoints
|
|
||||||
- ✅ Distributed execution support
|
|
||||||
|
|
||||||
### Infrastructure Enhancements - COMPLETED ✅
|
|
||||||
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
|
|
||||||
- ✅ AlertService (structured logging for Sentry/Slack)
|
|
||||||
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
|
|
||||||
- ✅ WorkerExecutionLog (database audit trail)
|
|
||||||
- ✅ CurrentUserService (JWT authentication context)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
|
||||||
|
|
||||||
**Clean Architecture** with 4 layers:
|
|
||||||
```
|
|
||||||
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
|
|
||||||
├── Entities/
|
|
||||||
│ ├── InventoryItem.cs # NEW: Inventory tracking
|
|
||||||
│ ├── StockMovement.cs # NEW: Movement audit
|
|
||||||
│ └── Warehouse.cs # NEW: Multi-warehouse
|
|
||||||
├── Enums/
|
|
||||||
│ └── StockMovementType.cs # NEW: Movement types
|
|
||||||
|
|
||||||
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
|
|
||||||
├── Features/
|
|
||||||
│ ├── InventoryItems/ # NEW: Inventory CQRS
|
|
||||||
│ │ ├── Commands/
|
|
||||||
│ │ ├── Queries/
|
|
||||||
│ │ └── Handlers/
|
|
||||||
│ ├── StockMovements/ # NEW: Movement CQRS
|
|
||||||
│ │ ├── Commands/
|
|
||||||
│ │ ├── Queries/
|
|
||||||
│ │ └── Handlers/
|
|
||||||
│ └── Warehouses/ # NEW: Warehouse CQRS
|
|
||||||
│ ├── Commands/
|
|
||||||
│ ├── Queries/
|
|
||||||
│ └── Handlers/
|
|
||||||
└── Common/Interfaces/
|
|
||||||
└── Repositories/ # NEW: Repository interfaces
|
|
||||||
|
|
||||||
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
|
|
||||||
├── Persistence/
|
|
||||||
│ ├── Context/
|
|
||||||
│ ├── Configurations/ # NEW: EF Core configs
|
|
||||||
│ ├── Repositories/ # NEW: Repository implementations
|
|
||||||
│ └── Migrations/
|
|
||||||
└── DependencyInjection.cs # NEW: DI setup
|
|
||||||
|
|
||||||
CMSMicroservice.WebApi/ # gRPC Services, Controllers
|
|
||||||
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Technology Stack**:
|
|
||||||
- .NET 9.0
|
|
||||||
- Entity Framework Core 9.0.11
|
|
||||||
- gRPC + JSON Transcoding
|
|
||||||
- Hangfire 1.8.22 (Job Scheduling)
|
|
||||||
- MediatR 13.0.0 (CQRS)
|
|
||||||
- Polly 8.5.0 (Resilience)
|
|
||||||
- MailKit 4.14.1 (Email)
|
|
||||||
- Kavenegar 1.2.5 (SMS)
|
|
||||||
- SQL Server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **[Development Plan](docs/development-plan.md)** - NEW: Inventory system roadmap
|
|
||||||
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
|
|
||||||
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
|
|
||||||
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
|
|
||||||
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
|
|
||||||
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏪 Inventory System Usage
|
|
||||||
|
|
||||||
### Create Warehouse
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new CreateWarehouseCommand
|
|
||||||
{
|
|
||||||
Name = "Main Warehouse",
|
|
||||||
Code = "WH-001",
|
|
||||||
IsDefault = true,
|
|
||||||
IsActive = true
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Inventory Item
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new CreateInventoryItemCommand
|
|
||||||
{
|
|
||||||
ProductId = 1,
|
|
||||||
WarehouseId = 1,
|
|
||||||
Quantity = 100,
|
|
||||||
MinQuantity = 10,
|
|
||||||
MaxQuantity = 1000
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reserve Stock for Order
|
|
||||||
```csharp
|
|
||||||
await mediator.Send(new ReserveInventoryCommand
|
|
||||||
{
|
|
||||||
Id = inventoryId,
|
|
||||||
Quantity = 5,
|
|
||||||
OrderId = 12345
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Availability
|
|
||||||
```csharp
|
|
||||||
bool available = await mediator.Send(
|
|
||||||
new CheckInventoryAvailabilityQuery(inventoryId, 10));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Low Stock Alerts
|
|
||||||
```csharp
|
|
||||||
var lowStock = await mediator.Send(new GetLowStockItemsQuery
|
|
||||||
{
|
|
||||||
WarehouseId = 1,
|
|
||||||
Count = 50
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Movement Analytics
|
|
||||||
```csharp
|
|
||||||
var summary = await mediator.Send(new GetMovementSummaryQuery
|
|
||||||
{
|
|
||||||
FromDate = DateTime.Now.AddDays(-7),
|
|
||||||
ToDate = DateTime.Now
|
|
||||||
});
|
|
||||||
|
|
||||||
var topProducts = await mediator.Send(new GetTopMovingProductsQuery
|
|
||||||
{
|
|
||||||
FromDate = DateTime.Now.AddDays(-30),
|
|
||||||
ToDate = DateTime.Now,
|
|
||||||
Count = 10
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- .NET 9.0 SDK
|
|
||||||
- SQL Server (local or remote)
|
|
||||||
- (Optional) Gmail account for Email
|
|
||||||
- (Optional) Kavenegar account for SMS
|
|
||||||
|
|
||||||
### 1. Clone & Build
|
|
||||||
```bash
|
|
||||||
cd /home/masoud/Apps/project/FourSat/CMS/src
|
|
||||||
dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure Database
|
|
||||||
Update `appsettings.json` with your SQL Server connection:
|
|
||||||
```json
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Apply Migrations
|
|
||||||
```bash
|
|
||||||
cd CMSMicroservice.WebApi
|
|
||||||
dotnet ef database update
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Configure Notifications (Optional)
|
|
||||||
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
|
|
||||||
|
|
||||||
### 5. Run
|
|
||||||
```bash
|
|
||||||
dotnet run --urls="http://localhost:5133"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Access Endpoints
|
|
||||||
- **Health**: http://localhost:5133/health
|
|
||||||
- **Hangfire Dashboard**: http://localhost:5133/hangfire
|
|
||||||
- **gRPC**: localhost:5133 (HTTP/2)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Email (SMTP)
|
|
||||||
```json
|
|
||||||
"Email": {
|
|
||||||
"Enabled": true,
|
|
||||||
"SmtpHost": "smtp.gmail.com",
|
|
||||||
"SmtpPort": 587,
|
|
||||||
"SmtpUsername": "your-email@gmail.com",
|
|
||||||
"SmtpPassword": "your-gmail-app-password",
|
|
||||||
"FromEmail": "noreply@foursat.com",
|
|
||||||
"FromName": "FourSat CMS",
|
|
||||||
"EnableSsl": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SMS (Kavenegar)
|
|
||||||
```json
|
|
||||||
"Sms": {
|
|
||||||
"Enabled": true,
|
|
||||||
"Provider": "Kavenegar",
|
|
||||||
"KavenegarApiKey": "YOUR_API_KEY",
|
|
||||||
"Sender": "10008663"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Worker
|
|
||||||
```csharp
|
|
||||||
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
|
|
||||||
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
|
|
||||||
"weekly-commission-calculation",
|
|
||||||
job => job.ExecuteAsync(CancellationToken.None),
|
|
||||||
"5 0 * * 0");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Manual Trigger (via API)
|
|
||||||
```bash
|
|
||||||
# Trigger weekly calculation immediately
|
|
||||||
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
|
|
||||||
|
|
||||||
# Trigger recurring job now
|
|
||||||
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
|
|
||||||
|
|
||||||
# Get recurring jobs status
|
|
||||||
curl http://localhost:5133/api/admin/recurring-jobs-status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
```bash
|
|
||||||
curl http://localhost:5133/health # Overall health
|
|
||||||
curl http://localhost:5133/health/ready # Readiness probe (K8s)
|
|
||||||
curl http://localhost:5133/health/live # Liveness probe (K8s)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 What's Remaining?
|
|
||||||
|
|
||||||
### 🏪 Inventory System (Current Focus)
|
|
||||||
1. **Phase 3: Business Services** (In Progress)
|
|
||||||
- `IInventoryManagementService` - High-level operations
|
|
||||||
- `IStockMovementService` - Movement orchestration
|
|
||||||
- `IWarehouseService` - Warehouse business logic
|
|
||||||
- `IInventoryReportingService` - Advanced reporting
|
|
||||||
|
|
||||||
2. **Phase 4: DTOs & AutoMapper** (Next)
|
|
||||||
- Request/Response DTOs
|
|
||||||
- AutoMapper profiles
|
|
||||||
- Validation rules
|
|
||||||
|
|
||||||
3. **Phase 5: API Controllers** (Planned)
|
|
||||||
- `InventoryController` - REST API
|
|
||||||
- `WarehouseController` - Warehouse management
|
|
||||||
- `StockMovementController` - Movement tracking
|
|
||||||
- Swagger documentation
|
|
||||||
|
|
||||||
### 💼 Commission System
|
|
||||||
1. **Payment Gateway Integration** (Phase 10 - 1 week)
|
|
||||||
- Daya or Bank Mellat API integration
|
|
||||||
- IBAN transfer automation
|
|
||||||
- Admin approval UI in BackOffice
|
|
||||||
|
|
||||||
2. **Production Configuration** (30 minutes)
|
|
||||||
- Gmail App Password setup
|
|
||||||
- Kavenegar API key registration
|
|
||||||
- Update `appsettings.Production.json`
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
3. **Club Shop Integration** (Phase 9 - 2 weeks)
|
|
||||||
- Product catalog for club memberships
|
|
||||||
- Shopping cart integration
|
|
||||||
- Auto-activation on purchase
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
4. **Testing** (Phase 7 - Postponed)
|
|
||||||
- Unit tests for business logic
|
|
||||||
- Integration tests for API
|
|
||||||
- Load testing for background worker
|
|
||||||
|
|
||||||
### Optional Enhancements
|
|
||||||
- Redis distributed locks (multi-server deployment)
|
|
||||||
- Sentry error tracking (API key needed)
|
|
||||||
- Slack notifications (webhook needed)
|
|
||||||
- FCM push notifications
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 MVP Features (100% Complete)
|
|
||||||
|
|
||||||
### 💼 Commission System:
|
|
||||||
✅ Binary network tree with automatic placement
|
|
||||||
✅ Club membership (Member/Trial) with different commission rates
|
|
||||||
✅ Weekly commission calculation (Lesser Leg algorithm)
|
|
||||||
✅ Background worker with Hangfire (cron scheduling)
|
|
||||||
✅ Balance carryover logic (rollover unused volumes)
|
|
||||||
✅ MaxWeeklyBalances cap enforcement
|
|
||||||
✅ Health check endpoints (Kubernetes-ready)
|
|
||||||
✅ Manual trigger API (admin control)
|
|
||||||
✅ Email + SMS notifications (MailKit + Kavenegar)
|
|
||||||
✅ Retry logic with exponential backoff (Polly)
|
|
||||||
✅ Audit trail (WorkerExecutionLog, History tables)
|
|
||||||
✅ Structured logging (AlertService for Sentry/Slack)
|
|
||||||
✅ JWT authentication context (CurrentUserService)
|
|
||||||
|
|
||||||
### 🏪 Inventory System (Phase 2 Complete):
|
|
||||||
✅ Domain entities (InventoryItem, StockMovement, Warehouse)
|
|
||||||
✅ Multi-warehouse inventory management
|
|
||||||
✅ Stock reservation system for orders
|
|
||||||
✅ 8 movement types with complete audit trail
|
|
||||||
✅ Repository pattern with 25+ methods per repository
|
|
||||||
✅ CQRS with 17 commands and 35 queries
|
|
||||||
✅ 52 MediatR handlers with business logic
|
|
||||||
✅ Low stock and out-of-stock alerts
|
|
||||||
✅ Advanced analytics (top products, daily volume)
|
|
||||||
✅ Bulk operations support
|
|
||||||
✅ Transaction-safe operations with rollback
|
|
||||||
✅ DI container configuration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👥 Team
|
|
||||||
|
|
||||||
**Development**: FourSat Team
|
|
||||||
**Last Updated**: January 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
Proprietary - FourSat Company
|
|
||||||
# Multi-remote push enabled
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
# CMS Application Configuration Secret (Production)
|
||||||
|
# This Secret stores appsettings.Production.json externally in K8s
|
||||||
|
# so configuration persists independently of Docker image rebuilds.
|
||||||
|
#
|
||||||
|
# To edit config without redeploying:
|
||||||
|
# kubectl edit secret cms-appsettings
|
||||||
|
# kubectl rollout restart deployment/cms
|
||||||
|
#
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: cms-appsettings
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
environment: production
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
appsettings.Production.json: |
|
||||||
|
{
|
||||||
|
"PaymentProvider": "zarinpal",
|
||||||
|
"ZarinPal": {
|
||||||
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
|
"UseSandbox": false
|
||||||
|
},
|
||||||
|
"CmsBaseUrl": "https://cms.kbs2.ir",
|
||||||
|
"FrontOfficeBaseUrl": "https://kbs2.ir",
|
||||||
|
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
|
||||||
|
"JwtIssuer": "https://localhost",
|
||||||
|
"JwtAudience": "https://localhost",
|
||||||
|
"JwtExpiryInDays": 5,
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Server=mssql-svc;Database=KBS;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;",
|
||||||
|
"providerName": "System.Data.SqlClient"
|
||||||
|
},
|
||||||
|
"Otp": {
|
||||||
|
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
|
||||||
|
},
|
||||||
|
"Monitoring": {
|
||||||
|
"SentryEnabled": false,
|
||||||
|
"SentryDsn": "",
|
||||||
|
"SlackEnabled": false,
|
||||||
|
"SlackWebhookUrl": "",
|
||||||
|
"EmailAlertsEnabled": false,
|
||||||
|
"AdminEmails": [
|
||||||
|
"admin@example.com"
|
||||||
|
],
|
||||||
|
"SmsNotificationsEnabled": false,
|
||||||
|
"SmsApiKey": "",
|
||||||
|
"SmsGatewayUrl": ""
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"Enabled": true,
|
||||||
|
"SmtpHost": "smtp.gmail.com",
|
||||||
|
"SmtpPort": 587,
|
||||||
|
"SmtpUsername": "your-email@gmail.com",
|
||||||
|
"SmtpPassword": "your-app-password",
|
||||||
|
"FromEmail": "noreply@foursat.com",
|
||||||
|
"FromName": "FourSat CMS",
|
||||||
|
"EnableSsl": true
|
||||||
|
},
|
||||||
|
"Sms": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Provider": "Kavenegar",
|
||||||
|
"KavenegarApiKey": "43676A4E786A6C50452F2F6252507939346A5145764B33566B456454374E657A614468706658656D6534593D",
|
||||||
|
"Sender": "1000001110100"
|
||||||
|
},
|
||||||
|
"DayaPayment": {
|
||||||
|
"BaseUrl": "https://api.daya.ir",
|
||||||
|
"ApiKey": "YOUR_DAYA_API_KEY"
|
||||||
|
},
|
||||||
|
"DayaApi": {
|
||||||
|
"UseMock": false,
|
||||||
|
"BaseAddress": "https://Dayadiamond.ir",
|
||||||
|
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
|
||||||
|
"CacheDurationMinutes": 20
|
||||||
|
},
|
||||||
|
"Chatika": {
|
||||||
|
"Enabled": true,
|
||||||
|
"BaseUrl": "https://api.chatika.ir",
|
||||||
|
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
|
||||||
|
},
|
||||||
|
"BackgroundJobs": {
|
||||||
|
"WeeklyCommissionCalculation": {
|
||||||
|
"Enabled": false,
|
||||||
|
"CronExpression": "5 0 * * 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SeedWorkers": {
|
||||||
|
"MagicWalletCycleSeed": {
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Kestrel": {
|
||||||
|
"EndpointDefaults": {
|
||||||
|
"Protocols": "Http2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Authority": "https://ids.domain.com/",
|
||||||
|
"Audience": "domain_api"
|
||||||
|
},
|
||||||
|
"Seq": {
|
||||||
|
"ServerUrl": "http://seq-svc:5341",
|
||||||
|
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
# CMS Uploads PersistentVolumeClaim (Production)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: cms-uploads-pvc
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Microservice Deployment (Production)
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cms
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cms
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: cms
|
||||||
|
image: 194.5.195.53:30080/admin/cms:prod
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
value: "Production"
|
||||||
|
- name: ASPNETCORE_URLS
|
||||||
|
value: "http://+:8080"
|
||||||
|
volumeMounts:
|
||||||
|
- name: cms-uploads
|
||||||
|
mountPath: /app/Uploads
|
||||||
|
- name: cms-config
|
||||||
|
mountPath: /app/appsettings.Production.json
|
||||||
|
subPath: appsettings.Production.json
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: cms-uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: cms-uploads-pvc
|
||||||
|
- name: cms-config
|
||||||
|
secret:
|
||||||
|
secretName: cms-appsettings
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Service (Production)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: cms-svc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cms
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Ingress (Production)
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cms-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cms.kbs1.ir
|
||||||
|
- cms.kbs2.ir
|
||||||
|
secretName: cms-tls
|
||||||
|
rules:
|
||||||
|
- host: cms.kbs2.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
- host: cms.kbs1.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
# CMS Application Configuration Secret (Staging)
|
||||||
|
# This Secret stores appsettings.Staging.json externally in K8s
|
||||||
|
# so configuration persists independently of Docker image rebuilds.
|
||||||
|
#
|
||||||
|
# To edit config without redeploying:
|
||||||
|
# kubectl edit secret cms-appsettings
|
||||||
|
# kubectl rollout restart deployment/cms
|
||||||
|
#
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: cms-appsettings
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
environment: staging
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
appsettings.Staging.json: |
|
||||||
|
{
|
||||||
|
"PaymentProvider": "zarinpal",
|
||||||
|
"ZarinPal": {
|
||||||
|
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
|
||||||
|
"UseSandbox": true
|
||||||
|
},
|
||||||
|
"CmsBaseUrl": "https://cms.se.kbs1.ir",
|
||||||
|
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
|
||||||
|
"FMS": {
|
||||||
|
"Address": "https://dl.afrino.co"
|
||||||
|
},
|
||||||
|
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
|
||||||
|
"JwtIssuer": "https://localhost",
|
||||||
|
"JwtAudience": "https://localhost",
|
||||||
|
"JwtExpiryInDays": 5,
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Data Source=194.5.195.53,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
|
||||||
|
"providerName": "System.Data.SqlClient"
|
||||||
|
},
|
||||||
|
"Otp": {
|
||||||
|
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
|
||||||
|
},
|
||||||
|
"Monitoring": {
|
||||||
|
"SentryEnabled": false,
|
||||||
|
"SentryDsn": "",
|
||||||
|
"SlackEnabled": false,
|
||||||
|
"SlackWebhookUrl": "",
|
||||||
|
"EmailAlertsEnabled": false,
|
||||||
|
"AdminEmails": ["admin@example.com"],
|
||||||
|
"SmsNotificationsEnabled": false,
|
||||||
|
"SmsApiKey": "",
|
||||||
|
"SmsGatewayUrl": ""
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"Enabled": true,
|
||||||
|
"SmtpHost": "smtp.gmail.com",
|
||||||
|
"SmtpPort": 587,
|
||||||
|
"SmtpUsername": "your-email@gmail.com",
|
||||||
|
"SmtpPassword": "your-app-password",
|
||||||
|
"FromEmail": "noreply@foursat.com",
|
||||||
|
"FromName": "FourSat CMS",
|
||||||
|
"EnableSsl": true
|
||||||
|
},
|
||||||
|
"Sms": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Provider": "Kavenegar",
|
||||||
|
"KavenegarApiKey": "43676A4E786A6C50452F2F6252507939346A5145764B33566B456454374E657A614468706658656D6534593D",
|
||||||
|
"Sender": "1000001110100"
|
||||||
|
},
|
||||||
|
"DayaPayment": {
|
||||||
|
"BaseUrl": "https://api.daya.ir",
|
||||||
|
"ApiKey": "YOUR_DAYA_API_KEY"
|
||||||
|
},
|
||||||
|
"DayaApi": {
|
||||||
|
"UseMock": false,
|
||||||
|
"BaseAddress": "https://Dayadiamond.ir",
|
||||||
|
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
|
||||||
|
"CacheDurationMinutes": 20
|
||||||
|
},
|
||||||
|
"Chatika": {
|
||||||
|
"Enabled": true,
|
||||||
|
"BaseUrl": "https://api.chatika.ir",
|
||||||
|
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
|
||||||
|
},
|
||||||
|
"BackgroundJobs": {
|
||||||
|
"WeeklyCommissionCalculation": {
|
||||||
|
"Enabled": true,
|
||||||
|
"CronExpression": "5 0 * * 0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SeedWorkers": {
|
||||||
|
"MagicWalletCycleSeed": {
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FileStorage": {
|
||||||
|
"UploadPath": "/app/Uploads"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Kestrel": {
|
||||||
|
"EndpointDefaults": {
|
||||||
|
"Protocols": "Http1AndHttp2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Authority": "https://ids.domain.com/",
|
||||||
|
"Audience": "domain_api"
|
||||||
|
},
|
||||||
|
"Seq": {
|
||||||
|
"ServerUrl": "https://seq.afrino.co",
|
||||||
|
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
# CMS Uploads PersistentVolumeClaim
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: cms-uploads-pvc
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Microservice Deployment
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: cms
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cms
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cms
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: cms
|
||||||
|
image: 194.5.195.53:30080/admin/cms:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
value: "Staging"
|
||||||
|
- name: ASPNETCORE_URLS
|
||||||
|
value: "http://+:8080"
|
||||||
|
volumeMounts:
|
||||||
|
- name: cms-uploads
|
||||||
|
mountPath: /app/Uploads
|
||||||
|
- name: cms-config
|
||||||
|
mountPath: /app/appsettings.Staging.json
|
||||||
|
subPath: appsettings.Staging.json
|
||||||
|
readOnly: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
limits:
|
||||||
|
memory: "1Gi"
|
||||||
|
cpu: "1000m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: cms-uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: cms-uploads-pvc
|
||||||
|
- name: cms-config
|
||||||
|
secret:
|
||||||
|
secretName: cms-appsettings
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Service
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: cms-svc
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cms
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
# CMS Ingress
|
||||||
|
# If clients report TLS PartialChain, the tls secret (cms-tls) tls.crt must include the full chain
|
||||||
|
# (leaf + intermediates). cert-manager ACME usually provides this; manual certs need fullchain.pem in tls.crt.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cms-ingress
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: "nginx"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cms.se.kbs1.ir
|
||||||
|
secretName: cms-tls
|
||||||
|
rules:
|
||||||
|
- host: cms.se.kbs1.ir
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cms-svc
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
+5
-3
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Application.Common;
|
||||||
using CMSMicroservice.Application.Common.FileManager;
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Domain.Entities.Blog;
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
@@ -32,12 +33,13 @@ public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostComman
|
|||||||
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
|
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
|
||||||
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
|
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
|
||||||
|
|
||||||
|
var slug = BlogSlugHelper.ResolveForCreate(request.Slug, request.Title.Trim());
|
||||||
|
slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(_context, slug, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
var post = new BlogPost
|
var post = new BlogPost
|
||||||
{
|
{
|
||||||
Title = request.Title.Trim(),
|
Title = request.Title.Trim(),
|
||||||
Slug = string.IsNullOrWhiteSpace(request.Slug)
|
Slug = slug,
|
||||||
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
|
|
||||||
: request.Slug.Trim().ToLower(),
|
|
||||||
Summary = request.Summary?.Trim(),
|
Summary = request.Summary?.Trim(),
|
||||||
HtmlContent = request.HtmlContent,
|
HtmlContent = request.HtmlContent,
|
||||||
FeaturedImagePath = request.FeaturedImagePath,
|
FeaturedImagePath = request.FeaturedImagePath,
|
||||||
|
|||||||
+2
-2
@@ -11,9 +11,9 @@ public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCo
|
|||||||
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||||
|
|
||||||
RuleFor(x => x.Slug)
|
RuleFor(x => x.Slug)
|
||||||
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
|
||||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
|
||||||
|
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
|
||||||
|
|
||||||
RuleFor(x => x.Summary)
|
RuleFor(x => x.Summary)
|
||||||
.MaximumLength(500).WithMessage("خلاصه نمیتواند بیشتر از 500 کاراکتر باشد")
|
.MaximumLength(500).WithMessage("خلاصه نمیتواند بیشتر از 500 کاراکتر باشد")
|
||||||
|
|||||||
+15
-2
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Application.Common;
|
||||||
using CMSMicroservice.Application.Common.FileManager;
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Domain.Entities.Blog;
|
using CMSMicroservice.Domain.Entities.Blog;
|
||||||
@@ -29,9 +30,21 @@ public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostComman
|
|||||||
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
|
||||||
|
|
||||||
post.Title = request.Title.Trim();
|
post.Title = request.Title.Trim();
|
||||||
// حفظ slug قبلی اگر مقدار جدید خالی باشد
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Slug))
|
if (!string.IsNullOrWhiteSpace(request.Slug))
|
||||||
post.Slug = request.Slug.Trim().ToLower();
|
{
|
||||||
|
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
|
||||||
|
_context,
|
||||||
|
BlogSlugHelper.NormalizeOptional(request.Slug)!,
|
||||||
|
post.Id,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(post.Slug))
|
||||||
|
{
|
||||||
|
var slug = BlogSlugHelper.ResolveForCreate(null, post.Title);
|
||||||
|
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
|
||||||
|
_context, slug, post.Id, cancellationToken);
|
||||||
|
}
|
||||||
post.Summary = request.Summary?.Trim();
|
post.Summary = request.Summary?.Trim();
|
||||||
post.HtmlContent = request.HtmlContent;
|
post.HtmlContent = request.HtmlContent;
|
||||||
post.FeaturedImagePath = request.FeaturedImagePath;
|
post.FeaturedImagePath = request.FeaturedImagePath;
|
||||||
|
|||||||
+2
-2
@@ -13,9 +13,9 @@ public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCo
|
|||||||
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
.MaximumLength(200).WithMessage("عنوان نمیتواند بیشتر از 200 کاراکتر باشد");
|
||||||
|
|
||||||
RuleFor(x => x.Slug)
|
RuleFor(x => x.Slug)
|
||||||
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
|
|
||||||
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
.MaximumLength(200).WithMessage("نشانی نمیتواند بیشتر از 200 کاراکتر باشد")
|
||||||
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
|
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط میتواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
|
||||||
|
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
|
||||||
|
|
||||||
RuleFor(x => x.HtmlContent)
|
RuleFor(x => x.HtmlContent)
|
||||||
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
|
||||||
|
|||||||
+180
-37
@@ -135,13 +135,75 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
};
|
};
|
||||||
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
await _context.UserContracts.AddAsync(userContract, cancellationToken);
|
||||||
|
|
||||||
// 6. دریافت مقادیر از SystemConstants (استاتیک)
|
// 6. بارگذاری پکیج موثر برای امضای قرارداد
|
||||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
// اولویت:
|
||||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
// 1) پکیج Cycle جاری (مسیر صحیح برای پرداخت دستی)
|
||||||
|
// 2) آخرین خرید پکیج
|
||||||
|
// 3) پکیج پایه
|
||||||
|
var currentCyclePackageId = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.UserId == userId && c.IsCurrentCycle)
|
||||||
|
.OrderByDescending(c => c.CycleNumber)
|
||||||
|
.Select(c => (long?)c.PackageId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var latestPurchase = await _context.UserPackagePurchases
|
||||||
|
.Where(p => p.UserId == userId)
|
||||||
|
.OrderByDescending(p => p.PurchasedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
Package? package = null;
|
||||||
|
|
||||||
|
if (currentCyclePackageId.HasValue && currentCyclePackageId.Value > 0)
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == currentCyclePackageId.Value && !p.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (package != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AcceptContract package source: CurrentCycle (UserId={UserId}, PackageId={PackageId})",
|
||||||
|
userId,
|
||||||
|
package.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package == null && latestPurchase != null)
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (package != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AcceptContract package source: LatestPurchase (UserId={UserId}, PackageId={PackageId})",
|
||||||
|
userId,
|
||||||
|
package.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package == null)
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (package != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AcceptContract package source: BasePackage (UserId={UserId}, PackageId={PackageId})",
|
||||||
|
userId,
|
||||||
|
package.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (package == null)
|
||||||
|
throw new NotFoundException("پکیج یافت نشد");
|
||||||
|
|
||||||
|
long giftValue = package.ActivationFee;
|
||||||
|
long activationFeeValue = package.ActivationFee;
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
|
||||||
giftValue, activationFeeValue
|
package.Id, package.Title, giftValue, activationFeeValue
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. فعالسازی باشگاه مشتریان
|
// 7. فعالسازی باشگاه مشتریان
|
||||||
@@ -156,6 +218,10 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
ActivatedAt = activationDate,
|
ActivatedAt = activationDate,
|
||||||
|
FirstActivationDate = activationDate,
|
||||||
|
FirstPackageId = package.Id,
|
||||||
|
LastActivationDate = activationDate,
|
||||||
|
LastPackageId = package.Id,
|
||||||
InitialContribution = activationFeeValue,
|
InitialContribution = activationFeeValue,
|
||||||
GiftValue = giftValue,
|
GiftValue = giftValue,
|
||||||
TotalEarned = 0,
|
TotalEarned = 0,
|
||||||
@@ -175,6 +241,8 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
clubMembership = user.ClubMembership!;
|
clubMembership = user.ClubMembership!;
|
||||||
clubMembership.IsActive = true;
|
clubMembership.IsActive = true;
|
||||||
clubMembership.ActivatedAt = activationDate;
|
clubMembership.ActivatedAt = activationDate;
|
||||||
|
clubMembership.LastActivationDate = activationDate;
|
||||||
|
clubMembership.LastPackageId = package.Id;
|
||||||
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
|
||||||
_context.ClubMemberships.Update(clubMembership);
|
_context.ClubMemberships.Update(clubMembership);
|
||||||
|
|
||||||
@@ -203,16 +271,64 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
_context.ClubMembershipHistories.Add(history);
|
_context.ClubMembershipHistories.Add(history);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 9. اضافه کردن مبلغ به Pool هفته جاری
|
// 8.5. آپدیت PackagePurchasedAt سایکل جاری به زمان امضای قرارداد
|
||||||
|
// دلیل: Pool در همین لحظه شارژ میشود — Validation باید به همین هفته اشاره کند
|
||||||
|
// سناریوی cross-week: کاربر هفته ۱۸ پکیج خریده، هفته ۱۹ قرارداد امضا کرده
|
||||||
|
// بدون این Fix: Cycle.PackagePurchasedAt = هفته ۱۸ ولی Pool هفته ۱۹ → اختلاف
|
||||||
|
// با این Fix: Cycle.PackagePurchasedAt = هفته ۱۹ = Pool هفته ۱۹ → درست
|
||||||
|
var currentCycle = await _context.ClubMembershipCycles
|
||||||
|
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle, cancellationToken);
|
||||||
|
|
||||||
|
if (currentCycle != null)
|
||||||
|
{
|
||||||
|
currentCycle.PackagePurchasedAt = activationDate;
|
||||||
|
_context.ClubMembershipCycles.Update(currentCycle);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Updated Cycle #{CycleNumber} PackagePurchasedAt to contract-signing time for UserId {UserId}",
|
||||||
|
currentCycle.CycleNumber,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// در صورتی که Cycle وجود ندارد (legacy یا مسیر مستقیم AcceptContract)
|
||||||
|
// Cycle را همینجا بساز تا Validation درست کار کند
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No IsCurrentCycle found for UserId {UserId} at AcceptContract — creating Cycle now",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
var maxCycleNumber = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.ClubMembershipId == clubMembership.Id)
|
||||||
|
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
|
||||||
|
|
||||||
|
var newCycle = new ClubMembershipCycle
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClubMembershipId = clubMembership.Id,
|
||||||
|
CycleNumber = maxCycleNumber + 1,
|
||||||
|
PackagePurchasedAt = activationDate,
|
||||||
|
PurchaseMethod = user.PackagePurchaseMethod,
|
||||||
|
PackageAmount = package.Price,
|
||||||
|
PackageId = package.Id,
|
||||||
|
IsCurrentCycle = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.ClubMembershipCycles.Add(newCycle);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||||
var weeklyPool = await _context.WeeklyCommissionPools
|
var weeklyPool = await _context.WeeklyCommissionPools
|
||||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||||
|
|
||||||
if (weeklyPool == null)
|
if (weeklyPool == null)
|
||||||
{
|
{
|
||||||
weeklyPool = new WeeklyCommissionPool
|
weeklyPool = new WeeklyCommissionPool
|
||||||
{
|
{
|
||||||
WeekDefinitionId = currentWeekDefinitionId,
|
WeekDefinitionId = currentWeekDefinitionId,
|
||||||
|
PackageId = package.Id,
|
||||||
TotalPoolAmount = activationFeeValue,
|
TotalPoolAmount = activationFeeValue,
|
||||||
TotalBalances = 0,
|
TotalBalances = 0,
|
||||||
ValuePerBalance = 0,
|
ValuePerBalance = 0,
|
||||||
@@ -243,36 +359,8 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 10. اعطای ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
// 10. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||||
if (isNewMembership)
|
await ApplyFeatureDiffAsync(user.Id, clubMembership.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||||
{
|
|
||||||
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
|
|
||||||
var clubFeatures = await _context.ClubFeatures
|
|
||||||
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (clubFeatures.Any())
|
|
||||||
{
|
|
||||||
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
|
|
||||||
{
|
|
||||||
UserId = user.Id,
|
|
||||||
ClubMembershipId = clubMembership.Id,
|
|
||||||
ClubFeatureId = feature.Id,
|
|
||||||
GrantedAt = activationDate,
|
|
||||||
IsActive = true,
|
|
||||||
Notes = "اعطا شده هنگام امضای قرارداد"
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
_context.UserClubFeatures.AddRange(userClubFeatures);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Granted {Count} club features to UserId {UserId}",
|
|
||||||
clubFeatures.Count,
|
|
||||||
user.Id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
|
||||||
@@ -302,6 +390,61 @@ public class AcceptClubMembershipContractCommandHandler
|
|||||||
return week.Id;
|
return week.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyFeatureDiffAsync(
|
||||||
|
long userId, long membershipId, long packageId,
|
||||||
|
bool isNewMembership, DateTime activationDate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var newFeatureIds = await _context.PackageFeatures
|
||||||
|
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
|
||||||
|
.Select(pf => pf.ClubFeatureId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!newFeatureIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No PackageFeatures for PackageId {PackageId}", packageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentFeatures = await _context.UserClubFeatures
|
||||||
|
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
|
||||||
|
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
|
||||||
|
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
|
||||||
|
|
||||||
|
foreach (var feature in toRemove)
|
||||||
|
{
|
||||||
|
feature.IsActive = false;
|
||||||
|
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAdd.Any())
|
||||||
|
{
|
||||||
|
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClubMembershipId = membershipId,
|
||||||
|
ClubFeatureId = featureId,
|
||||||
|
GrantedAt = activationDate,
|
||||||
|
IsActive = true,
|
||||||
|
Notes = isNewMembership
|
||||||
|
? "اعطا شده هنگام امضای قرارداد"
|
||||||
|
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_context.UserClubFeatures.AddRange(newUserFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
|
||||||
|
userId, toAdd.Count, toRemove.Count,
|
||||||
|
currentFeatureIds.Count - toRemove.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(bool Success, string Message)> VerifyOtpAsync(
|
private async Task<(bool Success, string Message)> VerifyOtpAsync(
|
||||||
string mobile,
|
string mobile,
|
||||||
string code,
|
string code,
|
||||||
|
|||||||
+11
@@ -12,4 +12,15 @@ public record ActivateClubMembershipCommand : IRequest<bool>
|
|||||||
/// شناسه کاربر
|
/// شناسه کاربر
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فعالسازی اجباری توسط ادمین — بدون بررسی سفارش و کیفپول
|
||||||
|
/// </summary>
|
||||||
|
public bool ForceActivation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — فقط در ForceActivation استفاده میشود.
|
||||||
|
/// اگر 0 باشد، پکیج پایه (IsBasePackage) استفاده میشود.
|
||||||
|
/// </summary>
|
||||||
|
public long PackageId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+232
-78
@@ -1,4 +1,5 @@
|
|||||||
using CMSMicroservice.Application.Common.Exceptions;
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
using CMSMicroservice.Application.Common.Helpers;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Application.Common.Models;
|
using CMSMicroservice.Application.Common.Models;
|
||||||
using CMSMicroservice.Domain.Common;
|
using CMSMicroservice.Domain.Common;
|
||||||
@@ -54,15 +55,21 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
throw new NotFoundException(nameof(User), request.UserId);
|
throw new NotFoundException(nameof(User), request.UserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// متغیر پکیج — در هر دو مسیر (عادی و Force) مقداردهی میشود
|
||||||
|
Package package;
|
||||||
|
|
||||||
|
// 2-5: بررسیهای مالی — در حالت ForceActivation (ادمین) رد میشود
|
||||||
|
if (!request.ForceActivation)
|
||||||
|
{
|
||||||
// 2. بررسی اینکه پکیج خریده باشد
|
// 2. بررسی اینکه پکیج خریده باشد
|
||||||
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"User {UserId} has not purchased golden package yet",
|
"User {UserId} has not purchased any package yet",
|
||||||
request.UserId
|
request.UserId
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
|
"برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,55 +83,59 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
throw new NotFoundException("کیف پول کاربر یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wallet.Balance < SystemConstants.BasePackageAmount)
|
// NOTE: balance check uses package.Price — loaded after finding order (step 4)
|
||||||
|
// Moved to after package loading
|
||||||
|
|
||||||
|
// 3.5. بررسی وضعیت کیفپول جادویی — اگر در حالت Magic است، فعالسازی مجاز نیست
|
||||||
|
if (wallet.WalletMode == WalletMode.Magic)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"User {UserId} has insufficient balance: {Balance}",
|
"User {UserId} cannot activate club while in Magic wallet mode",
|
||||||
request.UserId,
|
request.UserId
|
||||||
wallet.Balance
|
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
|
"ابتدا باید چرخه کیفپول جادویی تکمیل شود"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. پیدا کردن UserOrder با PackageId
|
// 4. پیدا کردن آخرین خرید موفق پکیج
|
||||||
var packageOrder = await _context.UserOrders
|
var packagePurchase = await _context.UserPackagePurchases
|
||||||
.Include(o => o.Transaction)
|
.Include(p => p.Transaction)
|
||||||
.Where(o =>
|
.Where(p =>
|
||||||
o.UserId == user.Id &&
|
p.UserId == user.Id &&
|
||||||
o.PackageId != null &&
|
p.Transaction != null &&
|
||||||
o.PaymentStatus == PaymentStatus.Success)
|
p.Transaction.PaymentStatus == PaymentStatus.Success)
|
||||||
.OrderByDescending(o => o.Created)
|
.OrderByDescending(p => p.Created)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (packageOrder == null)
|
if (packagePurchase == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"No successful package order found for UserId: {UserId}",
|
"No successful package purchase found for UserId: {UserId}",
|
||||||
request.UserId
|
request.UserId
|
||||||
);
|
);
|
||||||
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
|
throw new NotFoundException("سفارش پکیج یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. بررسی Transaction
|
// 5. بررسی Transaction
|
||||||
if (packageOrder.Transaction == null)
|
if (packagePurchase.Transaction == null)
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
"Transaction not found for OrderId: {OrderId}",
|
"Transaction not found for PurchaseId: {PurchaseId}",
|
||||||
packageOrder.Id
|
packagePurchase.Id
|
||||||
);
|
);
|
||||||
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction = packageOrder.Transaction;
|
var transaction = packagePurchase.Transaction;
|
||||||
|
|
||||||
if (transaction.Type != TransactionType.DepositIpg &&
|
if (transaction.Type != TransactionType.Buy &&
|
||||||
|
transaction.Type != TransactionType.DepositIpg &&
|
||||||
transaction.Type != TransactionType.DepositExternal1)
|
transaction.Type != TransactionType.DepositExternal1)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Invalid transaction type for OrderId {OrderId}: {Type}",
|
"Invalid transaction type for PurchaseId {PurchaseId}: {Type}",
|
||||||
packageOrder.Id,
|
packagePurchase.Id,
|
||||||
transaction.Type
|
transaction.Type
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -132,17 +143,60 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.5. بارگذاری پکیج از خرید
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == packagePurchase.PackageId && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException("پکیج یافت نشد");
|
||||||
|
|
||||||
|
// بررسی موجودی با مبلغ پکیج واقعی
|
||||||
|
if (wallet.Balance < package.Price)
|
||||||
|
{
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"برای فعالسازی باشگاه مشتریان باید حداقل {package.Price:N0} تومان موجودی اصلی داشته باشید"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Force activation by admin for UserId: {UserId} — skipping order/wallet validation",
|
||||||
|
request.UserId
|
||||||
|
);
|
||||||
|
|
||||||
|
// در فعالسازی اجباری ادمین، اگر روش خرید تنظیم نشده، دستی در نظر بگیر
|
||||||
|
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
|
||||||
|
{
|
||||||
|
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
|
||||||
|
_context.Users.Update(user);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// بارگذاری پکیج — اگر PackageId مشخص شده همان را لود کن، وگرنه پکیج پایه
|
||||||
|
if (request.PackageId > 0)
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"پکیج {request.PackageId} یافت نشد");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException("پکیج پایه یافت نشد");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. بررسی عضویت فعلی
|
// 6. بررسی عضویت فعلی
|
||||||
var existingMembership = await _context.ClubMemberships
|
var existingMembership = await _context.ClubMemberships
|
||||||
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
|
||||||
|
|
||||||
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
|
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از پکیج
|
||||||
long giftValue = SystemConstants.ClubMembershipGiftValue;
|
long giftValue = package.ActivationFee;
|
||||||
long activationFeeValue = SystemConstants.ClubActivationFee;
|
long activationFeeValue = package.ActivationFee;
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
|
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}, Price={Price}",
|
||||||
giftValue, activationFeeValue
|
package.Id, package.Title, giftValue, activationFeeValue, package.Price
|
||||||
);
|
);
|
||||||
|
|
||||||
ClubMembership entity;
|
ClubMembership entity;
|
||||||
@@ -151,14 +205,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
|
|
||||||
if (isNewMembership)
|
if (isNewMembership)
|
||||||
{
|
{
|
||||||
// ایجاد عضویت جدید
|
// ایجاد عضویت جدید — IsActive = false تا زمان امضای قرارداد باشگاه
|
||||||
entity = new ClubMembership
|
entity = new ClubMembership
|
||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
IsActive = true,
|
IsActive = false,
|
||||||
ActivatedAt = activationDate,
|
ActivatedAt = activationDate,
|
||||||
InitialContribution =activationFeeValue,
|
FirstActivationDate = activationDate,
|
||||||
GiftValue = giftValue, // مقدار از تنظیمات
|
FirstPackageId = package.Id,
|
||||||
|
LastActivationDate = activationDate,
|
||||||
|
LastPackageId = package.Id,
|
||||||
|
InitialContribution = activationFeeValue,
|
||||||
|
GiftValue = giftValue,
|
||||||
TotalEarned = 0,
|
TotalEarned = 0,
|
||||||
PurchaseMethod = user.PackagePurchaseMethod
|
PurchaseMethod = user.PackagePurchaseMethod
|
||||||
};
|
};
|
||||||
@@ -174,31 +232,79 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (existingMembership.IsActive)
|
// بررسی آیا چرخه فعال دارد — اگر بله، واقعاً فعال است و نیازی به کار نیست
|
||||||
|
var hasCurrentCycle = await _context.ClubMembershipCycles
|
||||||
|
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
|
||||||
|
|
||||||
|
// اگر چرخه فعال دارد و درخواست Force نیست — احتمال اجرای تکراری همان خرید
|
||||||
|
// در حالت ForceActivation (مثل پرداخت دستی)، باید چرخه جدید ساخته شود تا پکیج جدید ثبت شود.
|
||||||
|
if (hasCurrentCycle && !request.ForceActivation)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"User {UserId} is already an active club member",
|
"User {UserId} already has a running cycle — skipping duplicate activation",
|
||||||
user.Id
|
user.Id
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// فعالسازی مجدد
|
// فعالسازی مجدد یا خرید مجدد (Q19/Q20) — FirstActivation حفظ میشه (Q21), Last بروزرسانی میشه
|
||||||
|
// مقدار IsActive قبلی حفظ میشه — اگر قبلاً فعال بوده، فعال بمونه
|
||||||
entity = existingMembership;
|
entity = existingMembership;
|
||||||
entity.IsActive = true;
|
entity.LastActivationDate = activationDate;
|
||||||
entity.ActivatedAt = activationDate;
|
entity.LastPackageId = package.Id;
|
||||||
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
entity.PurchaseMethod = user.PackagePurchaseMethod;
|
||||||
|
|
||||||
_context.ClubMemberships.Update(entity);
|
_context.ClubMemberships.Update(entity);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Reactivated club membership for UserId {UserId}",
|
"Re-activating club membership for UserId {UserId} (re-purchase scenario, IsActive was {WasActive})",
|
||||||
user.Id
|
user.Id,
|
||||||
|
existingMembership.IsActive
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 6.5. ایجاد ClubMembershipCycle — هر خرید پکیج یک دور جدید
|
||||||
|
var previousCycles = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.UserId == user.Id && c.IsCurrentCycle)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var prevCycle in previousCycles)
|
||||||
|
{
|
||||||
|
prevCycle.IsCurrentCycle = false;
|
||||||
|
ClubMembershipCycleHistoryWriter.WriteCycleClosed(_context, prevCycle, reason: "شروع دوره جدید");
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxCycleNumber = await _context.ClubMembershipCycles
|
||||||
|
.Where(c => c.ClubMembershipId == entity.Id)
|
||||||
|
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
|
||||||
|
|
||||||
|
var newCycle = new ClubMembershipCycle
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
ClubMembershipId = entity.Id,
|
||||||
|
CycleNumber = maxCycleNumber + 1,
|
||||||
|
PackagePurchasedAt = activationDate,
|
||||||
|
PurchaseMethod = user.PackagePurchaseMethod,
|
||||||
|
PackageAmount = package.Price,
|
||||||
|
PackageId = package.Id,
|
||||||
|
IsCurrentCycle = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.ClubMembershipCycles.Add(newCycle);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
ClubMembershipCycleHistoryWriter.WriteCycleStarted(_context, newCycle, reason: "فعالسازی عضویت باشگاه");
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
|
||||||
|
newCycle.CycleNumber,
|
||||||
|
user.Id,
|
||||||
|
entity.Id
|
||||||
|
);
|
||||||
|
|
||||||
// 7. ثبت تاریخچه
|
// 7. ثبت تاریخچه
|
||||||
var history = new ClubMembershipHistory
|
var history = new ClubMembershipHistory
|
||||||
{
|
{
|
||||||
@@ -217,19 +323,25 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
|
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
|
||||||
|
// عضو جدید (isNewMembership=true): Pool توسط AcceptClubMembershipContract شارژ میشود
|
||||||
|
// چون کاربر قبل از امضای قرارداد IsActive=false است و قرارداد فقط یکبار امضا میشود.
|
||||||
|
// خرید مجدد (isNewMembership=false): قرارداد مجدد امضا نمیشود — Pool همینجا شارژ میشود.
|
||||||
|
if (!isNewMembership)
|
||||||
|
{
|
||||||
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
|
||||||
var weeklyPool = await _context.WeeklyCommissionPools
|
var weeklyPool = await _context.WeeklyCommissionPools
|
||||||
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
|
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
|
||||||
|
|
||||||
if (weeklyPool == null)
|
if (weeklyPool == null)
|
||||||
{
|
{
|
||||||
// ایجاد Pool جدید برای این هفته
|
// ایجاد Pool جدید برای این هفته × این پکیج
|
||||||
weeklyPool = new WeeklyCommissionPool
|
weeklyPool = new WeeklyCommissionPool
|
||||||
{
|
{
|
||||||
WeekDefinitionId = currentWeekDefinitionId,
|
WeekDefinitionId = currentWeekDefinitionId,
|
||||||
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
|
PackageId = package.Id,
|
||||||
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
|
TotalPoolAmount = activationFeeValue,
|
||||||
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
|
TotalBalances = 0,
|
||||||
|
ValuePerBalance = 0,
|
||||||
IsCalculated = false,
|
IsCalculated = false,
|
||||||
CalculatedAt = null
|
CalculatedAt = null
|
||||||
};
|
};
|
||||||
@@ -239,12 +351,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
|
||||||
currentWeekDefinitionId,
|
currentWeekDefinitionId,
|
||||||
giftValue
|
activationFeeValue
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// اضافه کردن به Pool موجود
|
|
||||||
weeklyPool.TotalPoolAmount += activationFeeValue;
|
weeklyPool.TotalPoolAmount += activationFeeValue;
|
||||||
_context.WeeklyCommissionPools.Update(weeklyPool);
|
_context.WeeklyCommissionPools.Update(weeklyPool);
|
||||||
|
|
||||||
@@ -257,42 +368,17 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 9. اضافه کردن ویژگیهای باشگاه برای کاربر (فقط برای عضویت جدید)
|
|
||||||
if (isNewMembership)
|
|
||||||
{
|
|
||||||
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
|
|
||||||
var clubFeatures = await _context.ClubFeatures
|
|
||||||
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (clubFeatures.Any())
|
|
||||||
{
|
|
||||||
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
|
|
||||||
{
|
|
||||||
UserId = user.Id,
|
|
||||||
ClubMembershipId = entity.Id,
|
|
||||||
ClubFeatureId = feature.Id,
|
|
||||||
GrantedAt = activationDate,
|
|
||||||
IsActive = true,
|
|
||||||
Notes = "اعطا شده بهطور خودکار هنگام فعالسازی"
|
|
||||||
}).ToList(); _context.UserClubFeatures.AddRange(userClubFeatures);
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Granted {Count} club features to UserId {UserId}",
|
|
||||||
clubFeatures.Count,
|
|
||||||
user.Id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogInformation(
|
||||||
"No club features found to grant to UserId {UserId}",
|
"New member — Pool charge deferred to AcceptClubMembershipContract for UserId {UserId}",
|
||||||
user.Id
|
user.Id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// 9. اعمال ویژگیهای پکیج — DIFF/تفاضل (Q20)
|
||||||
|
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
|
||||||
@@ -313,6 +399,74 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// اعمال DIFF فیچرها بر اساس پکیج (Q20)
|
||||||
|
/// عضویت جدید: همه فیچرهای پکیج اضافه میشه
|
||||||
|
/// خرید مجدد: تفاضل فیچرهای فعلی و پکیج جدید
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyFeatureDiffAsync(
|
||||||
|
long userId, long membershipId, long packageId,
|
||||||
|
bool isNewMembership, DateTime activationDate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// فیچرهای پکیج جدید
|
||||||
|
var newFeatureIds = await _context.PackageFeatures
|
||||||
|
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
|
||||||
|
.Select(pf => pf.ClubFeatureId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!newFeatureIds.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"No PackageFeatures found for PackageId {PackageId} — skipping feature assignment for UserId {UserId}",
|
||||||
|
packageId, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// فیچرهای فعال فعلی کاربر
|
||||||
|
var currentFeatures = await _context.UserClubFeatures
|
||||||
|
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
|
||||||
|
|
||||||
|
// DIFF
|
||||||
|
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
|
||||||
|
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
|
||||||
|
|
||||||
|
// حذف فیچرهای قدیمی که پکیج جدید ندارد
|
||||||
|
foreach (var feature in toRemove)
|
||||||
|
{
|
||||||
|
feature.IsActive = false;
|
||||||
|
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
|
||||||
|
}
|
||||||
|
|
||||||
|
// اضافه فیچرهای جدید
|
||||||
|
if (toAdd.Any())
|
||||||
|
{
|
||||||
|
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClubMembershipId = membershipId,
|
||||||
|
ClubFeatureId = featureId,
|
||||||
|
GrantedAt = activationDate,
|
||||||
|
IsActive = true,
|
||||||
|
Notes = isNewMembership
|
||||||
|
? "اعطا شده هنگام فعالسازی اولیه"
|
||||||
|
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_context.UserClubFeatures.AddRange(newUserFeatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
|
||||||
|
userId, toAdd.Count, toRemove.Count,
|
||||||
|
currentFeatureIds.Count - toRemove.Count);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// دریافت شناسه تعریف هفته جاری
|
/// دریافت شناسه تعریف هفته جاری
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+4
-1
@@ -13,6 +13,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
|
|||||||
{
|
{
|
||||||
var query = _context.ClubMemberships
|
var query = _context.ClubMemberships
|
||||||
.Include(x => x.User)
|
.Include(x => x.User)
|
||||||
|
.Include(x => x.LastPackage)
|
||||||
.ApplyOrder(sortBy: request.SortBy)
|
.ApplyOrder(sortBy: request.SortBy)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
@@ -35,8 +36,10 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
|
|||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
UserId = x.UserId,
|
UserId = x.UserId,
|
||||||
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
|
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
|
||||||
|
PackageId = x.LastPackageId ?? 0,
|
||||||
|
PackageName = x.LastPackage != null ? x.LastPackage.Title : string.Empty,
|
||||||
IsActive = x.IsActive,
|
IsActive = x.IsActive,
|
||||||
ActivatedAt = x.ActivatedAt,
|
ActivatedAt = x.LastActivationDate ?? x.ActivatedAt,
|
||||||
InitialContribution = x.InitialContribution,
|
InitialContribution = x.InitialContribution,
|
||||||
TotalEarned = x.TotalEarned,
|
TotalEarned = x.TotalEarned,
|
||||||
Created = x.Created,
|
Created = x.Created,
|
||||||
|
|||||||
+2
@@ -11,6 +11,8 @@ public class GetAllClubMembershipsResponseModel
|
|||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public long UserId { get; set; }
|
public long UserId { get; set; }
|
||||||
public string UserName { get; set; } = string.Empty;
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageName { get; set; } = string.Empty;
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public DateTime? ActivatedAt { get; set; }
|
public DateTime? ActivatedAt { get; set; }
|
||||||
public long InitialContribution { get; set; }
|
public long InitialContribution { get; set; }
|
||||||
|
|||||||
+10
-20
@@ -1,27 +1,17 @@
|
|||||||
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
|
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query برای دریافت تاریخچه تغییرات عضویت باشگاه
|
|
||||||
/// </summary>
|
|
||||||
public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto>
|
public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// شناسه عضویت (اختیاری)
|
|
||||||
/// </summary>
|
|
||||||
public long? MembershipId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// شناسه کاربر (اختیاری)
|
|
||||||
/// </summary>
|
|
||||||
public long? UserId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// موقعیت صفحهبندی
|
|
||||||
/// </summary>
|
|
||||||
public PaginationState? PaginationState { get; init; }
|
public PaginationState? PaginationState { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// مرتبسازی بر اساس
|
|
||||||
/// </summary>
|
|
||||||
public string? SortBy { get; init; }
|
public string? SortBy { get; init; }
|
||||||
|
public GetClubMembershipHistoryFilter? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetClubMembershipHistoryFilter
|
||||||
|
{
|
||||||
|
public long? UserId { get; set; }
|
||||||
|
public long? ClubMembershipId { get; set; }
|
||||||
|
public ClubMembershipAction? Action { get; set; }
|
||||||
|
public DateTime? CreatedFrom { get; set; }
|
||||||
|
public DateTime? CreatedTo { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -12,20 +12,30 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
|
|||||||
public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken)
|
public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = _context.ClubMembershipHistories
|
var query = _context.ClubMembershipHistories
|
||||||
|
.Include(x => x.ClubMembership)
|
||||||
|
.ThenInclude(m => m!.User)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (request.MembershipId.HasValue)
|
if (request.Filter != null)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.ClubMembershipId == request.MembershipId.Value);
|
if (request.Filter.ClubMembershipId.HasValue)
|
||||||
|
query = query.Where(x => x.ClubMembershipId == request.Filter.ClubMembershipId.Value);
|
||||||
|
|
||||||
|
if (request.Filter.UserId.HasValue)
|
||||||
|
query = query.Where(x => x.UserId == request.Filter.UserId.Value);
|
||||||
|
|
||||||
|
if (request.Filter.Action.HasValue)
|
||||||
|
query = query.Where(x => x.Action == request.Filter.Action.Value);
|
||||||
|
|
||||||
|
if (request.Filter.CreatedFrom.HasValue)
|
||||||
|
query = query.Where(x => x.Created >= request.Filter.CreatedFrom.Value);
|
||||||
|
|
||||||
|
if (request.Filter.CreatedTo.HasValue)
|
||||||
|
query = query.Where(x => x.Created <= request.Filter.CreatedTo.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.UserId.HasValue)
|
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
|
||||||
{
|
|
||||||
query = query.Where(x => x.UserId == request.UserId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیشفرض: جدیدترین اول
|
|
||||||
|
|
||||||
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
||||||
|
|
||||||
@@ -36,6 +46,9 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
|
|||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
ClubMembershipId = x.ClubMembershipId,
|
ClubMembershipId = x.ClubMembershipId,
|
||||||
UserId = x.UserId,
|
UserId = x.UserId,
|
||||||
|
UserName = x.ClubMembership != null && x.ClubMembership.User != null
|
||||||
|
? $"{x.ClubMembership.User.FirstName} {x.ClubMembership.User.LastName}"
|
||||||
|
: string.Empty,
|
||||||
OldIsActive = x.OldIsActive,
|
OldIsActive = x.OldIsActive,
|
||||||
NewIsActive = x.NewIsActive,
|
NewIsActive = x.NewIsActive,
|
||||||
OldInitialContribution = x.OldInitialContribution,
|
OldInitialContribution = x.OldInitialContribution,
|
||||||
|
|||||||
+1
-26
@@ -4,31 +4,6 @@ public class GetClubMembershipHistoryQueryValidator : AbstractValidator<GetClubM
|
|||||||
{
|
{
|
||||||
public GetClubMembershipHistoryQueryValidator()
|
public GetClubMembershipHistoryQueryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.MembershipId)
|
// Admin report: filters are all optional
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("شناسه عضویت معتبر نیست")
|
|
||||||
.When(x => x.MembershipId.HasValue);
|
|
||||||
|
|
||||||
RuleFor(x => x.UserId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("شناسه کاربر معتبر نیست")
|
|
||||||
.When(x => x.UserId.HasValue);
|
|
||||||
|
|
||||||
RuleFor(x => x)
|
|
||||||
.Must(x => x.MembershipId.HasValue || x.UserId.HasValue)
|
|
||||||
.WithMessage("حداقل یکی از MembershipId یا UserId باید مقداردهی شود");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
|
|
||||||
{
|
|
||||||
var result = await ValidateAsync(
|
|
||||||
ValidationContext<GetClubMembershipHistoryQuery>.CreateWithOptions(
|
|
||||||
(GetClubMembershipHistoryQuery)model,
|
|
||||||
x => x.IncludeProperties(propertyName)));
|
|
||||||
|
|
||||||
if (result.IsValid)
|
|
||||||
return Array.Empty<string>();
|
|
||||||
|
|
||||||
return result.Errors.Select(e => e.ErrorMessage);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ public class GetClubMembershipHistoryResponseModel
|
|||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
public long ClubMembershipId { get; set; }
|
public long ClubMembershipId { get; set; }
|
||||||
public long UserId { get; set; }
|
public long UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
public bool OldIsActive { get; set; }
|
public bool OldIsActive { get; set; }
|
||||||
public bool NewIsActive { get; set; }
|
public bool NewIsActive { get; set; }
|
||||||
public long? OldInitialContribution { get; set; }
|
public long? OldInitialContribution { get; set; }
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using CMSMicroservice.Domain.Entities.Club;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||||
|
|
||||||
|
public static class ClubMembershipCycleFilterExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<ClubMembershipCycle> ApplyClubMembershipCycleFilter(
|
||||||
|
this IQueryable<ClubMembershipCycle> query,
|
||||||
|
GetAllClubMembershipCycleByFilterFilter? filter)
|
||||||
|
{
|
||||||
|
if (filter == null) return query;
|
||||||
|
|
||||||
|
if (filter.UserId.HasValue)
|
||||||
|
query = query.Where(x => x.UserId == filter.UserId.Value);
|
||||||
|
|
||||||
|
if (filter.IsCurrentCycle.HasValue)
|
||||||
|
query = query.Where(x => x.IsCurrentCycle == filter.IsCurrentCycle.Value);
|
||||||
|
|
||||||
|
if (filter.PackageId.HasValue)
|
||||||
|
query = query.Where(x => x.PackageId == filter.PackageId.Value);
|
||||||
|
|
||||||
|
if (filter.CycleNumber.HasValue)
|
||||||
|
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
|
||||||
|
|
||||||
|
if (filter.PurchaseMethod.HasValue)
|
||||||
|
query = query.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
|
||||||
|
|
||||||
|
if (filter.MagicState.HasValue)
|
||||||
|
{
|
||||||
|
query = filter.MagicState.Value switch
|
||||||
|
{
|
||||||
|
1 => query.Where(x => x.MagicStartedAt != null && x.MagicCompletedAt == null),
|
||||||
|
2 => query.Where(x => x.MagicCompletedAt != null),
|
||||||
|
_ => query.Where(x => x.MagicStartedAt == null && x.MagicCompletedAt == null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.PackagePurchasedFrom.HasValue)
|
||||||
|
query = query.Where(x => x.PackagePurchasedAt >= filter.PackagePurchasedFrom.Value);
|
||||||
|
|
||||||
|
if (filter.PackagePurchasedTo.HasValue)
|
||||||
|
query = query.Where(x => x.PackagePurchasedAt <= filter.PackagePurchasedTo.Value);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||||
|
|
||||||
|
public record GetAllClubMembershipCycleByFilterQuery : IRequest<GetAllClubMembershipCycleByFilterResponseDto>
|
||||||
|
{
|
||||||
|
public PaginationState? PaginationState { get; init; }
|
||||||
|
public string? SortBy { get; init; }
|
||||||
|
public GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleByFilterFilter
|
||||||
|
{
|
||||||
|
public long? UserId { get; set; }
|
||||||
|
public bool? IsCurrentCycle { get; set; }
|
||||||
|
public long? PackageId { get; set; }
|
||||||
|
public int? CycleNumber { get; set; }
|
||||||
|
public PackagePurchaseMethod? PurchaseMethod { get; set; }
|
||||||
|
/// <summary>0=none, 1=active magic, 2=completed magic</summary>
|
||||||
|
public int? MagicState { get; set; }
|
||||||
|
public DateTime? PackagePurchasedFrom { get; set; }
|
||||||
|
public DateTime? PackagePurchasedTo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleByFilterResponseDto
|
||||||
|
{
|
||||||
|
public MetaData MetaData { get; set; }
|
||||||
|
public List<GetAllClubMembershipCycleByFilterResponseModel>? Models { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleByFilterResponseModel
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public long ClubMembershipId { get; set; }
|
||||||
|
public int CycleNumber { get; set; }
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageName { get; set; } = string.Empty;
|
||||||
|
public long PackageAmount { get; set; }
|
||||||
|
public PackagePurchaseMethod PurchaseMethod { get; set; }
|
||||||
|
public DateTime PackagePurchasedAt { get; set; }
|
||||||
|
public DateTime? MagicStartedAt { get; set; }
|
||||||
|
public DateTime? MagicCompletedAt { get; set; }
|
||||||
|
public bool IsCurrentCycle { get; set; }
|
||||||
|
public DateTimeOffset Created { get; set; }
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleByFilterQueryHandler
|
||||||
|
: IRequestHandler<GetAllClubMembershipCycleByFilterQuery, GetAllClubMembershipCycleByFilterResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetAllClubMembershipCycleByFilterQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetAllClubMembershipCycleByFilterResponseDto> Handle(
|
||||||
|
GetAllClubMembershipCycleByFilterQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.ClubMembershipCycles
|
||||||
|
.Include(x => x.User)
|
||||||
|
.Include(x => x.Package)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ApplyClubMembershipCycleFilter(request.Filter)
|
||||||
|
.ApplyOrder(sortBy: request.SortBy ?? "-PackagePurchasedAt");
|
||||||
|
|
||||||
|
return new GetAllClubMembershipCycleByFilterResponseDto
|
||||||
|
{
|
||||||
|
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||||
|
Models = await query
|
||||||
|
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||||
|
.Select(x => new GetAllClubMembershipCycleByFilterResponseModel
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
UserId = x.UserId,
|
||||||
|
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
|
||||||
|
ClubMembershipId = x.ClubMembershipId,
|
||||||
|
CycleNumber = x.CycleNumber,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageName = x.Package != null ? x.Package.Title : string.Empty,
|
||||||
|
PackageAmount = x.PackageAmount,
|
||||||
|
PurchaseMethod = x.PurchaseMethod,
|
||||||
|
PackagePurchasedAt = x.PackagePurchasedAt,
|
||||||
|
MagicStartedAt = x.MagicStartedAt,
|
||||||
|
MagicCompletedAt = x.MagicCompletedAt,
|
||||||
|
IsCurrentCycle = x.IsCurrentCycle,
|
||||||
|
Created = x.Created
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
|
||||||
|
|
||||||
|
public record GetClubMembershipCycleSummaryQuery : IRequest<GetClubMembershipCycleSummaryResponseDto>
|
||||||
|
{
|
||||||
|
public GetAllClubMembershipCycleByFilter.GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetClubMembershipCycleSummaryResponseDto
|
||||||
|
{
|
||||||
|
public long TotalCount { get; set; }
|
||||||
|
public long CurrentCycleCount { get; set; }
|
||||||
|
public long MagicActiveCount { get; set; }
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
|
||||||
|
|
||||||
|
public class GetClubMembershipCycleSummaryQueryHandler
|
||||||
|
: IRequestHandler<GetClubMembershipCycleSummaryQuery, GetClubMembershipCycleSummaryResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetClubMembershipCycleSummaryQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetClubMembershipCycleSummaryResponseDto> Handle(
|
||||||
|
GetClubMembershipCycleSummaryQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.ClubMembershipCycles
|
||||||
|
.AsNoTracking()
|
||||||
|
.ApplyClubMembershipCycleFilter(request.Filter);
|
||||||
|
|
||||||
|
var totalCount = await query.LongCountAsync(cancellationToken);
|
||||||
|
var currentCycleCount = await query.LongCountAsync(x => x.IsCurrentCycle, cancellationToken);
|
||||||
|
var magicActiveCount = await query.LongCountAsync(
|
||||||
|
x => x.MagicStartedAt != null && x.MagicCompletedAt == null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return new GetClubMembershipCycleSummaryResponseDto
|
||||||
|
{
|
||||||
|
TotalCount = totalCount,
|
||||||
|
CurrentCycleCount = currentCycleCount,
|
||||||
|
MagicActiveCount = magicActiveCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
using CMSMicroservice.Domain.Entities.History;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||||
|
|
||||||
|
public static class ClubMembershipCycleHistoryFilterExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<ClubMembershipCycleHistory> ApplyClubMembershipCycleHistoryFilter(
|
||||||
|
this IQueryable<ClubMembershipCycleHistory> query,
|
||||||
|
GetAllClubMembershipCycleHistoryByFilterFilter? filter)
|
||||||
|
{
|
||||||
|
if (filter == null) return query;
|
||||||
|
|
||||||
|
if (filter.UserId.HasValue)
|
||||||
|
query = query.Where(x => x.UserId == filter.UserId.Value);
|
||||||
|
|
||||||
|
if (filter.ClubMembershipCycleId.HasValue)
|
||||||
|
query = query.Where(x => x.ClubMembershipCycleId == filter.ClubMembershipCycleId.Value);
|
||||||
|
|
||||||
|
if (filter.CycleNumber.HasValue)
|
||||||
|
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
|
||||||
|
|
||||||
|
if (filter.Action.HasValue)
|
||||||
|
query = query.Where(x => x.Action == filter.Action.Value);
|
||||||
|
|
||||||
|
if (filter.CreatedFrom.HasValue)
|
||||||
|
query = query.Where(x => x.Created >= filter.CreatedFrom.Value);
|
||||||
|
|
||||||
|
if (filter.CreatedTo.HasValue)
|
||||||
|
query = query.Where(x => x.Created <= filter.CreatedTo.Value);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||||
|
|
||||||
|
public record GetAllClubMembershipCycleHistoryByFilterQuery : IRequest<GetAllClubMembershipCycleHistoryByFilterResponseDto>
|
||||||
|
{
|
||||||
|
public PaginationState? PaginationState { get; init; }
|
||||||
|
public string? SortBy { get; init; }
|
||||||
|
public GetAllClubMembershipCycleHistoryByFilterFilter? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleHistoryByFilterFilter
|
||||||
|
{
|
||||||
|
public long? UserId { get; set; }
|
||||||
|
public long? ClubMembershipCycleId { get; set; }
|
||||||
|
public int? CycleNumber { get; set; }
|
||||||
|
public ClubMembershipCycleAction? Action { get; set; }
|
||||||
|
public DateTime? CreatedFrom { get; set; }
|
||||||
|
public DateTime? CreatedTo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleHistoryByFilterResponseDto
|
||||||
|
{
|
||||||
|
public MetaData MetaData { get; set; }
|
||||||
|
public List<GetAllClubMembershipCycleHistoryByFilterResponseModel>? Models { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleHistoryByFilterResponseModel
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long ClubMembershipCycleId { get; set; }
|
||||||
|
public long UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public int CycleNumber { get; set; }
|
||||||
|
public bool OldIsCurrentCycle { get; set; }
|
||||||
|
public bool NewIsCurrentCycle { get; set; }
|
||||||
|
public DateTime? OldMagicStartedAt { get; set; }
|
||||||
|
public DateTime? NewMagicStartedAt { get; set; }
|
||||||
|
public DateTime? OldMagicCompletedAt { get; set; }
|
||||||
|
public DateTime? NewMagicCompletedAt { get; set; }
|
||||||
|
public ClubMembershipCycleAction Action { get; set; }
|
||||||
|
public string? PerformedBy { get; set; }
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
public DateTimeOffset Created { get; set; }
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
|
||||||
|
|
||||||
|
public class GetAllClubMembershipCycleHistoryByFilterQueryHandler
|
||||||
|
: IRequestHandler<GetAllClubMembershipCycleHistoryByFilterQuery, GetAllClubMembershipCycleHistoryByFilterResponseDto>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetAllClubMembershipCycleHistoryByFilterQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GetAllClubMembershipCycleHistoryByFilterResponseDto> Handle(
|
||||||
|
GetAllClubMembershipCycleHistoryByFilterQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.ClubMembershipCycleHistories
|
||||||
|
.Include(x => x.ClubMembershipCycle)
|
||||||
|
.ThenInclude(c => c!.User)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ApplyClubMembershipCycleHistoryFilter(request.Filter)
|
||||||
|
.ApplyOrder(sortBy: request.SortBy ?? "-Created");
|
||||||
|
|
||||||
|
return new GetAllClubMembershipCycleHistoryByFilterResponseDto
|
||||||
|
{
|
||||||
|
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
|
||||||
|
Models = await query
|
||||||
|
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||||
|
.Select(x => new GetAllClubMembershipCycleHistoryByFilterResponseModel
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
ClubMembershipCycleId = x.ClubMembershipCycleId,
|
||||||
|
UserId = x.UserId,
|
||||||
|
UserName = x.ClubMembershipCycle != null && x.ClubMembershipCycle.User != null
|
||||||
|
? $"{x.ClubMembershipCycle.User.FirstName} {x.ClubMembershipCycle.User.LastName}"
|
||||||
|
: string.Empty,
|
||||||
|
CycleNumber = x.CycleNumber,
|
||||||
|
OldIsCurrentCycle = x.OldIsCurrentCycle,
|
||||||
|
NewIsCurrentCycle = x.NewIsCurrentCycle,
|
||||||
|
OldMagicStartedAt = x.OldMagicStartedAt,
|
||||||
|
NewMagicStartedAt = x.NewMagicStartedAt,
|
||||||
|
OldMagicCompletedAt = x.OldMagicCompletedAt,
|
||||||
|
NewMagicCompletedAt = x.NewMagicCompletedAt,
|
||||||
|
Action = x.Action,
|
||||||
|
PerformedBy = x.PerformedBy,
|
||||||
|
Reason = x.Reason,
|
||||||
|
Created = x.Created
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
|
||||||
|
|
||||||
|
public class BulkCreditPayoutsCommand : IRequest<BulkCreditPayoutsResult>
|
||||||
|
{
|
||||||
|
public long WeekDefinitionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BulkCreditPayoutsResult
|
||||||
|
{
|
||||||
|
public int CreditedCount { get; set; }
|
||||||
|
public long TotalCredited { get; set; }
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Commission;
|
||||||
|
using CMSMicroservice.Domain.Entities.History;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
|
||||||
|
|
||||||
|
public class BulkCreditPayoutsCommandHandler : IRequestHandler<BulkCreditPayoutsCommand, BulkCreditPayoutsResult>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly ICurrentUserService _currentUser;
|
||||||
|
private readonly ILogger<BulkCreditPayoutsCommandHandler> _logger;
|
||||||
|
|
||||||
|
public BulkCreditPayoutsCommandHandler(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
ICurrentUserService currentUser,
|
||||||
|
ILogger<BulkCreditPayoutsCommandHandler> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BulkCreditPayoutsResult> Handle(BulkCreditPayoutsCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pendingPayouts = await _context.UserCommissionPayouts
|
||||||
|
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId
|
||||||
|
&& p.Status == CommissionPayoutStatus.Pending
|
||||||
|
&& !p.IsDeleted)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!pendingPayouts.Any())
|
||||||
|
{
|
||||||
|
return new BulkCreditPayoutsResult { CreditedCount = 0, TotalCredited = 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
var performedBy = _currentUser.GetPerformedBy() ?? "System";
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var payoutHistories = new List<CommissionPayoutHistory>();
|
||||||
|
var creditedCount = 0;
|
||||||
|
long totalCredited = 0;
|
||||||
|
|
||||||
|
foreach (var payout in pendingPayouts)
|
||||||
|
{
|
||||||
|
payout.Status = CommissionPayoutStatus.Paid;
|
||||||
|
payout.PaidAt = now;
|
||||||
|
payout.LastModified = now;
|
||||||
|
|
||||||
|
payoutHistories.Add(new CommissionPayoutHistory
|
||||||
|
{
|
||||||
|
UserCommissionPayoutId = payout.Id,
|
||||||
|
UserId = payout.UserId,
|
||||||
|
WeekDefinitionId = payout.WeekDefinitionId,
|
||||||
|
AmountBefore = payout.TotalAmount,
|
||||||
|
AmountAfter = payout.TotalAmount,
|
||||||
|
OldStatus = CommissionPayoutStatus.Pending,
|
||||||
|
NewStatus = CommissionPayoutStatus.Paid,
|
||||||
|
Action = CommissionPayoutAction.Paid,
|
||||||
|
PerformedBy = performedBy,
|
||||||
|
Reason = $"تایید پرداخت دستهجمعی کمیسیون هفته {payout.WeekDefinitionId} توسط {performedBy}"
|
||||||
|
});
|
||||||
|
|
||||||
|
creditedCount++;
|
||||||
|
totalCredited += payout.TotalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.CommissionPayoutHistories.AddRangeAsync(payoutHistories, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"BulkCreditPayouts completed: Week={WeekId}, Credited={Count}, TotalAmount={Total}, By={By}",
|
||||||
|
request.WeekDefinitionId, creditedCount, totalCredited, performedBy);
|
||||||
|
|
||||||
|
return new BulkCreditPayoutsResult
|
||||||
|
{
|
||||||
|
CreditedCount = creditedCount,
|
||||||
|
TotalCredited = totalCredited
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-10
@@ -43,8 +43,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
|
|
||||||
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
|
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
|
||||||
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
|
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
|
||||||
|
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
|
||||||
|
var magicModeUserIds = await _context.UserWallets
|
||||||
|
.Where(w => w.WalletMode == WalletMode.Magic)
|
||||||
|
.Select(w => w.UserId)
|
||||||
|
.ToHashSetAsync(cancellationToken);
|
||||||
|
|
||||||
var activeClubMemberUserIds = await _context.ClubMemberships
|
var activeClubMemberUserIds = await _context.ClubMemberships
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
|
||||||
.Select(c => c.UserId)
|
.Select(c => c.UserId)
|
||||||
.ToHashSetAsync(cancellationToken);
|
.ToHashSetAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -76,11 +82,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
var balancesList = new List<NetworkWeeklyBalance>();
|
var balancesList = new List<NetworkWeeklyBalance>();
|
||||||
var calculatedAt = DateTime.Now;
|
var calculatedAt = DateTime.Now;
|
||||||
|
|
||||||
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
|
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||||
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
|
var package = await _context.Packages
|
||||||
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
|
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
|
||||||
|
// خواندن سقفها از پکیج (قبلاً از SystemConstants بود)
|
||||||
|
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
|
||||||
|
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||||
|
|
||||||
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
|
||||||
{
|
{
|
||||||
@@ -134,6 +143,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
|
PackageId = package.Id,
|
||||||
|
|
||||||
// اطلاعات جدید
|
// اطلاعات جدید
|
||||||
LeftLegNewMembers = leftNewMembers,
|
LeftLegNewMembers = leftNewMembers,
|
||||||
@@ -256,11 +266,16 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
|
|||||||
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
|
|
||||||
// اگر فرزند در این هفته فعال شده، 1 امتیاز
|
// ⭐ بر اساس بازه تاریخی هفته، نه IsCurrentCycle
|
||||||
var membership = await _context.ClubMemberships
|
// IsCurrentCycle فقط آخرین خرید را نشان میدهد — برای تاریخچهای (هفتههای گذشته)
|
||||||
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
|
// باید از PackagePurchasedAt استفاده کرد که تاریخ دقیق خرید را نگه میدارد
|
||||||
|
var hadCycleInWeek = await _context.ClubMembershipCycles
|
||||||
|
.AnyAsync(x => x.UserId == child.Id
|
||||||
|
&& x.PackagePurchasedAt >= startDate
|
||||||
|
&& x.PackagePurchasedAt <= endDate,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
|
if (hadCycleInWeek)
|
||||||
{
|
{
|
||||||
count = 1;
|
count = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -10,6 +10,11 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long WeekDefinitionId { get; init; }
|
public long WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اگر مقدار داشته باشد فقط همین پکیج محاسبه میشود.
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// آیا محاسبه مجدد انجام شود؟
|
/// آیا محاسبه مجدد انجام شود؟
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+34
-13
@@ -23,8 +23,17 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// بررسی وجود استخر
|
// بررسی وجود استخر
|
||||||
var existingPool = await _context.WeeklyCommissionPools
|
var existingPoolQuery = _context.WeeklyCommissionPools
|
||||||
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
|
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
{
|
||||||
|
existingPoolQuery = existingPoolQuery.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPool = await existingPoolQuery
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (existingPool == null)
|
if (existingPool == null)
|
||||||
{
|
{
|
||||||
@@ -40,9 +49,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// بررسی وجود تعادلهای هفتگی
|
// بررسی وجود تعادلهای هفتگی
|
||||||
var weeklyBalances = await _context.NetworkWeeklyBalances
|
var weeklyBalancesQuery = _context.NetworkWeeklyBalances
|
||||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
|
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
{
|
||||||
|
weeklyBalancesQuery = weeklyBalancesQuery.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var weeklyBalances = await weeklyBalancesQuery.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (!weeklyBalances.Any())
|
if (!weeklyBalances.Any())
|
||||||
{
|
{
|
||||||
@@ -76,9 +91,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
// حذف پرداختهای قبلی در صورت ForceRecalculate
|
// حذف پرداختهای قبلی در صورت ForceRecalculate
|
||||||
if (request.ForceRecalculate)
|
if (request.ForceRecalculate)
|
||||||
{
|
{
|
||||||
var oldPayouts = await _context.UserCommissionPayouts
|
var oldPayoutsQuery = _context.UserCommissionPayouts
|
||||||
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId)
|
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
{
|
||||||
|
oldPayoutsQuery = oldPayoutsQuery.Where(p => p.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldPayouts = await oldPayoutsQuery.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (oldPayouts.Any())
|
if (oldPayouts.Any())
|
||||||
{
|
{
|
||||||
@@ -192,7 +213,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
.ToDictionaryAsync(w => w.UserId, cancellationToken);
|
.ToDictionaryAsync(w => w.UserId, cancellationToken);
|
||||||
|
|
||||||
var newWallets = new List<UserWallet>();
|
var newWallets = new List<UserWallet>();
|
||||||
var walletLogs = new List<UserWalletChangeLog>();
|
var walletLogs = new List<UserWalletHistory>();
|
||||||
|
|
||||||
foreach (var payout in payouts)
|
foreach (var payout in payouts)
|
||||||
{
|
{
|
||||||
@@ -241,7 +262,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
wallet.NetworkBalance += payout.TotalAmount;
|
wallet.NetworkBalance += payout.TotalAmount;
|
||||||
|
|
||||||
// ایجاد لاگ تغییر کیف پول
|
// ایجاد لاگ تغییر کیف پول
|
||||||
var walletLog = new UserWalletChangeLog
|
var walletLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -258,7 +279,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ذخیره تغییرات کیف پول و لاگها
|
// ذخیره تغییرات کیف پول و لاگها
|
||||||
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
|
await _context.UserWalletHistories.AddRangeAsync(walletLogs, cancellationToken);
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +292,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// پیدا کردن لاگهای کیف پول مرتبط با پرداختهای قبلی
|
// پیدا کردن لاگهای کیف پول مرتبط با پرداختهای قبلی
|
||||||
var oldWalletLogs = await _context.UserWalletChangeLogs
|
var oldWalletLogs = await _context.UserWalletHistories
|
||||||
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
|
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -300,7 +321,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// حذف لاگهای قبلی
|
// حذف لاگهای قبلی
|
||||||
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
|
_context.UserWalletHistories.RemoveRange(oldWalletLogs);
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -49,8 +49,11 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
|||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
|
// بارگذاری پکیج پایه برای خواندن سقفها
|
||||||
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
|
var package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||||
|
var maxNetworkLevel = package.MaxNetworkLevel;
|
||||||
|
|
||||||
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
// دریافت همه تعادلهای هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
|
||||||
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
var allWeeklyBalances = await _context.NetworkWeeklyBalances
|
||||||
@@ -113,6 +116,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
|
|||||||
UserId = userId,
|
UserId = userId,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
WeeklyPoolId = pool.Id,
|
WeeklyPoolId = pool.Id,
|
||||||
|
PackageId = package.Id,
|
||||||
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
|
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
|
||||||
ValuePerBalance = pool.ValuePerBalance,
|
ValuePerBalance = pool.ValuePerBalance,
|
||||||
TotalAmount = totalAmount,
|
TotalAmount = totalAmount,
|
||||||
|
|||||||
+6
-1
@@ -1,3 +1,5 @@
|
|||||||
|
using CMSMicroservice.Application.Common;
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
|
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
|
||||||
|
|
||||||
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
|
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
|
||||||
@@ -37,7 +39,10 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
|
|||||||
|
|
||||||
if (request.Method == WithdrawalMethod.Cash)
|
if (request.Method == WithdrawalMethod.Cash)
|
||||||
{
|
{
|
||||||
payout.IbanNumber = request.IbanNumber;
|
var normalizedIban = IbanNormalizer.TryNormalize(request.IbanNumber);
|
||||||
|
if (normalizedIban is null)
|
||||||
|
throw new InvalidOperationException("فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد.");
|
||||||
|
payout.IbanNumber = normalizedIban;
|
||||||
}
|
}
|
||||||
|
|
||||||
_context.UserCommissionPayouts.Update(payout);
|
_context.UserCommissionPayouts.Update(payout);
|
||||||
|
|||||||
+5
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
|
|||||||
/// Skip pool calculation and payout processing (Step 2)
|
/// Skip pool calculation and payout processing (Step 2)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SkipPayouts { get; init; }
|
public bool SkipPayouts { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اگر مقدار داشته باشد فقط این پکیج محاسبه میشود، در غیر این صورت همه پکیجها
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -53,6 +53,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
|
|||||||
var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
|
var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
|
||||||
request.WeekDefinitionId,
|
request.WeekDefinitionId,
|
||||||
request.ForceRecalculate,
|
request.ForceRecalculate,
|
||||||
|
request.PackageId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
|
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
|
||||||
@@ -64,6 +65,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
|
|||||||
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
|
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
|
||||||
request.WeekDefinitionId,
|
request.WeekDefinitionId,
|
||||||
request.ForceRecalculate,
|
request.ForceRecalculate,
|
||||||
|
request.PackageId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
|
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
|
||||||
|
|||||||
+5
@@ -20,6 +20,11 @@ public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? OnlyCalculated { get; init; }
|
public bool? OnlyCalculated { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// شماره صفحه
|
/// شماره صفحه
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+9
-1
@@ -17,6 +17,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
{
|
{
|
||||||
var query = _context.WeeklyCommissionPools
|
var query = _context.WeeklyCommissionPools
|
||||||
.Include(i=> i.WeekDefinition)
|
.Include(i=> i.WeekDefinition)
|
||||||
|
.Include(i => i.Package)
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
@@ -35,6 +36,11 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
query = query.Where(x => x.IsCalculated);
|
query = query.Where(x => x.IsCalculated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// Order by week number descending (newest first)
|
// Order by week number descending (newest first)
|
||||||
query = query.OrderByDescending(x => x.WeekDefinitionId);
|
query = query.OrderByDescending(x => x.WeekDefinitionId);
|
||||||
|
|
||||||
@@ -55,7 +61,9 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
|
|||||||
ValuePerBalance = x.ValuePerBalance,
|
ValuePerBalance = x.ValuePerBalance,
|
||||||
IsCalculated = x.IsCalculated,
|
IsCalculated = x.IsCalculated,
|
||||||
CalculatedAt = x.CalculatedAt,
|
CalculatedAt = x.CalculatedAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+2
@@ -17,6 +17,8 @@ public record WeeklyCommissionPoolDto
|
|||||||
public bool IsCalculated { get; init; }
|
public bool IsCalculated { get; init; }
|
||||||
public DateTime? CalculatedAt { get; init; }
|
public DateTime? CalculatedAt { get; init; }
|
||||||
public DateTime Created { get; init; }
|
public DateTime Created { get; init; }
|
||||||
|
public long PackageId { get; init; }
|
||||||
|
public string PackageTitle { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MetaDataDto
|
public record MetaDataDto
|
||||||
|
|||||||
+5
@@ -18,6 +18,11 @@ public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsRespo
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pagination
|
/// Pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+10
-1
@@ -28,6 +28,7 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
|
|
||||||
var query = _context.UserCommissionPayouts
|
var query = _context.UserCommissionPayouts
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
|
.Include(x => x.Package)
|
||||||
.Where(x => x.UserId == userId)
|
.Where(x => x.UserId == userId)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
@@ -43,6 +44,12 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
|
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فیلتر بر اساس پکیج
|
||||||
|
if (request.PackageId.HasValue && request.PackageId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// مرتبسازی: جدیدترین اول
|
// مرتبسازی: جدیدترین اول
|
||||||
query = query.OrderByDescending(x => x.Created);
|
query = query.OrderByDescending(x => x.Created);
|
||||||
|
|
||||||
@@ -60,7 +67,9 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
|
|||||||
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
|
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
|
||||||
Status = x.Status,
|
Status = x.Status,
|
||||||
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
|
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
|
||||||
DatePersian = ""
|
DatePersian = "",
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : ""
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -20,4 +20,8 @@ public class GetMyCommissionPayoutsResponseModel
|
|||||||
public CommissionPayoutStatus Status { get; set; }
|
public CommissionPayoutStatus Status { get; set; }
|
||||||
public DateTime? CalculatedDate { get; set; }
|
public DateTime? CalculatedDate { get; set; }
|
||||||
public string DatePersian { get; set; } = string.Empty;
|
public string DatePersian { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -17,6 +17,11 @@ public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseD
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnlyActive { get; init; }
|
public bool OnlyActive { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pagination
|
/// Pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalanc
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
WeekDefinitionId = request.WeekDefinitionId,
|
WeekDefinitionId = request.WeekDefinitionId,
|
||||||
|
PackageId = request.PackageId,
|
||||||
OnlyActive = request.OnlyActive,
|
OnlyActive = request.OnlyActive,
|
||||||
PaginationState = request.PaginationState
|
PaginationState = request.PaginationState
|
||||||
};
|
};
|
||||||
|
|||||||
+5
@@ -20,6 +20,11 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// مرتبسازی
|
/// مرتبسازی
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+10
-1
@@ -18,6 +18,7 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
var query = _context.UserCommissionPayouts
|
var query = _context.UserCommissionPayouts
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
.Include(x => x.User)
|
.Include(x => x.User)
|
||||||
|
.Include(x => x.Package)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
@@ -41,6 +42,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// فیلتر بر اساس پکیج
|
||||||
|
if (request.PackageId.HasValue && request.PackageId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
|
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
|
||||||
|
|
||||||
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
||||||
@@ -64,7 +71,9 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
|
|||||||
WithdrawalMethod = x.WithdrawalMethod,
|
WithdrawalMethod = x.WithdrawalMethod,
|
||||||
IbanNumber = x.IbanNumber,
|
IbanNumber = x.IbanNumber,
|
||||||
WithdrawnAt = x.WithdrawnAt,
|
WithdrawnAt = x.WithdrawnAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : ""
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -24,4 +24,8 @@ public class GetUserCommissionPayoutsResponseModel
|
|||||||
public string? IbanNumber { get; set; }
|
public string? IbanNumber { get; set; }
|
||||||
public DateTime? WithdrawnAt { get; set; }
|
public DateTime? WithdrawnAt { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -15,6 +15,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? WeekDefinitionId { get; init; }
|
public long? WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فیلتر بر اساس پکیج (اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// فقط موارد Expired نشده؟
|
/// فقط موارد Expired نشده؟
|
||||||
|
|||||||
+35
-52
@@ -15,66 +15,49 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
|
|||||||
|
|
||||||
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
|
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = _context.NetworkWeeklyBalances
|
var pageSize = request.PaginationState?.PageSize > 0 ? request.PaginationState.PageSize : PaginationDefaults.PageSize;
|
||||||
.Include(x => x.WeekDefinition)
|
var pageNumber = request.PaginationState?.PageNumber > 0 ? request.PaginationState.PageNumber : PaginationDefaults.PageNumber;
|
||||||
.Include(x => x.User)
|
|
||||||
.AsNoTracking()
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
// UserId > 0 → filter by that user
|
// تشخیص جهت مرتبسازی از SortBy (پیشفرض: نزولی)
|
||||||
// UserId == 0 or null → show ALL users (admin mode)
|
var sortBy = request.SortBy ?? "-WeekDefinitionId";
|
||||||
// Customer endpoints resolve UserId from JWT before calling this handler
|
int sortDescending = sortBy.StartsWith("-") ? 1 : 0;
|
||||||
long? userId = request.UserId;
|
|
||||||
|
|
||||||
if (userId.HasValue && userId.Value > 0)
|
long? userId = request.UserId.HasValue && request.UserId.Value > 0 ? request.UserId : null;
|
||||||
{
|
long? weekDefinitionId = request.WeekDefinitionId.HasValue ? request.WeekDefinitionId : null;
|
||||||
query = query.Where(x => x.UserId == userId.Value);
|
long? packageId = request.PackageId.HasValue && request.PackageId.Value > 0 ? request.PackageId : null;
|
||||||
}
|
int onlyActive = request.OnlyActive.HasValue && request.OnlyActive.Value ? 1 : 0;
|
||||||
|
|
||||||
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
|
var rows = await _context.Database
|
||||||
if (request.WeekDefinitionId.HasValue)
|
.SqlQuery<GetUserWeeklyBalancesResponseModel>(
|
||||||
{
|
$"""
|
||||||
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
|
EXEC [CMS].[sp_GetUserWeeklyBalances]
|
||||||
}
|
@UserId = {userId},
|
||||||
|
@WeekDefinitionId = {weekDefinitionId},
|
||||||
|
@PackageId = {packageId},
|
||||||
if (request.OnlyActive.HasValue && request.OnlyActive.Value)
|
@OnlyActive = {onlyActive},
|
||||||
{
|
@SortDescending = {sortDescending},
|
||||||
query = query.Where(x => !x.IsExpired);
|
@PageNumber = {pageNumber},
|
||||||
}
|
@PageSize = {pageSize}
|
||||||
|
""")
|
||||||
// مرتبسازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
|
|
||||||
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
|
|
||||||
|
|
||||||
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
|
|
||||||
|
|
||||||
var models = await query
|
|
||||||
.PaginatedListAsync(paginationState: request.PaginationState)
|
|
||||||
.Select(x => new GetUserWeeklyBalancesResponseModel
|
|
||||||
{
|
|
||||||
Id = x.Id,
|
|
||||||
UserId = x.UserId,
|
|
||||||
UserFullName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}".Trim() : "",
|
|
||||||
WeekDefinitionId = x.WeekDefinitionId,
|
|
||||||
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
|
|
||||||
LeftLegNewMembers = x.LeftLegNewMembers,
|
|
||||||
LeftLegCarryover = x.LeftLegCarryover,
|
|
||||||
LeftLegTotal = x.LeftLegTotal,
|
|
||||||
RightLegNewMembers = x.RightLegNewMembers,
|
|
||||||
RightLegCarryover = x.RightLegCarryover,
|
|
||||||
RightLegTotal = x.RightLegTotal,
|
|
||||||
TotalBalances = x.TotalBalances,
|
|
||||||
WeeklyPoolContribution = x.WeeklyPoolContribution,
|
|
||||||
CalculatedAt = x.CalculatedAt,
|
|
||||||
IsExpired = x.IsExpired,
|
|
||||||
Created = x.Created
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var totalCount = rows.Count > 0 ? rows[0].TotalCount : 0;
|
||||||
|
var totalPageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
|
||||||
|
var meta = new MetaData
|
||||||
|
{
|
||||||
|
CurrentPage = pageNumber,
|
||||||
|
PageSize = pageSize,
|
||||||
|
TotalCount = totalCount,
|
||||||
|
TotalPage = totalPageCount,
|
||||||
|
HasNext = pageNumber < totalPageCount,
|
||||||
|
HasPrevious = pageNumber > 1
|
||||||
|
};
|
||||||
|
|
||||||
return new GetUserWeeklyBalancesResponseDto
|
return new GetUserWeeklyBalancesResponseDto
|
||||||
{
|
{
|
||||||
MetaData = meta,
|
MetaData = meta,
|
||||||
Models = models
|
Models = rows
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -29,4 +29,11 @@ public class GetUserWeeklyBalancesResponseModel
|
|||||||
public DateTime? CalculatedAt { get; set; }
|
public DateTime? CalculatedAt { get; set; }
|
||||||
public bool IsExpired { get; set; }
|
public bool IsExpired { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
|
||||||
|
// Package info
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// نتیجه COUNT(*) OVER() از SP — فقط در خواندن از SP پر میشود
|
||||||
|
public int TotalCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -9,4 +9,9 @@ public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
|
|||||||
/// شماره هفته
|
/// شماره هفته
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long WeekDefinitionId { get; init; }
|
public long WeekDefinitionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اختیاری
|
||||||
|
/// </summary>
|
||||||
|
public long? PackageId { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-9
@@ -15,29 +15,87 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
|
|||||||
|
|
||||||
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
|
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pool = await _context.WeeklyCommissionPools
|
var query = _context.WeeklyCommissionPools
|
||||||
.Include(x => x.WeekDefinition)
|
.Include(x => x.WeekDefinition)
|
||||||
|
.Include(x => x.Package)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
|
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
|
||||||
|
|
||||||
|
if (request.PackageId.HasValue)
|
||||||
|
query = query.Where(x => x.PackageId == request.PackageId.Value);
|
||||||
|
|
||||||
|
var pool = await query
|
||||||
.Select(x => new WeeklyCommissionPoolDto
|
.Select(x => new WeeklyCommissionPoolDto
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
WeekDefinitionId = x.WeekDefinitionId,
|
WeekDefinitionId = x.WeekDefinitionId,
|
||||||
WeekDisplayName=x.WeekDefinition.DisplayName,
|
WeekDisplayName = x.WeekDefinition.DisplayName,
|
||||||
TotalPoolAmount = x.TotalPoolAmount,
|
TotalPoolAmount = x.TotalPoolAmount,
|
||||||
TotalBalances = x.TotalBalances,
|
TotalBalances = x.TotalBalances,
|
||||||
ValuePerBalance = x.ValuePerBalance,
|
ValuePerBalance = x.ValuePerBalance,
|
||||||
IsCalculated = x.IsCalculated,
|
IsCalculated = x.IsCalculated,
|
||||||
CalculatedAt = x.CalculatedAt,
|
CalculatedAt = x.CalculatedAt,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageId = x.PackageId,
|
||||||
|
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// if (pool != null)
|
pool ??= new WeeklyCommissionPoolDto();
|
||||||
// {
|
|
||||||
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return pool ?? new WeeklyCommissionPoolDto();
|
// ── Q29: اعتبارسنجی TotalPoolAmount در برابر فعالسازیهای واقعی ──
|
||||||
|
await ValidatePoolAmountAsync(pool, request, cancellationToken);
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TotalPoolAmount استخر را با تعداد فعالسازیهای ثبتشده (AcceptContract + ActivateClubMembership) مقایسه میکند.
|
||||||
|
/// نتیجه در فیلدهای Validation... ذخیره میشود.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ValidatePoolAmountAsync(WeeklyCommissionPoolDto pool, GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var packageId = pool.PackageId > 0 ? pool.PackageId : request.PackageId;
|
||||||
|
if (packageId is null or 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var weekDef = _weekDefinitionRepository.GetWeekById(request.WeekDefinitionId);
|
||||||
|
if (weekDef is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var activationFee = await _context.Packages
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == packageId.Value)
|
||||||
|
.Select(p => p.ActivationFee)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (activationFee <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// شمارش Cycleهایی که Pool آنها واقعاً شارژ شده:
|
||||||
|
// - عضو جدید: Pool توسط AcceptContract شارژ میشود → UserContract وجود دارد
|
||||||
|
// - خرید مجدد: Pool در ActivateClubMembership شارژ میشود → IsCurrentCycle نبوده (قبلاً active بوده)
|
||||||
|
// - نباید از IsActive استفاده شود: وضعیت تاریخی نیست (عضو ممکن است بعداً غیرفعال شده باشد)
|
||||||
|
//
|
||||||
|
// قانون: Cycle شارژ شده = یا قرارداد دارد (عضو جدید) یا IsActive قبلاً true بوده (خرید مجدد)
|
||||||
|
// سادهترین معادل: Cycle هایی که ClubMembership.ActivatedAt در بازه هفته است (زمان واقعی شارژ Pool)
|
||||||
|
var totalActivations = await _context.ClubMembershipCycles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.PackageId == packageId.Value
|
||||||
|
&& c.PackagePurchasedAt >= weekDef.StartDate
|
||||||
|
&& c.PackagePurchasedAt < weekDef.EndDate
|
||||||
|
&& _context.UserContracts.Any(uc =>
|
||||||
|
uc.UserId == c.UserId
|
||||||
|
&& _context.Contracts.Any(con =>
|
||||||
|
con.Id == uc.ContractId
|
||||||
|
&& con.Type == ContractType.ClubMembership)))
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var expectedAmount = (long)totalActivations * activationFee;
|
||||||
|
|
||||||
|
pool.ValidationActivationCount = totalActivations;
|
||||||
|
pool.ValidationExpectedAmount = expectedAmount;
|
||||||
|
pool.ValidationDiscrepancy = pool.TotalPoolAmount - expectedAmount;
|
||||||
|
pool.ValidationIsValid = pool.TotalPoolAmount == expectedAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -14,4 +14,16 @@ public class WeeklyCommissionPoolDto
|
|||||||
public bool IsCalculated { get; set; }
|
public bool IsCalculated { get; set; }
|
||||||
public DateTime? CalculatedAt { get; set; }
|
public DateTime? CalculatedAt { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
public DateTimeOffset Created { get; set; }
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
public string PackageTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Validation Fields (Q29) ──
|
||||||
|
/// <summary>آیا TotalPoolAmount با تعداد فعالسازیهای ثبتشده در دیتابیس مطابقت دارد؟</summary>
|
||||||
|
public bool ValidationIsValid { get; set; } = true;
|
||||||
|
/// <summary>مبلغ مورد انتظار بر اساس شمارش فعالسازیها × ActivationFee</summary>
|
||||||
|
public long ValidationExpectedAmount { get; set; }
|
||||||
|
/// <summary>اختلاف: TotalPoolAmount - ValidationExpectedAmount</summary>
|
||||||
|
public long ValidationDiscrepancy { get; set; }
|
||||||
|
/// <summary>تعداد فعالسازیهای شناساییشده برای این هفته و پکیج</summary>
|
||||||
|
public int ValidationActivationCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -51,6 +51,15 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
|
|||||||
.PaginatedListAsync(paginationState: request.PaginationState)
|
.PaginatedListAsync(paginationState: request.PaginationState)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// دریافت PackageName از آخرین خرید پکیج کاربران
|
||||||
|
var userIds = models.Select(x => x.UserId).Distinct().ToList();
|
||||||
|
var userPackageNames = await _context.UserPackagePurchases
|
||||||
|
.Where(p => userIds.Contains(p.UserId))
|
||||||
|
.Include(p => p.Package)
|
||||||
|
.GroupBy(p => p.UserId)
|
||||||
|
.Select(g => new { UserId = g.Key, PackageName = g.OrderByDescending(x => x.PurchasedAt).First().Package.Title })
|
||||||
|
.ToDictionaryAsync(x => x.UserId, x => x.PackageName, cancellationToken);
|
||||||
|
|
||||||
var result = models.Select(x => new WithdrawalRequestModel
|
var result = models.Select(x => new WithdrawalRequestModel
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
Id = x.Id,
|
||||||
@@ -69,7 +78,8 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
|
|||||||
BankReferenceId = x.BankReferenceId,
|
BankReferenceId = x.BankReferenceId,
|
||||||
BankTrackingCode = x.BankTrackingCode,
|
BankTrackingCode = x.BankTrackingCode,
|
||||||
PaymentFailureReason = x.PaymentFailureReason,
|
PaymentFailureReason = x.PaymentFailureReason,
|
||||||
Created = x.Created
|
Created = x.Created,
|
||||||
|
PackageName = userPackageNames.GetValueOrDefault(x.UserId)
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new GetWithdrawalRequestsResponseDto
|
return new GetWithdrawalRequestsResponseDto
|
||||||
|
|||||||
+1
@@ -25,4 +25,5 @@ public class WithdrawalRequestModel
|
|||||||
public string? BankTrackingCode { get; set; }
|
public string? BankTrackingCode { get; set; }
|
||||||
public string? PaymentFailureReason { get; set; }
|
public string? PaymentFailureReason { get; set; }
|
||||||
public DateTime Created { get; set; }
|
public DateTime Created { get; set; }
|
||||||
|
public string? PackageName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.Common;
|
||||||
|
|
||||||
|
public static class BlogSlugHelper
|
||||||
|
{
|
||||||
|
private const int MaxSlugLength = 200;
|
||||||
|
|
||||||
|
public static string? NormalizeOptional(string? slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return slug.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a URL slug from title (ASCII letters/digits). Persian-only titles yield empty string.
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateFromTitle(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
var lastWasHyphen = false;
|
||||||
|
|
||||||
|
foreach (var c in title.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
lastWasHyphen = false;
|
||||||
|
}
|
||||||
|
else if (c is ' ' or '_' or '-')
|
||||||
|
{
|
||||||
|
if (sb.Length > 0 && !lastWasHyphen)
|
||||||
|
{
|
||||||
|
sb.Append('-');
|
||||||
|
lastWasHyphen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = sb.ToString().Trim('-');
|
||||||
|
return result.Length > MaxSlugLength ? result[..MaxSlugLength].TrimEnd('-') : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CreateFallbackSlug() =>
|
||||||
|
$"post-{Guid.NewGuid():N}"[..Math.Min(20, MaxSlugLength)];
|
||||||
|
|
||||||
|
public static string ResolveForCreate(string? requestedSlug, string title)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptional(requestedSlug);
|
||||||
|
if (!string.IsNullOrEmpty(normalized))
|
||||||
|
return normalized;
|
||||||
|
|
||||||
|
var fromTitle = GenerateFromTitle(title);
|
||||||
|
return !string.IsNullOrEmpty(fromTitle) ? fromTitle : CreateFallbackSlug();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> EnsureUniquePostSlugAsync(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
string slug,
|
||||||
|
long? excludePostId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var candidate = slug;
|
||||||
|
var suffix = 2;
|
||||||
|
|
||||||
|
while (await context.BlogPosts.AnyAsync(
|
||||||
|
p => p.Slug == candidate && !p.IsDeleted && (excludePostId == null || p.Id != excludePostId.Value),
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
var suffixStr = $"-{suffix}";
|
||||||
|
var maxBase = MaxSlugLength - suffixStr.Length;
|
||||||
|
var basePart = slug.Length > maxBase ? slug[..maxBase].TrimEnd('-') : slug;
|
||||||
|
candidate = basePart + suffixStr;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a concurrent payment operation is already in progress for the same scope.
|
||||||
|
/// </summary>
|
||||||
|
public class PaymentInProgressException : Exception
|
||||||
|
{
|
||||||
|
public PaymentInProgressException()
|
||||||
|
: base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentInProgressException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentInProgressException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using System.Linq.Dynamic.Core;
|
using System.Linq.Dynamic.Core;
|
||||||
using CMSMicroservice.Domain.Common;
|
using CMSMicroservice.Domain.Common;
|
||||||
|
|
||||||
@@ -8,16 +9,46 @@ public static class SortByExtensions
|
|||||||
public static IQueryable<TSource> ApplyOrder<TSource>(this IQueryable<TSource> source,
|
public static IQueryable<TSource> ApplyOrder<TSource>(this IQueryable<TSource> source,
|
||||||
string? sortBy) where TSource : BaseAuditableEntity
|
string? sortBy) where TSource : BaseAuditableEntity
|
||||||
{
|
{
|
||||||
// default sort approach
|
|
||||||
if (sortBy is null or "")
|
if (sortBy is null or "")
|
||||||
{
|
{
|
||||||
source = source.OrderByDescending(p => p.Created);
|
return source.OrderByDescending(p => p.Created);
|
||||||
return source;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort using dynamic linq
|
return source.OrderBy(NormalizeSortBy(sortBy));
|
||||||
source = source.OrderBy(sortBy);
|
}
|
||||||
|
|
||||||
return source;
|
/// <summary>
|
||||||
|
/// موجودیدارها اول، سپس سورت درخواستی کاربر (یا Created desc اگر SortBy خالی باشد).
|
||||||
|
/// </summary>
|
||||||
|
public static IQueryable<TSource> ApplyOrderWithInStockPriority<TSource>(
|
||||||
|
this IQueryable<TSource> source,
|
||||||
|
string? sortBy,
|
||||||
|
Expression<Func<TSource, bool>> isInStock) where TSource : BaseAuditableEntity
|
||||||
|
{
|
||||||
|
var ordered = source.OrderByDescending(isInStock);
|
||||||
|
if (string.IsNullOrWhiteSpace(sortBy))
|
||||||
|
return ordered.ThenByDescending(p => p.Created);
|
||||||
|
|
||||||
|
return ordered.ThenBy(NormalizeSortBy(sortBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dynamic LINQ treats "-Created" as unary minus on DateTime (invalid).
|
||||||
|
/// Project convention: leading '-' means descending sort.
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeSortBy(string sortBy)
|
||||||
|
{
|
||||||
|
sortBy = sortBy.Trim();
|
||||||
|
if (sortBy.StartsWith('-') && sortBy.Length > 1)
|
||||||
|
{
|
||||||
|
return $"{sortBy[1..]} descending";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.StartsWith('+') && sortBy.Length > 1)
|
||||||
|
{
|
||||||
|
return $"{sortBy[1..]} ascending";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using CMSMicroservice.Domain.Entities.Club;
|
||||||
|
using CMSMicroservice.Domain.Entities.History;
|
||||||
|
using CMSMicroservice.Domain.Enums;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.Common.Helpers;
|
||||||
|
|
||||||
|
public static class ClubMembershipCycleHistoryWriter
|
||||||
|
{
|
||||||
|
public static void WriteCycleStarted(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
ClubMembershipCycle cycle,
|
||||||
|
string? performedBy = "System",
|
||||||
|
string? reason = null)
|
||||||
|
{
|
||||||
|
context.ClubMembershipCycleHistories.Add(new ClubMembershipCycleHistory
|
||||||
|
{
|
||||||
|
ClubMembershipCycleId = cycle.Id,
|
||||||
|
UserId = cycle.UserId,
|
||||||
|
CycleNumber = cycle.CycleNumber,
|
||||||
|
OldIsCurrentCycle = false,
|
||||||
|
NewIsCurrentCycle = cycle.IsCurrentCycle,
|
||||||
|
OldMagicStartedAt = null,
|
||||||
|
NewMagicStartedAt = cycle.MagicStartedAt,
|
||||||
|
OldMagicCompletedAt = null,
|
||||||
|
NewMagicCompletedAt = cycle.MagicCompletedAt,
|
||||||
|
Action = ClubMembershipCycleAction.Started,
|
||||||
|
PerformedBy = performedBy,
|
||||||
|
Reason = reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteCycleClosed(
|
||||||
|
IApplicationDbContext context,
|
||||||
|
ClubMembershipCycle cycle,
|
||||||
|
string? performedBy = "System",
|
||||||
|
string? reason = null)
|
||||||
|
{
|
||||||
|
context.ClubMembershipCycleHistories.Add(new ClubMembershipCycleHistory
|
||||||
|
{
|
||||||
|
ClubMembershipCycleId = cycle.Id,
|
||||||
|
UserId = cycle.UserId,
|
||||||
|
CycleNumber = cycle.CycleNumber,
|
||||||
|
OldIsCurrentCycle = true,
|
||||||
|
NewIsCurrentCycle = false,
|
||||||
|
OldMagicStartedAt = cycle.MagicStartedAt,
|
||||||
|
NewMagicStartedAt = cycle.MagicStartedAt,
|
||||||
|
OldMagicCompletedAt = cycle.MagicCompletedAt,
|
||||||
|
NewMagicCompletedAt = cycle.MagicCompletedAt,
|
||||||
|
Action = ClubMembershipCycleAction.Completed,
|
||||||
|
PerformedBy = performedBy,
|
||||||
|
Reason = reason ?? "دوره جدید جایگزین شد"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نرمالسازی و اعتبارسنجی شماره شبا ایران (IR + 24 رقم).
|
||||||
|
/// </summary>
|
||||||
|
public static class IbanNormalizer
|
||||||
|
{
|
||||||
|
private static readonly Regex IranianIbanRegex = new(@"^IR\d{24}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فاصله و خط تیره را حذف میکند، به حروف بزرگ تبدیل میکند و یک پیشوند IR میگذارد.
|
||||||
|
/// در صورت نامعتبر بودن، null برمیگرداند.
|
||||||
|
/// </summary>
|
||||||
|
public static string? TryNormalize(string? iban)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(iban))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = iban.Trim().ToUpperInvariant()
|
||||||
|
.Replace(" ", "", StringComparison.Ordinal)
|
||||||
|
.Replace("-", "", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (normalized.StartsWith("IR", StringComparison.Ordinal))
|
||||||
|
normalized = normalized[2..];
|
||||||
|
|
||||||
|
normalized = "IR" + normalized;
|
||||||
|
|
||||||
|
return IranianIbanRegex.IsMatch(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValid(string? iban) => TryNormalize(iban) is not null;
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ public interface IUserNotificationService
|
|||||||
long userId,
|
long userId,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
int weekNumber,
|
int weekNumber,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -42,6 +43,7 @@ public interface IUserNotificationService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task SendClubActivationNotificationAsync(
|
Task SendClubActivationNotificationAsync(
|
||||||
long userId,
|
long userId,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -50,5 +52,6 @@ public interface IUserNotificationService
|
|||||||
Task SendPayoutErrorNotificationAsync(
|
Task SendPayoutErrorNotificationAsync(
|
||||||
long userId,
|
long userId,
|
||||||
string errorMessage,
|
string errorMessage,
|
||||||
|
string? packageName = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,16 @@ public interface IApplicationDbContext
|
|||||||
DbSet<OrderVAT> OrderVATs { get; }
|
DbSet<OrderVAT> OrderVATs { get; }
|
||||||
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
|
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
|
||||||
DbSet<UserWallet> UserWallets { get; }
|
DbSet<UserWallet> UserWallets { get; }
|
||||||
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
|
DbSet<UserWalletHistory> UserWalletHistories { get; }
|
||||||
DbSet<ManualPayment> ManualPayments { get; }
|
DbSet<ManualPayment> ManualPayments { get; }
|
||||||
DbSet<PaymentTransaction> PaymentTransactions { get; }
|
DbSet<PaymentTransaction> PaymentTransactions { get; }
|
||||||
DbSet<PublicMessage> PublicMessages { get; }
|
DbSet<PublicMessage> PublicMessages { get; }
|
||||||
DbSet<ClubMembership> ClubMemberships { get; }
|
DbSet<ClubMembership> ClubMemberships { get; }
|
||||||
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
|
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
|
||||||
|
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
|
||||||
|
DbSet<ClubMembershipCycleHistory> ClubMembershipCycleHistories { get; }
|
||||||
|
DbSet<PackageHistory> PackageHistories { get; }
|
||||||
|
DbSet<PackageFeature> PackageFeatures { get; }
|
||||||
DbSet<ClubFeature> ClubFeatures { get; }
|
DbSet<ClubFeature> ClubFeatures { get; }
|
||||||
DbSet<UserClubFeature> UserClubFeatures { get; }
|
DbSet<UserClubFeature> UserClubFeatures { get; }
|
||||||
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
|
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
|
||||||
@@ -80,6 +84,10 @@ public interface IApplicationDbContext
|
|||||||
DbSet<SitePage> SitePages { get; }
|
DbSet<SitePage> SitePages { get; }
|
||||||
DbSet<SitePageSection> SitePageSections { get; }
|
DbSet<SitePageSection> SitePageSections { get; }
|
||||||
|
|
||||||
|
// ============= Site Page Settings (Simplified) =============
|
||||||
|
DbSet<SitePageSettings> SitePageSettingsEntities { get; }
|
||||||
|
DbSet<SitePageImage> SitePageImages { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
|
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ public interface ICommissionCalculationStrategy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="weekDefinitionId">شناسه هفته</param>
|
/// <param name="weekDefinitionId">شناسه هفته</param>
|
||||||
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
||||||
|
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها</param>
|
||||||
/// <param name="cancellationToken">توکن لغو</param>
|
/// <param name="cancellationToken">توکن لغو</param>
|
||||||
/// <returns>تعداد رکوردهای محاسبه شده</returns>
|
/// <returns>تعداد رکوردهای محاسبه شده</returns>
|
||||||
Task<int> CalculateWeeklyBalancesAsync(
|
Task<int> CalculateWeeklyBalancesAsync(
|
||||||
long weekDefinitionId,
|
long weekDefinitionId,
|
||||||
bool forceRecalculate,
|
bool forceRecalculate,
|
||||||
|
long? packageId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -23,11 +25,13 @@ public interface ICommissionCalculationStrategy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="weekDefinitionId">شناسه هفته</param>
|
/// <param name="weekDefinitionId">شناسه هفته</param>
|
||||||
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
|
||||||
|
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه میشود — در غیر این صورت همه پکیجها</param>
|
||||||
/// <param name="cancellationToken">توکن لغو</param>
|
/// <param name="cancellationToken">توکن لغو</param>
|
||||||
/// <returns>شناسه Pool ایجاد شده</returns>
|
/// <returns>شناسه Pool ایجاد شده</returns>
|
||||||
Task<long> CalculateWeeklyCommissionPoolAsync(
|
Task<long> CalculateWeeklyCommissionPoolAsync(
|
||||||
long weekDefinitionId,
|
long weekDefinitionId,
|
||||||
bool forceRecalculate,
|
bool forceRecalculate,
|
||||||
|
long? packageId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,10 @@ public interface IKavenegarService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ارسال پیامک با قالب (VerifyLookup)
|
/// ارسال پیامک با قالب (VerifyLookup)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task VerifyLookupAsync(string mobile, string token, string template = "Afrino");
|
Task VerifyLookupAsync(string mobile, string token, string template = "OTP");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ارسال پیامک با قالب و توکن دوم (VerifyLookup) — مثلاً برای قراردادها
|
||||||
|
/// </summary>
|
||||||
|
Task VerifyLookupAsync(string mobile, string token, string token2, string template);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,21 @@ public interface IPaymentGatewayService
|
|||||||
decimal amountInToman,
|
decimal amountInToman,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// پیشفرض: درگاههایی که Amount نمیخواهند، از overload بدون amount استفاده کنند
|
// ⚠️ هشدار: این پیادهسازی پیشفرض مبلغ را نادیده میگیرد.
|
||||||
return VerifyPaymentAsync(refId, verificationToken, cancellationToken);
|
// درگاههایی مثل زرینپال باید حتماً این متد را override کنند.
|
||||||
|
throw new NotImplementedException(
|
||||||
|
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیادهسازی کند");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// فهرست Authorityهایی که کاربر پرداخت کرده ولی سیستم هنوز Verify نکرده
|
||||||
|
/// این متد برای Reconciliation در background job استفاده میشود.
|
||||||
|
/// پیادهسازی پیشفرض لیست خالی برمیگرداند (درگاههایی که این API را ندارند).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> GetUnverifiedAuthoritiesAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-scope in-process mutex for payment flows (initiate / verify).
|
||||||
|
/// Prevents the same user from running duplicate gateway operations concurrently.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUserPaymentLock
|
||||||
|
{
|
||||||
|
Task<T> ExecuteAsync<T>(
|
||||||
|
string scope,
|
||||||
|
PaymentLockStrategy strategy,
|
||||||
|
Func<CancellationToken, Task<T>> action,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task ExecuteAsync(
|
||||||
|
string scope,
|
||||||
|
PaymentLockStrategy strategy,
|
||||||
|
Func<CancellationToken, Task> action,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How to behave when the payment lock is already held.
|
||||||
|
/// </summary>
|
||||||
|
public enum PaymentLockStrategy
|
||||||
|
{
|
||||||
|
/// <summary>Wait up to the configured timeout (callback / verify paths).</summary>
|
||||||
|
WaitForRelease,
|
||||||
|
|
||||||
|
/// <summary>Reject immediately (initiate / double-click paths).</summary>
|
||||||
|
FailFast
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetAllUserWalletChangeLogByFilter;
|
|
||||||
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetUserWalletChangeLog;
|
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.Common.Mappings;
|
|
||||||
|
|
||||||
public class UserWalletChangeLogProfile : IRegister
|
|
||||||
{
|
|
||||||
void IRegister.Register(TypeAdapterConfig config)
|
|
||||||
{
|
|
||||||
config.NewConfig<UserWalletChangeLog,GetAllUserWalletChangeLogByFilterResponseModel>()
|
|
||||||
.Map(dest => dest.CreatedAt, src => src.Created);
|
|
||||||
|
|
||||||
config.NewConfig<UserWalletChangeLog, GetUserWalletChangeLogResponseDto>()
|
|
||||||
.Map(dest => dest.CreatedAt, src => src.Created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetAllUserWalletHistoryByFilter;
|
||||||
|
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetUserWalletHistory;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.Common.Mappings;
|
||||||
|
|
||||||
|
public class UserWalletHistoryProfile : IRegister
|
||||||
|
{
|
||||||
|
void IRegister.Register(TypeAdapterConfig config)
|
||||||
|
{
|
||||||
|
config.NewConfig<UserWalletHistory,GetAllUserWalletHistoryByFilterResponseModel>()
|
||||||
|
.Map(dest => dest.CreatedAt, src => src.Created);
|
||||||
|
|
||||||
|
config.NewConfig<UserWalletHistory, GetUserWalletHistoryResponseDto>()
|
||||||
|
.Map(dest => dest.CreatedAt, src => src.Created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical scope keys for <see cref="Interfaces.IUserPaymentLock"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class PaymentLockScopes
|
||||||
|
{
|
||||||
|
/// <summary>One active payment initiation per user (package, wallet charge, order, IPG deposit).</summary>
|
||||||
|
public static string Initiate(long userId) => $"payment:initiate:user:{userId}";
|
||||||
|
|
||||||
|
/// <summary>One active verify per user + authority/order (duplicate callback protection).</summary>
|
||||||
|
public static string Verify(long userId, string resourceKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(resourceKey))
|
||||||
|
throw new ArgumentException("Payment verify resource key is required.", nameof(resourceKey));
|
||||||
|
|
||||||
|
return $"payment:verify:user:{userId}:{resourceKey.Trim()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
|
|||||||
public static class VatCalculator
|
public static class VatCalculator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نرخ VAT ایران - 9 درصد
|
/// نرخ VAT ایران - 10 درصد
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const decimal VAT_RATE = 0.09m;
|
public const decimal VAT_RATE = 0.10m;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نرخ VAT به صورت درصد (9)
|
/// نرخ VAT به صورت درصد (10)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int VAT_PERCENT = 9;
|
public const int VAT_PERCENT = 10;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// محاسبه VAT از مبلغ خالص
|
/// محاسبه VAT از مبلغ خالص
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace CMSMicroservice.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام نمایشی کاربر برای گزارشها؛ اگر نام خالی باشد موبایل یا شناسه.
|
||||||
|
/// </summary>
|
||||||
|
public static class UserDisplayName
|
||||||
|
{
|
||||||
|
public static string From(string? firstName, string? lastName, string? mobile, long userId)
|
||||||
|
{
|
||||||
|
var name = string.Join(" ",
|
||||||
|
new[] { firstName, lastName }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
return name;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mobile))
|
||||||
|
return mobile.Trim();
|
||||||
|
|
||||||
|
return userId > 0 ? $"کاربر {userId}" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string From(Domain.Entities.User? user)
|
||||||
|
{
|
||||||
|
if (user is null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return From(user.FirstName, user.LastName, user.Mobile, user.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-29
@@ -12,7 +12,7 @@ namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoa
|
|||||||
/// 1. استعلام از API دایا
|
/// 1. استعلام از API دایا
|
||||||
/// 2. ذخیره/بهروزرسانی DayaLoanContract
|
/// 2. ذخیره/بهروزرسانی DayaLoanContract
|
||||||
/// 3. شارژ کیف پول برای وامهای تأیید شده
|
/// 3. شارژ کیف پول برای وامهای تأیید شده
|
||||||
/// 4. ثبت Order پکیج طلایی
|
/// 4. ثبت Order پکیج
|
||||||
/// 5. ارسال SMS
|
/// 5. ارسال SMS
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndProcessDayaLoansCommand, CheckAndProcessDayaLoansResponseDto>
|
public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndProcessDayaLoansCommand, CheckAndProcessDayaLoansResponseDto>
|
||||||
@@ -72,6 +72,17 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
|
|||||||
|
|
||||||
result.UserId = user.Id;
|
result.UserId = user.Id;
|
||||||
|
|
||||||
|
// 2.5. بررسی محدودیت دایا — پس از اولین دور، فقط IPG مجاز است
|
||||||
|
var hasPreviousCycle = await _context.ClubMembershipCycles
|
||||||
|
.AnyAsync(c => c.UserId == user.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (hasPreviousCycle)
|
||||||
|
{
|
||||||
|
result.Message = "کاربرانی که دور اول را تکمیل کردهاند فقط از طریق درگاه بانکی میتوانند پکیج خریداری کنند";
|
||||||
|
results.Add(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. ذخیره/بهروزرسانی DayaLoanContract
|
// 3. ذخیره/بهروزرسانی DayaLoanContract
|
||||||
var contract = await _context.DayaLoanContracts
|
var contract = await _context.DayaLoanContracts
|
||||||
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
|
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
|
||||||
@@ -111,6 +122,7 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
|
|||||||
contract.IsProcessed = true;
|
contract.IsProcessed = true;
|
||||||
result.WasProcessed = true;
|
result.WasProcessed = true;
|
||||||
result.NewWalletBalance = processResult.NewBalance;
|
result.NewWalletBalance = processResult.NewBalance;
|
||||||
|
result.PackageName = processResult.PackageName;
|
||||||
result.Message = "وام پردازش و کیف پول شارژ شد";
|
result.Message = "وام پردازش و کیف پول شارژ شد";
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
||||||
@@ -170,15 +182,20 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// پردازش مالی وام دایا
|
/// پردازش مالی وام دایا
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(long NewBalance, long TransactionId)> ProcessDayaLoanAsync(
|
private async Task<(long NewBalance, long TransactionId, string PackageName)> ProcessDayaLoanAsync(
|
||||||
User user,
|
User user,
|
||||||
string contractNumber,
|
string contractNumber,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// بارگذاری پکیج پایه
|
||||||
|
var package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
|
||||||
|
|
||||||
// 1. ایجاد تراکنش
|
// 1. ایجاد تراکنش
|
||||||
var transaction = new Transaction
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Amount = SystemConstants.DayaLoanAmount,
|
Amount = package.Price,
|
||||||
Description = $"دریافت اعتبار دایا - قرارداد {contractNumber}",
|
Description = $"دریافت اعتبار دایا - قرارداد {contractNumber}",
|
||||||
PaymentStatus = PaymentStatus.Success,
|
PaymentStatus = PaymentStatus.Success,
|
||||||
PaymentDate = DateTime.Now,
|
PaymentDate = DateTime.Now,
|
||||||
@@ -205,25 +222,26 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. شارژ کیف پول عادی
|
// 3. شارژ کیف پول عادی
|
||||||
wallet.Balance += SystemConstants.DayaLoanAmount;
|
wallet.Balance += package.Price;
|
||||||
|
|
||||||
var mainLog = new UserWalletChangeLog
|
var mainLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = 0,
|
CurrentBalance = 0,
|
||||||
ChangeValue = SystemConstants.DayaLoanAmount,
|
ChangeValue = package.Price,
|
||||||
CurrentNetworkBalance = wallet.NetworkBalance,
|
CurrentNetworkBalance = wallet.NetworkBalance,
|
||||||
ChangeNerworkValue = 0,
|
ChangeNerworkValue = 0,
|
||||||
IsIncrease = true,
|
IsIncrease = true,
|
||||||
RefrenceId = transaction.Id
|
RefrenceId = transaction.Id,
|
||||||
|
PackageId = package.Id
|
||||||
};
|
};
|
||||||
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
|
await _context.UserWalletHistories.AddAsync(mainLog, cancellationToken);
|
||||||
|
|
||||||
// 4. شارژ کیف پول تخفیف (دو برابر)
|
// 4. شارژ کیف پول تخفیف
|
||||||
var discountAmount = SystemConstants.DayaLoanAmount * 2;
|
var discountAmount = (long)(package.Price * package.DiscountMultiplier);
|
||||||
wallet.DiscountBalance += discountAmount;
|
wallet.DiscountBalance += discountAmount;
|
||||||
|
|
||||||
var discountLog = new UserWalletChangeLog
|
var discountLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -233,62 +251,54 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
|
|||||||
CurrentDiscountBalance = 0,
|
CurrentDiscountBalance = 0,
|
||||||
ChangeDiscountValue = discountAmount,
|
ChangeDiscountValue = discountAmount,
|
||||||
IsIncrease = true,
|
IsIncrease = true,
|
||||||
RefrenceId = transaction.Id
|
RefrenceId = transaction.Id,
|
||||||
|
PackageId = package.Id
|
||||||
};
|
};
|
||||||
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
|
await _context.UserWalletHistories.AddAsync(discountLog, cancellationToken);
|
||||||
|
|
||||||
// 5. بهروزرسانی کاربر
|
// 5. بهروزرسانی کاربر
|
||||||
user.HasReceivedDayaCredit = true;
|
user.HasReceivedDayaCredit = true;
|
||||||
user.DayaCreditReceivedAt = DateTime.Now;
|
user.DayaCreditReceivedAt = DateTime.Now;
|
||||||
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
|
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
|
||||||
|
|
||||||
// 6. ثبت Order پکیج پایه
|
|
||||||
var goldenPackage = await _context.Packages
|
|
||||||
.FirstOrDefaultAsync(p => p.Id == 4, cancellationToken);
|
|
||||||
|
|
||||||
if (goldenPackage != null)
|
|
||||||
{
|
|
||||||
// 6. ثبت UserPackagePurchase برای پکیج پایه
|
// 6. ثبت UserPackagePurchase برای پکیج پایه
|
||||||
var goldenPackageId =goldenPackage.Id;
|
|
||||||
var packagePurchase = new UserPackagePurchase
|
var packagePurchase = new UserPackagePurchase
|
||||||
{
|
{
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
PackageId = goldenPackageId,
|
PackageId = package.Id,
|
||||||
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
|
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
|
||||||
PurchasedAt = DateTime.Now,
|
PurchasedAt = DateTime.Now,
|
||||||
Amount = SystemConstants.DayaLoanAmount,
|
Amount = package.Price,
|
||||||
TransactionId = transaction.Id
|
TransactionId = transaction.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
|
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Domain Event
|
// 7. Domain Event
|
||||||
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, contractNumber));
|
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, contractNumber));
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 8. ارسال SMS
|
// 8. ارسال SMS
|
||||||
await SendDayaLoanSmsAsync(user);
|
await SendDayaLoanSmsAsync(user, package.Title);
|
||||||
|
|
||||||
return (wallet.Balance, transaction.Id);
|
return (wallet.Balance, transaction.Id, package.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ارسال SMS اطلاعرسانی
|
/// ارسال SMS اطلاعرسانی
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SendDayaLoanSmsAsync(User user)
|
private async Task SendDayaLoanSmsAsync(User user, string packageName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userName = SmsTemplates.GetUserName(user.FirstName+" "+user.LastName);
|
var userName = SmsTemplates.GetUserName(user.FirstName+" "+user.LastName);
|
||||||
var message = SmsTemplates.DayaLoanReceived(userName);
|
var message = SmsTemplates.DayaLoanReceived(userName, packageName);
|
||||||
|
|
||||||
await _smsService.SendAsync(user.Mobile, message);
|
await _smsService.SendAsync(user.Mobile, message);
|
||||||
|
|
||||||
// ارسال SMS به ادمین برای اطلاع
|
// ارسال SMS به ادمین برای اطلاع
|
||||||
var adminMessage = $"وام دایا دریافت شد\nکاربر: {user.FirstName} {user.LastName}\nموبایل: {user.Mobile}\nکدملی: {user.NationalCode}";
|
var adminMessage = $"وام دایا دریافت شد\nکاربر: {user.FirstName} {user.LastName}\nموبایل: {user.Mobile}\nکدملی: {user.NationalCode}\nپکیج: {packageName}";
|
||||||
await _smsService.SendAsync("09199877503", adminMessage);
|
await _smsService.SendAsync("09199877503", adminMessage);
|
||||||
|
|
||||||
_logger.LogInformation("Daya loan SMS sent to User {UserId}", user.Id);
|
_logger.LogInformation("Daya loan SMS sent to User {UserId}", user.Id);
|
||||||
|
|||||||
+5
@@ -63,6 +63,11 @@ public class DayaLoanProcessResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long? NewWalletBalance { get; set; }
|
public long? NewWalletBalance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام پکیج
|
||||||
|
/// </summary>
|
||||||
|
public string? PackageName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// پیام
|
/// پیام
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+15
-1
@@ -62,9 +62,23 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
|
|||||||
var userWallet = await _context.UserWallets
|
var userWallet = await _context.UserWallets
|
||||||
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
|
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
|
||||||
|
|
||||||
if (userWallet != null)
|
if (userWallet != null && order.DiscountBalanceUsed > 0)
|
||||||
{
|
{
|
||||||
userWallet.DiscountBalance -= order.DiscountBalanceUsed;
|
userWallet.DiscountBalance -= order.DiscountBalanceUsed;
|
||||||
|
|
||||||
|
// ثبت لاگ تغییرات کیف پول
|
||||||
|
_context.UserWalletHistories.Add(new Domain.Entities.UserWalletHistory
|
||||||
|
{
|
||||||
|
WalletId = userWallet.Id,
|
||||||
|
CurrentBalance = userWallet.Balance,
|
||||||
|
ChangeValue = 0,
|
||||||
|
CurrentNetworkBalance = userWallet.NetworkBalance,
|
||||||
|
ChangeNerworkValue = 0,
|
||||||
|
CurrentDiscountBalance = userWallet.DiscountBalance,
|
||||||
|
ChangeDiscountValue = -order.DiscountBalanceUsed,
|
||||||
|
IsIncrease = false,
|
||||||
|
RefrenceId = transaction.Id
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// تایید فروش و کسر موجودی از طریق InventoryService
|
// تایید فروش و کسر موجودی از طریق InventoryService
|
||||||
|
|||||||
+46
-7
@@ -1,3 +1,5 @@
|
|||||||
|
using CMSMicroservice.Application.Common;
|
||||||
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Application.Common.Services;
|
using CMSMicroservice.Application.Common.Services;
|
||||||
using CMSMicroservice.Domain.Entities.DiscountShop;
|
using CMSMicroservice.Domain.Entities.DiscountShop;
|
||||||
@@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
|||||||
private readonly IPaymentGatewayService _paymentGateway;
|
private readonly IPaymentGatewayService _paymentGateway;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<PlaceOrderCommandHandler> _logger;
|
private readonly ILogger<PlaceOrderCommandHandler> _logger;
|
||||||
|
private readonly IUserPaymentLock _paymentLock;
|
||||||
|
|
||||||
public PlaceOrderCommandHandler(
|
public PlaceOrderCommandHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
IInventoryService inventoryService,
|
IInventoryService inventoryService,
|
||||||
IPaymentGatewayService paymentGateway,
|
IPaymentGatewayService paymentGateway,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<PlaceOrderCommandHandler> logger)
|
ILogger<PlaceOrderCommandHandler> logger,
|
||||||
|
IUserPaymentLock paymentLock)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_inventoryService = inventoryService;
|
_inventoryService = inventoryService;
|
||||||
_paymentGateway = paymentGateway;
|
_paymentGateway = paymentGateway;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_paymentLock = paymentLock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
|
public Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) =>
|
||||||
|
_paymentLock.ExecuteAsync(
|
||||||
|
PaymentLockScopes.Initiate(request.UserId),
|
||||||
|
PaymentLockStrategy.FailFast,
|
||||||
|
ct => HandleCore(request, ct),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<PlaceOrderResponseDto> HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Get user wallet
|
// Get user wallet
|
||||||
var userWallet = await _context.UserWallets
|
var userWallet = await _context.UserWallets
|
||||||
@@ -111,7 +123,18 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
|||||||
|
|
||||||
// Always apply maximum possible discount (100%) — user cannot choose less
|
// Always apply maximum possible discount (100%) — user cannot choose less
|
||||||
var maxDiscountBalanceUsable = totalDiscountAmount;
|
var maxDiscountBalanceUsable = totalDiscountAmount;
|
||||||
var actualDiscountBalanceUsed = Math.Min(maxDiscountBalanceUsable, userWallet.DiscountBalance);
|
|
||||||
|
// بررسی کافی بودن موجودی کیف پول اعتباری (تخفیفی)
|
||||||
|
if (userWallet.DiscountBalance < maxDiscountBalanceUsable)
|
||||||
|
{
|
||||||
|
return new PlaceOrderResponseDto
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = $"موجودی کیف پول اعتباری کافی نیست. مبلغ مورد نیاز: {maxDiscountBalanceUsable:N0} تومان، موجودی شما: {userWallet.DiscountBalance:N0} تومان"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualDiscountBalanceUsed = maxDiscountBalanceUsable;
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Discount auto-applied: max allowed={MaxAllowed}, wallet balance={WalletBalance}, used={Used}",
|
"Discount auto-applied: max allowed={MaxAllowed}, wallet balance={WalletBalance}, used={Used}",
|
||||||
@@ -184,9 +207,9 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// آدرس callback — زرینپال بعد از پرداخت کاربر را به اینجا هدایت میکند
|
// آدرس callback — زرینپال بعد از پرداخت مستقیم به فرانتآفیس هدایت میکند
|
||||||
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
|
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
|
||||||
var callbackUrl = $"{cmsBaseUrl}/api/payment/discount-order/callback?orderId={order.Id}";
|
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?type=discount-order&orderId={order.Id}";
|
||||||
|
|
||||||
// درخواست به درگاه
|
// درخواست به درگاه
|
||||||
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
|
||||||
@@ -263,9 +286,25 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
|
|||||||
|
|
||||||
var walletForDeduct = await _context.UserWallets
|
var walletForDeduct = await _context.UserWallets
|
||||||
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
|
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
|
||||||
if (walletForDeduct != null)
|
if (walletForDeduct != null && actualDiscountBalanceUsed > 0)
|
||||||
|
{
|
||||||
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
|
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
|
||||||
|
|
||||||
|
// ثبت لاگ تغییرات کیف پول
|
||||||
|
_context.UserWalletHistories.Add(new Domain.Entities.UserWalletHistory
|
||||||
|
{
|
||||||
|
WalletId = walletForDeduct.Id,
|
||||||
|
CurrentBalance = walletForDeduct.Balance,
|
||||||
|
ChangeValue = 0,
|
||||||
|
CurrentNetworkBalance = walletForDeduct.NetworkBalance,
|
||||||
|
ChangeNerworkValue = 0,
|
||||||
|
CurrentDiscountBalance = walletForDeduct.DiscountBalance,
|
||||||
|
ChangeDiscountValue = -actualDiscountBalanceUsed,
|
||||||
|
IsIncrease = false,
|
||||||
|
RefrenceId = transaction.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var cartItem in cartItems)
|
foreach (var cartItem in cartItems)
|
||||||
{
|
{
|
||||||
await _inventoryService.ConfirmSaleAsync(
|
await _inventoryService.ConfirmSaleAsync(
|
||||||
|
|||||||
+5
@@ -35,6 +35,11 @@ public class UpdateOrderStatusCommandHandler : IRequestHandler<UpdateOrderStatus
|
|||||||
order.TrackingCode = request.TrackingCode;
|
order.TrackingCode = request.TrackingCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.AdminNotes))
|
||||||
|
{
|
||||||
|
order.DeliveryDescription = request.AdminNotes;
|
||||||
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return new UpdateOrderStatusResponseDto
|
return new UpdateOrderStatusResponseDto
|
||||||
|
|||||||
+36
-12
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Application.Common;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Application.Common.Models;
|
using CMSMicroservice.Application.Common.Models;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -80,16 +81,40 @@ public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOr
|
|||||||
// Apply pagination
|
// Apply pagination
|
||||||
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 };
|
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 };
|
||||||
|
|
||||||
var orders = await query
|
var rows = await query
|
||||||
.OrderByDescending(o => o.Created)
|
.OrderByDescending(o => o.Created)
|
||||||
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
|
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
|
||||||
.Take(pagination.PageSize)
|
.Take(pagination.PageSize)
|
||||||
.Select(o => new AdminOrderDto
|
.Select(o => new
|
||||||
|
{
|
||||||
|
o.Id,
|
||||||
|
o.UserId,
|
||||||
|
FirstName = o.User.FirstName,
|
||||||
|
LastName = o.User.LastName,
|
||||||
|
Mobile = o.User.Mobile,
|
||||||
|
o.TotalAmount,
|
||||||
|
o.DiscountBalanceUsed,
|
||||||
|
o.GatewayAmountPaid,
|
||||||
|
o.VatAmount,
|
||||||
|
o.PaymentStatus,
|
||||||
|
o.PaymentDate,
|
||||||
|
o.DeliveryStatus,
|
||||||
|
ShippingAddress = o.UserAddress.Address,
|
||||||
|
ReceiverName = o.UserAddress.Title,
|
||||||
|
o.TrackingCode,
|
||||||
|
AdminNote = o.DeliveryDescription,
|
||||||
|
o.Created,
|
||||||
|
o.LastModified,
|
||||||
|
ItemsCount = o.OrderDetails.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var orders = rows.Select(o => new AdminOrderDto
|
||||||
{
|
{
|
||||||
Id = o.Id,
|
Id = o.Id,
|
||||||
UserId = o.UserId,
|
UserId = o.UserId,
|
||||||
UserFullName = (o.User.FirstName ?? "") + " " + (o.User.LastName ?? ""),
|
UserFullName = UserDisplayName.From(o.FirstName, o.LastName, o.Mobile, o.UserId),
|
||||||
UserMobile = o.User.Mobile,
|
UserMobile = o.Mobile,
|
||||||
TotalAmount = o.TotalAmount,
|
TotalAmount = o.TotalAmount,
|
||||||
DiscountBalanceUsed = o.DiscountBalanceUsed,
|
DiscountBalanceUsed = o.DiscountBalanceUsed,
|
||||||
GatewayAmountPaid = o.GatewayAmountPaid,
|
GatewayAmountPaid = o.GatewayAmountPaid,
|
||||||
@@ -97,17 +122,16 @@ public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOr
|
|||||||
PaymentStatus = o.PaymentStatus,
|
PaymentStatus = o.PaymentStatus,
|
||||||
PaymentDate = o.PaymentDate,
|
PaymentDate = o.PaymentDate,
|
||||||
DeliveryStatus = o.DeliveryStatus,
|
DeliveryStatus = o.DeliveryStatus,
|
||||||
DeliveryDate = null, // TODO: Add DeliveryDate to DiscountOrder if needed
|
DeliveryDate = null,
|
||||||
ShippingAddress = o.UserAddress.Address,
|
ShippingAddress = o.ShippingAddress,
|
||||||
ReceiverName = o.UserAddress.Title,
|
ReceiverName = o.ReceiverName,
|
||||||
ReceiverMobile = o.User.Mobile,
|
ReceiverMobile = o.Mobile,
|
||||||
TrackingCode = o.TrackingCode,
|
TrackingCode = o.TrackingCode,
|
||||||
AdminNote = o.DeliveryDescription,
|
AdminNote = o.AdminNote,
|
||||||
Created = o.Created,
|
Created = o.Created,
|
||||||
LastModified = o.LastModified,
|
LastModified = o.LastModified,
|
||||||
ItemsCount = o.OrderDetails.Count
|
ItemsCount = o.ItemsCount
|
||||||
})
|
}).ToList();
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return new GetAllDiscountOrdersResponseDto
|
return new GetAllDiscountOrdersResponseDto
|
||||||
{
|
{
|
||||||
|
|||||||
+5
@@ -8,9 +8,14 @@ public class GetDiscountProductsQuery : IRequest<GetDiscountProductsResponseDto>
|
|||||||
public PaginationState? PaginationQuery { get; set; }
|
public PaginationState? PaginationQuery { get; set; }
|
||||||
public long? CategoryId { get; set; }
|
public long? CategoryId { get; set; }
|
||||||
public string? SearchTerm { get; set; }
|
public string? SearchTerm { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// true = فقط موجود (RemainingCount > 0)، false = فقط ناموجود
|
||||||
|
/// </summary>
|
||||||
|
public bool? InStock { get; set; }
|
||||||
public bool? IsActive { get; set; }
|
public bool? IsActive { get; set; }
|
||||||
public int? MinPrice { get; set; }
|
public int? MinPrice { get; set; }
|
||||||
public int? MaxPrice { get; set; }
|
public int? MaxPrice { get; set; }
|
||||||
|
public string? SortBy { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GetDiscountProductsResponseDto
|
public class GetDiscountProductsResponseDto
|
||||||
|
|||||||
+9
-2
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Extensions;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Application.Common.Models;
|
using CMSMicroservice.Application.Common.Models;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -41,6 +42,11 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
|
|||||||
query = query.Where(p => p.IsActive == request.IsActive.Value);
|
query = query.Where(p => p.IsActive == request.IsActive.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.InStock == true)
|
||||||
|
query = query.Where(p => p.RemainingCount > 0);
|
||||||
|
else if (request.InStock == false)
|
||||||
|
query = query.Where(p => p.RemainingCount <= 0);
|
||||||
|
|
||||||
if (request.MinPrice.HasValue)
|
if (request.MinPrice.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(p => p.Price >= request.MinPrice.Value);
|
query = query.Where(p => p.Price >= request.MinPrice.Value);
|
||||||
@@ -51,13 +57,14 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
|
|||||||
query = query.Where(p => p.Price <= request.MaxPrice.Value);
|
query = query.Where(p => p.Price <= request.MaxPrice.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// موجودیدارها اول، سپس سورت درخواستی
|
||||||
|
query = query.ApplyOrderWithInStockPriority(request.SortBy, p => p.RemainingCount > 0);
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(cancellationToken);
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
// Apply pagination
|
|
||||||
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 10 };
|
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 10 };
|
||||||
|
|
||||||
var products = await query
|
var products = await query
|
||||||
.OrderByDescending(p => p.Created)
|
|
||||||
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
|
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
|
||||||
.Take(pagination.PageSize)
|
.Take(pagination.PageSize)
|
||||||
.Select(p => new DiscountProductDto
|
.Select(p => new DiscountProductDto
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,7 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetOrderById;
|
|||||||
public class GetOrderByIdQuery : IRequest<OrderDetailDto?>
|
public class GetOrderByIdQuery : IRequest<OrderDetailDto?>
|
||||||
{
|
{
|
||||||
public long OrderId { get; set; }
|
public long OrderId { get; set; }
|
||||||
public long UserId { get; set; }
|
public long? UserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OrderDetailDto
|
public class OrderDetailDto
|
||||||
@@ -32,6 +32,8 @@ public class UserAddressDto
|
|||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Address { get; set; }
|
public string Address { get; set; }
|
||||||
public string PostalCode { get; set; }
|
public string PostalCode { get; set; }
|
||||||
|
/// <summary>شماره تماس سفارشدهنده (موبایل کاربر) برای برچسب پست</summary>
|
||||||
|
public string? Phone { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OrderItemDto
|
public class OrderItemDto
|
||||||
|
|||||||
+12
-3
@@ -15,9 +15,17 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
|||||||
|
|
||||||
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
|
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var order = await _context.DiscountOrders
|
var query = _context.DiscountOrders
|
||||||
.Where(o => o.Id == request.OrderId && o.UserId == request.UserId)
|
.Where(o => o.Id == request.OrderId);
|
||||||
|
|
||||||
|
if (request.UserId.HasValue && request.UserId.Value > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(o => o.UserId == request.UserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = await query
|
||||||
.Include(o => o.UserAddress)
|
.Include(o => o.UserAddress)
|
||||||
|
.Include(o => o.User)
|
||||||
.Include(o => o.OrderDetails)
|
.Include(o => o.OrderDetails)
|
||||||
.ThenInclude(od => od.Product)
|
.ThenInclude(od => od.Product)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -43,7 +51,8 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
|
|||||||
{
|
{
|
||||||
Title = order.UserAddress.Title,
|
Title = order.UserAddress.Title,
|
||||||
Address = order.UserAddress.Address,
|
Address = order.UserAddress.Address,
|
||||||
PostalCode = order.UserAddress.PostalCode
|
PostalCode = order.UserAddress.PostalCode,
|
||||||
|
Phone = order.User?.Mobile
|
||||||
},
|
},
|
||||||
Items = order.OrderDetails.Select(od => new OrderItemDto
|
Items = order.OrderDetails.Select(od => new OrderItemDto
|
||||||
{
|
{
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.DeletePageImage;
|
||||||
|
|
||||||
|
// ── Command ──
|
||||||
|
public class DeletePageImageCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──
|
||||||
|
public class DeletePageImageCommandHandler : IRequestHandler<DeletePageImageCommand, Unit>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public DeletePageImageCommandHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Handle(DeletePageImageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.SitePageImages
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
entity.IsDeleted = true; // Soft delete
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Content;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageImage;
|
||||||
|
|
||||||
|
// ── Command ──
|
||||||
|
public class SavePageImageCommand : IRequest<SavePageImageResult>
|
||||||
|
{
|
||||||
|
public long Id { get; set; } // 0 = ایجاد جدید
|
||||||
|
public long SitePageSettingsId { get; set; }
|
||||||
|
public string ImageGroup { get; set; } = default!;
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Subtitle { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? LinkUrl { get; set; }
|
||||||
|
public string? IconName { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
// Image upload
|
||||||
|
public byte[]? ImageFileBytes { get; set; }
|
||||||
|
public string? ImageFileMime { get; set; }
|
||||||
|
public string? ImageFileName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SavePageImageResult
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──
|
||||||
|
public class SavePageImageCommandHandler : IRequestHandler<SavePageImageCommand, SavePageImageResult>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly IFileManager _fileManager;
|
||||||
|
|
||||||
|
public SavePageImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_fileManager = fileManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SavePageImageResult> Handle(SavePageImageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SitePageImage entity;
|
||||||
|
|
||||||
|
if (request.Id > 0)
|
||||||
|
{
|
||||||
|
// ویرایش
|
||||||
|
entity = await _context.SitePageImages
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
|
||||||
|
?? throw new Exception($"تصویر با شناسه {request.Id} یافت نشد");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ایجاد جدید
|
||||||
|
entity = new SitePageImage
|
||||||
|
{
|
||||||
|
SitePageSettingsId = request.SitePageSettingsId,
|
||||||
|
ImageGroup = request.ImageGroup,
|
||||||
|
ImagePath = string.Empty // placeholder — will be set below
|
||||||
|
};
|
||||||
|
_context.SitePageImages.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Title = request.Title;
|
||||||
|
entity.Subtitle = request.Subtitle;
|
||||||
|
entity.Description = request.Description;
|
||||||
|
entity.LinkUrl = request.LinkUrl;
|
||||||
|
entity.IconName = request.IconName;
|
||||||
|
entity.SortOrder = request.SortOrder;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
// آپلود تصویر
|
||||||
|
if (request.ImageFileBytes is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var result = await _fileManager.UploadImageAsync(
|
||||||
|
$"Images/SitePageSettings/{request.ImageGroup}",
|
||||||
|
request.ImageFileBytes,
|
||||||
|
request.ImageFileMime ?? "image/jpeg",
|
||||||
|
request.ImageFileName,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
entity.ImagePath = result.Main.Path;
|
||||||
|
entity.ThumbnailPath = result.Thumbnail.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new SavePageImageResult { Id = entity.Id, Success = true };
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
using CMSMicroservice.Application.Common.FileManager;
|
||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Content;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
|
||||||
|
|
||||||
|
// ── Command ──
|
||||||
|
public class SavePageSettingsCommand : IRequest<SavePageSettingsResult>
|
||||||
|
{
|
||||||
|
public string PageKey { get; set; } = default!;
|
||||||
|
public string Title { get; set; } = default!;
|
||||||
|
public string? MetaDescription { get; set; }
|
||||||
|
public string? HeroTitle { get; set; }
|
||||||
|
public string? HeroSubtitle { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string? SettingsJson { get; set; }
|
||||||
|
|
||||||
|
// Hero image upload
|
||||||
|
public byte[]? ImageFileBytes { get; set; }
|
||||||
|
public string? ImageFileMime { get; set; }
|
||||||
|
public string? ImageFileName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SavePageSettingsResult
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──
|
||||||
|
public class SavePageSettingsCommandHandler : IRequestHandler<SavePageSettingsCommand, SavePageSettingsResult>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
private readonly IFileManager _fileManager;
|
||||||
|
|
||||||
|
public SavePageSettingsCommandHandler(IApplicationDbContext context, IFileManager fileManager)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_fileManager = fileManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SavePageSettingsResult> Handle(SavePageSettingsCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.SitePageSettingsEntities
|
||||||
|
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
{
|
||||||
|
// ایجاد صفحه جدید (فقط برای seed اولیه — صفحات ثابت هستند)
|
||||||
|
entity = new SitePageSettings
|
||||||
|
{
|
||||||
|
PageKey = request.PageKey,
|
||||||
|
};
|
||||||
|
_context.SitePageSettingsEntities.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Title = request.Title;
|
||||||
|
entity.MetaDescription = request.MetaDescription;
|
||||||
|
entity.HeroTitle = request.HeroTitle;
|
||||||
|
entity.HeroSubtitle = request.HeroSubtitle;
|
||||||
|
entity.IsActive = request.IsActive;
|
||||||
|
entity.SettingsJson = request.SettingsJson;
|
||||||
|
|
||||||
|
// آپلود تصویر هیرو
|
||||||
|
if (request.ImageFileBytes is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var result = await _fileManager.UploadImageAsync(
|
||||||
|
"Images/SitePageSettings",
|
||||||
|
request.ImageFileBytes,
|
||||||
|
request.ImageFileMime ?? "image/jpeg",
|
||||||
|
request.ImageFileName,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
entity.HeroImagePath = result.Main.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new SavePageSettingsResult { Id = entity.Id, Success = true };
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
|
||||||
|
|
||||||
|
public class SavePageSettingsCommandValidator : AbstractValidator<SavePageSettingsCommand>
|
||||||
|
{
|
||||||
|
private static readonly string[] ValidPageKeys = { "landing", "about", "contact", "licenses" };
|
||||||
|
|
||||||
|
public SavePageSettingsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PageKey)
|
||||||
|
.NotEmpty().WithMessage("کلید صفحه الزامی است")
|
||||||
|
.MaximumLength(50)
|
||||||
|
.Must(key => ValidPageKeys.Contains(key))
|
||||||
|
.WithMessage("کلید صفحه باید یکی از مقادیر landing, about, contact, licenses باشد");
|
||||||
|
|
||||||
|
RuleFor(x => x.Title)
|
||||||
|
.NotEmpty().WithMessage("عنوان صفحه الزامی است")
|
||||||
|
.MaximumLength(200).WithMessage("عنوان صفحه حداکثر ۲۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.MetaDescription)
|
||||||
|
.MaximumLength(300).WithMessage("توضیحات متا حداکثر ۳۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.HeroTitle)
|
||||||
|
.MaximumLength(200).WithMessage("عنوان هیرو حداکثر ۲۰۰ کاراکتر");
|
||||||
|
|
||||||
|
RuleFor(x => x.HeroSubtitle)
|
||||||
|
.MaximumLength(500).WithMessage("زیرعنوان هیرو حداکثر ۵۰۰ کاراکتر");
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetAllPageSettings;
|
||||||
|
|
||||||
|
// ── Query ──
|
||||||
|
public class GetAllPageSettingsQuery : IRequest<List<PageSettingsSummaryDto>>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──
|
||||||
|
public class GetAllPageSettingsQueryHandler : IRequestHandler<GetAllPageSettingsQuery, List<PageSettingsSummaryDto>>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetAllPageSettingsQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<PageSettingsSummaryDto>> Handle(GetAllPageSettingsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.SitePageSettingsEntities
|
||||||
|
.Where(x => !x.IsDeleted)
|
||||||
|
.OrderBy(x => x.Id)
|
||||||
|
.Select(x => new PageSettingsSummaryDto
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
PageKey = x.PageKey,
|
||||||
|
Title = x.Title,
|
||||||
|
IsActive = x.IsActive,
|
||||||
|
ImageCount = x.Images.Count(i => !i.IsDeleted),
|
||||||
|
LastModified = x.LastModified ?? x.Created
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTO ──
|
||||||
|
public class PageSettingsSummaryDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string PageKey { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int ImageCount { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
|
using CMSMicroservice.Domain.Entities.Content;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetPageSettings;
|
||||||
|
|
||||||
|
// ── Query ──
|
||||||
|
public class GetPageSettingsQuery : IRequest<PageSettingsDto?>
|
||||||
|
{
|
||||||
|
public string PageKey { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ──
|
||||||
|
public class GetPageSettingsQueryHandler : IRequestHandler<GetPageSettingsQuery, PageSettingsDto?>
|
||||||
|
{
|
||||||
|
private readonly IApplicationDbContext _context;
|
||||||
|
|
||||||
|
public GetPageSettingsQueryHandler(IApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PageSettingsDto?> Handle(GetPageSettingsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.SitePageSettingsEntities
|
||||||
|
.Include(x => x.Images.Where(i => !i.IsDeleted && i.IsActive).OrderBy(i => i.SortOrder))
|
||||||
|
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return MapToDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PageSettingsDto MapToDto(SitePageSettings entity) => new()
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
PageKey = entity.PageKey,
|
||||||
|
Title = entity.Title,
|
||||||
|
MetaDescription = entity.MetaDescription,
|
||||||
|
HeroTitle = entity.HeroTitle,
|
||||||
|
HeroSubtitle = entity.HeroSubtitle,
|
||||||
|
HeroImagePath = entity.HeroImagePath,
|
||||||
|
IsActive = entity.IsActive,
|
||||||
|
SettingsJson = entity.SettingsJson,
|
||||||
|
Images = entity.Images.Select(i => new PageImageDto
|
||||||
|
{
|
||||||
|
Id = i.Id,
|
||||||
|
ImageGroup = i.ImageGroup,
|
||||||
|
Title = i.Title,
|
||||||
|
Subtitle = i.Subtitle,
|
||||||
|
Description = i.Description,
|
||||||
|
ImagePath = i.ImagePath,
|
||||||
|
ThumbnailPath = i.ThumbnailPath,
|
||||||
|
LinkUrl = i.LinkUrl,
|
||||||
|
IconName = i.IconName,
|
||||||
|
SortOrder = i.SortOrder,
|
||||||
|
IsActive = i.IsActive
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTOs ──
|
||||||
|
public class PageSettingsDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string PageKey { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? MetaDescription { get; set; }
|
||||||
|
public string? HeroTitle { get; set; }
|
||||||
|
public string? HeroSubtitle { get; set; }
|
||||||
|
public string? HeroImagePath { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string? SettingsJson { get; set; }
|
||||||
|
public List<PageImageDto> Images { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PageImageDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string ImageGroup { get; set; } = string.Empty;
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Subtitle { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string ImagePath { get; set; } = string.Empty;
|
||||||
|
public string? ThumbnailPath { get; set; }
|
||||||
|
public string? LinkUrl { get; set; }
|
||||||
|
public string? IconName { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
|
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Command برای کم کردن موجودی (فروش)
|
/// Command برای کم کردن موجودی (فروش، خسارت، مفقودی و ...)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
|
public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
|
||||||
{
|
{
|
||||||
@@ -19,4 +19,8 @@ public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
|
|||||||
public long? PerformedByUserId { get; init; }
|
public long? PerformedByUserId { get; init; }
|
||||||
/// <summary>آیا از موجودی رزرو شده کم شود؟</summary>
|
/// <summary>آیا از موجودی رزرو شده کم شود؟</summary>
|
||||||
public bool FromReserved { get; init; } = true;
|
public bool FromReserved { get; init; } = true;
|
||||||
|
/// <summary>نوع حرکت انبار (پیشفرض: فروش)</summary>
|
||||||
|
public Domain.Enums.StockMovementType MovementType { get; init; } = Domain.Enums.StockMovementType.Sale;
|
||||||
|
/// <summary>توضیحات</summary>
|
||||||
|
public string? Note { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -53,11 +53,11 @@ public class ReduceInventoryCommandHandler : IRequestHandler<ReduceInventoryComm
|
|||||||
var stockMovement = new StockMovement
|
var stockMovement = new StockMovement
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
InventoryItemId = item.Id,
|
||||||
MovementType = StockMovementType.Sale,
|
MovementType = request.MovementType,
|
||||||
Quantity = request.Quantity,
|
Quantity = -request.Quantity, // منفی — CHECK constraint: QuantityAfter = QuantityBefore + Quantity
|
||||||
QuantityBefore = previousQuantity,
|
QuantityBefore = previousQuantity,
|
||||||
QuantityAfter = item.Quantity,
|
QuantityAfter = item.Quantity,
|
||||||
Note = "Sale confirmed",
|
Note = request.Note ?? request.MovementType.ToString(),
|
||||||
ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}",
|
ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}",
|
||||||
OrderId = request.OrderId,
|
OrderId = request.OrderId,
|
||||||
DiscountOrderId = request.DiscountOrderId,
|
DiscountOrderId = request.DiscountOrderId,
|
||||||
|
|||||||
+6
-6
@@ -108,7 +108,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
wallet.DiscountBalance += manualPayment.Amount;
|
wallet.DiscountBalance += manualPayment.Amount;
|
||||||
|
|
||||||
// لاگ Balance
|
// لاگ Balance
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -122,7 +122,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
// لاگ DiscountBalance
|
// لاگ DiscountBalance
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -139,7 +139,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
case ManualPaymentType.DiscountWalletCharge:
|
case ManualPaymentType.DiscountWalletCharge:
|
||||||
wallet.DiscountBalance += manualPayment.Amount;
|
wallet.DiscountBalance += manualPayment.Amount;
|
||||||
|
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -156,7 +156,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
case ManualPaymentType.NetworkWalletCharge:
|
case ManualPaymentType.NetworkWalletCharge:
|
||||||
wallet.NetworkBalance += manualPayment.Amount;
|
wallet.NetworkBalance += manualPayment.Amount;
|
||||||
|
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -183,7 +183,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
wallet.DiscountBalance -= manualPayment.Amount;
|
wallet.DiscountBalance -= manualPayment.Amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -201,7 +201,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
|
|||||||
// Other یا سایر موارد - فقط Balance
|
// Other یا سایر موارد - فقط Balance
|
||||||
wallet.Balance += manualPayment.Amount;
|
wallet.Balance += manualPayment.Amount;
|
||||||
|
|
||||||
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
|
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
|
|||||||
+6
-1
@@ -14,10 +14,15 @@ public class CreateManualPaymentCommand : IRequest<long>
|
|||||||
public long UserId { get; set; }
|
public long UserId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// مبلغ تراکنش (ریال)
|
/// مبلغ تراکنش (تومان) — [DEPRECATED] مبلغ از پکیج خوانده میشود
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Amount { get; set; }
|
public long Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه پکیج — اگر 0 باشد، پکیج پایه استفاده میشود
|
||||||
|
/// </summary>
|
||||||
|
public long PackageId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نوع تراکنش دستی
|
/// نوع تراکنش دستی
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+62
-10
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
|
||||||
using CMSMicroservice.Application.Common.Exceptions;
|
using CMSMicroservice.Application.Common.Exceptions;
|
||||||
using CMSMicroservice.Application.Common.Interfaces;
|
using CMSMicroservice.Application.Common.Interfaces;
|
||||||
using CMSMicroservice.Domain.Common;
|
using CMSMicroservice.Domain.Common;
|
||||||
@@ -14,15 +15,18 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
|
|||||||
{
|
{
|
||||||
private readonly IApplicationDbContext _context;
|
private readonly IApplicationDbContext _context;
|
||||||
private readonly ICurrentUserService _currentUser;
|
private readonly ICurrentUserService _currentUser;
|
||||||
|
private readonly ISender _sender;
|
||||||
private readonly ILogger<CreateManualPaymentCommandHandler> _logger;
|
private readonly ILogger<CreateManualPaymentCommandHandler> _logger;
|
||||||
|
|
||||||
public CreateManualPaymentCommandHandler(
|
public CreateManualPaymentCommandHandler(
|
||||||
IApplicationDbContext context,
|
IApplicationDbContext context,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
|
ISender sender,
|
||||||
ILogger<CreateManualPaymentCommandHandler> logger)
|
ILogger<CreateManualPaymentCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
|
_sender = sender;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +74,23 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
|
|||||||
throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد");
|
throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. محاسبه مبالغ
|
// 4. بارگذاری پکیج انتخابشده (یا پکیج پایه اگر PackageId=0)
|
||||||
var balanceAmount = SystemConstants.BasePackageAmount; // 56M
|
Domain.Entities.Package package;
|
||||||
var discountBalanceAmount = SystemConstants.BasePackageAmount * 2; // 112M
|
if (request.PackageId > 0)
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"پکیج با شناسه {request.PackageId} یافت نشد");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
package = await _context.Packages
|
||||||
|
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
|
||||||
|
?? throw new NotFoundException("پکیج پایه یافت نشد");
|
||||||
|
}
|
||||||
|
|
||||||
|
var balanceAmount = package.Price;
|
||||||
|
var discountBalanceAmount = (long)(package.Price * package.DiscountMultiplier);
|
||||||
|
|
||||||
|
|
||||||
// 5. ثبت تراکنش
|
// 5. ثبت تراکنش
|
||||||
@@ -112,11 +130,11 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
|
|||||||
var oldBalance = wallet.Balance;
|
var oldBalance = wallet.Balance;
|
||||||
var oldDiscountBalance = wallet.DiscountBalance;
|
var oldDiscountBalance = wallet.DiscountBalance;
|
||||||
|
|
||||||
wallet.Balance += balanceAmount; // +56M
|
wallet.Balance += balanceAmount;
|
||||||
wallet.DiscountBalance += discountBalanceAmount; // +112M
|
wallet.DiscountBalance += discountBalanceAmount;
|
||||||
|
|
||||||
// 8. ثبت لاگ کیف پول
|
// 8. ثبت لاگ کیف پول
|
||||||
var walletLog = new UserWalletChangeLog
|
var walletLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = 0,
|
CurrentBalance = 0,
|
||||||
@@ -126,17 +144,51 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
|
|||||||
CurrentDiscountBalance =0,
|
CurrentDiscountBalance =0,
|
||||||
ChangeDiscountValue = discountBalanceAmount,
|
ChangeDiscountValue = discountBalanceAmount,
|
||||||
IsIncrease = true,
|
IsIncrease = true,
|
||||||
RefrenceId = transaction.Id
|
RefrenceId = transaction.Id,
|
||||||
|
PackageId = package.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
await _context.UserWalletChangeLogs.AddAsync(walletLog, cancellationToken);
|
await _context.UserWalletHistories.AddAsync(walletLog, cancellationToken);
|
||||||
|
|
||||||
// 9. تنظیم روش خرید پکیج
|
// 9. تنظیم روش خرید پکیج + ثبت ledger خرید دستی
|
||||||
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
|
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
|
||||||
|
|
||||||
|
_context.UserPackagePurchases.Add(new UserPackagePurchase
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
PackageId = package.Id,
|
||||||
|
PurchaseMethod = PackagePurchaseMethod.Manual,
|
||||||
|
PurchasedAt = DateTime.Now,
|
||||||
|
Amount = balanceAmount,
|
||||||
|
TransactionId = transaction.Id
|
||||||
|
});
|
||||||
|
|
||||||
// 10. ذخیره همه تغییرات
|
// 10. ذخیره همه تغییرات
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 11. فعالسازی باشگاه مشتریان — ساخت Cycle و آمادهسازی برای AcceptContract
|
||||||
|
// این مرحله تضمین میکند که:
|
||||||
|
// الف) ClubMembershipCycle ساخته شود تا والدین امتیاز «عضو جدید» بگیرند
|
||||||
|
// ب) Pool توسط AcceptContract (که کاربر در FO امضا میکند) شارژ شود
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _sender.Send(new ActivateClubMembershipCommand
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
ForceActivation = true,
|
||||||
|
PackageId = package.Id
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception activateEx)
|
||||||
|
{
|
||||||
|
// عضویت باشگاه اختیاری است — خطا نباید پرداخت دستی را برگرداند
|
||||||
|
_logger.LogWarning(
|
||||||
|
activateEx,
|
||||||
|
"Club membership activation failed after manual payment for UserId {UserId} — payment recorded successfully",
|
||||||
|
request.UserId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Manual membership payment created successfully. " +
|
"Manual membership payment created successfully. " +
|
||||||
"ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " +
|
"ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " +
|
||||||
|
|||||||
+5
-5
@@ -1,3 +1,4 @@
|
|||||||
|
using CMSMicroservice.Domain.Common;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment;
|
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment;
|
||||||
@@ -10,11 +11,10 @@ public class CreateManualPaymentCommandValidator : AbstractValidator<CreateManua
|
|||||||
.GreaterThan(0)
|
.GreaterThan(0)
|
||||||
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
|
||||||
|
|
||||||
RuleFor(x => x.Amount)
|
// مبلغ از پکیج خوانده میشود — فیلد Amount دیگر اعتبارسنجی نمیشود
|
||||||
.GreaterThan(0)
|
RuleFor(x => x.PackageId)
|
||||||
.WithMessage("مبلغ باید بزرگتر از صفر باشد")
|
.GreaterThanOrEqualTo(0)
|
||||||
.LessThanOrEqualTo(1_000_000_000)
|
.WithMessage("شناسه پکیج نامعتبر است");
|
||||||
.WithMessage("مبلغ نمیتواند بیشتر از 1 میلیارد ریال باشد");
|
|
||||||
|
|
||||||
RuleFor(x => x.Type)
|
RuleFor(x => x.Type)
|
||||||
.IsInEnum()
|
.IsInEnum()
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ public record ProcessManualMembershipPaymentCommand : IRequest<ProcessManualMemb
|
|||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// مبلغ پرداختی (ریال)
|
/// مبلغ پرداختی (تومان)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Amount { get; init; }
|
public long Amount { get; init; }
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -104,7 +104,7 @@ public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<Proc
|
|||||||
wallet.DiscountBalance += request.Amount;
|
wallet.DiscountBalance += request.Amount;
|
||||||
|
|
||||||
// 8. ثبت لاگ Balance
|
// 8. ثبت لاگ Balance
|
||||||
var balanceLog = new UserWalletChangeLog
|
var balanceLog = new UserWalletHistory
|
||||||
{
|
{
|
||||||
WalletId = wallet.Id,
|
WalletId = wallet.Id,
|
||||||
CurrentBalance = wallet.Balance,
|
CurrentBalance = wallet.Balance,
|
||||||
@@ -116,9 +116,9 @@ public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<Proc
|
|||||||
IsIncrease = true,
|
IsIncrease = true,
|
||||||
RefrenceId = transaction.Id
|
RefrenceId = transaction.Id
|
||||||
};
|
};
|
||||||
await _context.UserWalletChangeLogs.AddAsync(balanceLog, cancellationToken);
|
await _context.UserWalletHistories.AddAsync(balanceLog, cancellationToken);
|
||||||
|
|
||||||
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
|
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
|
||||||
// 10. بهروزرسانی ManualPayment با TransactionId
|
// 10. بهروزرسانی ManualPayment با TransactionId
|
||||||
manualPayment.TransactionId = transaction.Id;
|
manualPayment.TransactionId = transaction.Id;
|
||||||
await _context.SaveChangesAsync(cancellationToken);
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
+48
@@ -69,6 +69,14 @@ public class GetAllManualPaymentsQueryHandler
|
|||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
var skip = (request.PageNumber - 1) * request.PageSize;
|
var skip = (request.PageNumber - 1) * request.PageSize;
|
||||||
|
|
||||||
|
// دریافت PackageName پیشفرض از آخرین خرید پکیج کاربر (fallback برای دادههای قدیمی)
|
||||||
|
var userPackageNames = await _context.UserPackagePurchases
|
||||||
|
.Include(p => p.Package)
|
||||||
|
.GroupBy(p => p.UserId)
|
||||||
|
.Select(g => new { UserId = g.Key, PackageName = g.OrderByDescending(x => x.PurchasedAt).First().Package.Title })
|
||||||
|
.ToDictionaryAsync(x => x.UserId, x => x.PackageName, cancellationToken);
|
||||||
|
|
||||||
var data = await query
|
var data = await query
|
||||||
.Skip(skip)
|
.Skip(skip)
|
||||||
.Take(request.PageSize)
|
.Take(request.PageSize)
|
||||||
@@ -96,6 +104,46 @@ public class GetAllManualPaymentsQueryHandler
|
|||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// PackageName دقیق هر پرداخت دستی از UserWalletHistories بر اساس TransactionId همان پرداخت
|
||||||
|
var transactionIds = data
|
||||||
|
.Where(x => x.TransactionId.HasValue)
|
||||||
|
.Select(x => x.TransactionId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var packageNamesByTransaction = new Dictionary<long, string>();
|
||||||
|
if (transactionIds.Count > 0)
|
||||||
|
{
|
||||||
|
packageNamesByTransaction = await _context.UserWalletHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(h => h.Package)
|
||||||
|
.Where(h => h.RefrenceId.HasValue
|
||||||
|
&& transactionIds.Contains(h.RefrenceId.Value)
|
||||||
|
&& h.PackageId.HasValue)
|
||||||
|
.GroupBy(h => h.RefrenceId!.Value)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
TransactionId = g.Key,
|
||||||
|
PackageName = g.OrderByDescending(x => x.Created)
|
||||||
|
.Select(x => x.Package != null ? x.Package.Title : string.Empty)
|
||||||
|
.FirstOrDefault()
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(x => x.TransactionId, x => x.PackageName ?? string.Empty, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// تکمیل PackageName
|
||||||
|
foreach (var item in data)
|
||||||
|
{
|
||||||
|
if (item.TransactionId.HasValue && packageNamesByTransaction.TryGetValue(item.TransactionId.Value, out var txPkgName))
|
||||||
|
{
|
||||||
|
item.PackageName = txPkgName;
|
||||||
|
}
|
||||||
|
else if (userPackageNames.TryGetValue(item.UserId, out var pkgName))
|
||||||
|
{
|
||||||
|
item.PackageName = pkgName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var metaData = new MetaData
|
var metaData = new MetaData
|
||||||
{
|
{
|
||||||
TotalCount = totalCount,
|
TotalCount = totalCount,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user