7 Commits

Author SHA1 Message Date
masoodafar-web 1a425f0d93 chore: Update production workflow to use Nexus registry (no proxy needed)
Build and Deploy to Production / build-and-deploy (push) Failing after 0s
- Changed Dockerfile base images to use 194.5.195.53:32082 (Nexus)
- Updated workflow to use docker-sshpass image from Nexus
- Removed HTTP_PROXY/HTTPS_PROXY dependencies
- Changed deploy method to SSH instead of kubeconfig
- Registry: 194.5.195.53:30080 (Gitea internal)
- K8S Server: 45.149.79.127 (Production)
2026-01-29 22:04:31 +03:30
masoodafar-web 6f8aefc2ce Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 1m40s
2025-12-29 00:36:51 +03:30
masoodafar-web 2bd5c7db78 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 1m50s
2025-12-27 07:37:30 +03:30
masoodafar-web 0728ec2b76 feat: add background job configuration for weekly commission calculation
Build and Deploy to Production / build-and-deploy (push) Successful in 3m18s
2025-12-27 01:51:32 +03:30
masoodafar-web 1425fb187b Merge branch 'kub-stage' into production
# Conflicts:
#	src/CMSMicroservice.WebApi/Program.cs
2025-12-27 01:49:11 +03:30
masoodafar-web 81d2b39ee1 fix: update Seq ServerUrl and change Hangfire job timezone to local - Adjusted Seq server URL for production and modified recurring job options to use local timezone 2025-12-27 01:14:31 +03:30
masoodafar-web fce194a195 feat: update connection strings and add production configuration - Modified database connection details and created appsettings.Production.json for production environment
Build and Deploy to Production / build-and-deploy (push) Successful in 4m2s
2025-12-27 00:16:36 +03:30
447 changed files with 5133 additions and 33962 deletions
+48 -51
View File
@@ -6,84 +6,81 @@ on:
- kub-stage
env:
REGISTRY: 194.5.195.53:30080
REGISTRY: git.foursat.afrino.co
IMAGE_NAME: admin/cms
K8S_SERVER: 194.5.195.53
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
image: docker:latest
options: --privileged
env:
HTTP_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
HTTPS_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
NO_PROXY: localhost,127.0.0.1,gitea-svc,194.5.195.53,10.0.0.0/8
steps:
- name: Start Docker daemon
- name: Install dependencies
run: |
apk add --no-cache git curl
# Install kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Start Docker daemon with insecure registry
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
"insecure-registries": ["git.foursat.afrino.co", "gitea-svc:3000"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
mkdir -p ~/.docker
cat > ~/.docker/config.json << 'CONF'
{
"proxies": {
"default": {
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"noProxy": "localhost,127.0.0.1,gitea-svc,194.5.195.53,10.0.0.0/8"
}
}
}
CONF
dockerd &
# Wait up to 3 minutes for Docker to be ready
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
done
# Final check
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
docker info
- name: Checkout code
run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git .
- name: Publish Protobuf packages
run: |
echo "📦 Publishing Protobuf packages..."
docker run --rm -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj"
dotnet restore "$proj"
dotnet build "$proj" -c Release --no-restore
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
--api-key "admin:87zH26nbqT" \
--skip-duplicate --allow-insecure-connections || true
done
done
'
echo "✅ Protobuf packages done!"
git log -1 --format="%H %s"
- name: Build Docker Image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
.
- name: Push to Registry
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 }}:latest
- name: Deploy to Kubernetes
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl rollout restart deployment/cms
kubectl rollout status deployment/cms --timeout=180s
"
echo "Deployed!"
# Setup kubeconfig
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
# Restart deployment to pull new image
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
# Wait for rollout to complete
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
+42 -44
View File
@@ -8,65 +8,70 @@ on:
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms
K8S_SERVER: 45.149.79.127
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: docker:latest
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
env:
HTTP_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
HTTPS_PROXY: http://proxyuser:87zH26nbqT2@46.249.98.211:3128
NO_PROXY: localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8
steps:
- name: Install dependencies
run: |
apk add --no-cache git curl
# Install kubectl with fixed version
KUBECTL_VERSION="v1.31.0"
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Start Docker daemon with insecure registry
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082", "gitea-svc:3000"]
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32082"]
}
DAEMON
mkdir -p ~/.docker
cat > ~/.docker/config.json << 'CONF'
{
"proxies": {
"default": {
"httpProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"httpsProxy": "http://proxyuser:87zH26nbqT2@46.249.98.211:3128",
"noProxy": "localhost,127.0.0.1,gitea-svc,45.149.79.127,10.0.0.0/8"
}
}
}
CONF
echo "🚀 Starting Docker daemon..."
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
docker info
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start"
exit 1
fi
- name: Checkout code
run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
git log -1 --format="%H %s"
- name: Publish Protobuf packages
run: |
echo "📦 Publishing Protobuf packages..."
docker run --rm -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj"
dotnet restore "$proj"
dotnet build "$proj" -c Release --no-restore
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \
--api-key "admin:87zH26nbqT" \
--skip-duplicate --allow-insecure-connections || true
done
done
'
echo "✅ Protobuf packages done!"
- name: Build Docker Image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
--build-arg HTTP_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
--build-arg HTTPS_PROXY=http://proxyuser:87zH26nbqT2@46.249.98.211:3128 \
.
- name: Push to Registry
@@ -77,12 +82,5 @@ jobs:
- name: Deploy to Production
run: |
# Setup kubeconfig for PRODUCTION
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_PROD }}" | base64 -d > ~/.kube/config
# Restart deployment to pull new image
kubectl rollout restart deployment/cms || echo "Deployment doesn't exist yet"
# Wait for rollout to complete
kubectl rollout status deployment/cms --timeout=5m || echo "Deployment rollout pending"
sshpass -p "${{ secrets.K8S_SSH_PASSWORD }}" ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} \
"kubectl rollout restart deployment/cms && kubectl rollout status deployment/cms --timeout=5m" || echo "Deployment pending"
+1 -1
View File
@@ -7,7 +7,7 @@ COPY src/NuGet.config ./
# Copy solution and project files
COPY src/ ./
# Restore with Nexus config and publish
# Restore and publish
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config
RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
+23 -209
View File
@@ -1,93 +1,39 @@
# CMS Microservice - Network & Club Commission + Inventory Management System
# CMS Microservice - Network & Club Commission System
[![Status](https://img.shields.io/badge/Status-Active%20Development-success)]()
[![Progress](https://img.shields.io/badge/Inventory%20System-Phase%202%20Complete-blue)]()
[![Phase](https://img.shields.io/badge/Next-Business%20Services-orange)]()
[![Status](https://img.shields.io/badge/Status-Production%20Ready-success)]()
[![Progress](https://img.shields.io/badge/Progress-85%25-blue)]()
[![MVP](https://img.shields.io/badge/MVP-100%25%20Complete-brightgreen)]()
## 📊 Project Status (January 2026)
## 📊 Project Status (2025-12-01)
### 🏪 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
**Overall Progress**: 85% Complete (7/10 phases)
**Production Readiness**: 95%
**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)
### ✅ Completed Phases (7)
1.Domain Layer (Entities, Enums, Value Objects)
2. ✅ Club Membership System
3.Binary Network Tree
4. **Commission Calculation & Background Worker** (MVP)
5.Protobuf gRPC Services
6. ✅ History & Configuration Management
7. ✅ Database Migration & Seed Data
### 🟡 Partially Complete
### 🟡 Partially Complete (1)
- Phase 10: Withdrawal & Settlement (40%)
- ✅ Commands & Database
- ❌ Payment Gateway Integration
### ❌ Not Started
### ❌ Not Started (1)
- 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
### ⏸️ Postponed (1)
- Phase 7: Testing (Unit, Integration, Load tests)
---
## 🚀 Recent Updates (2025-12-01)
### Email & SMS Notifications - COMPLETED ✅
-**MailKit 4.14.1** for Email (SMTP with HTML templates)
-**Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
@@ -117,38 +63,8 @@
**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
```
@@ -168,7 +84,6 @@ CMSMicroservice.Protobuf/ # Protocol Buffers definitions
## 📖 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
@@ -177,74 +92,6 @@ CMSMicroservice.Protobuf/ # Protocol Buffers definitions
---
## 🏪 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
@@ -350,25 +197,7 @@ 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
### High Priority
1. **Payment Gateway Integration** (Phase 10 - 1 week)
- Daya or Bank Mellat API integration
- IBAN transfer automation
@@ -401,7 +230,6 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
## 🎯 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)
@@ -416,26 +244,12 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
✅ 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
**Last Updated**: 2025-12-01
---
-1
View File
@@ -1 +0,0 @@
Docs moved to /totalDoc — see totalDoc/INDEX.md
+490
View File
@@ -0,0 +1,490 @@
# Club Feature Management Services - Implementation Guide
## Overview
Admin services for managing user club features (enable/disable features per user).
## Created Files
### 1. CQRS Layer (Application)
#### Query: GetUserClubFeatures
**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Queries/GetUserClubFeatures/`
**Files:**
- `GetUserClubFeaturesQuery.cs` - Query definition
- `GetUserClubFeaturesQueryHandler.cs` - Query handler
- `UserClubFeatureDto.cs` - Response DTO
**Purpose:** Get list of all club features for a specific user with their active status.
**Input:**
```csharp
public record GetUserClubFeaturesQuery : IRequest<List<UserClubFeatureDto>>
{
public long UserId { get; init; }
}
```
**Output:**
```csharp
public class UserClubFeatureDto
{
public long Id { get; set; }
public long UserId { get; set; }
public long ClubMembershipId { get; set; }
public long ClubFeatureId { get; set; }
public string FeatureTitle { get; set; }
public string? FeatureDescription { get; set; }
public bool IsActive { get; set; }
public DateTime GrantedAt { get; set; }
public string? Notes { get; set; }
}
```
**Logic:**
- Joins `UserClubFeatures` with `ClubFeature` table
- Filters by `UserId` and `!IsDeleted`
- Returns list of features with their active status
---
#### Command: ToggleUserClubFeature
**Location:** `/CMS/src/CMSMicroservice.Application/ClubFeatureCQ/Commands/ToggleUserClubFeature/`
**Files:**
- `ToggleUserClubFeatureCommand.cs` - Command definition
- `ToggleUserClubFeatureCommandHandler.cs` - Command handler
- `ToggleUserClubFeatureResponse.cs` - Response DTO
**Purpose:** Enable or disable a specific club feature for a user.
**Input:**
```csharp
public record ToggleUserClubFeatureCommand : IRequest<ToggleUserClubFeatureResponse>
{
public long UserId { get; init; }
public long ClubFeatureId { get; init; }
public bool IsActive { get; init; }
}
```
**Output:**
```csharp
public class ToggleUserClubFeatureResponse
{
public bool Success { get; set; }
public string Message { get; set; }
public long? UserClubFeatureId { get; set; }
public bool? IsActive { get; set; }
}
```
**Validations:**
1. ✅ User exists and not deleted
2. ✅ Club feature exists and not deleted
3. ✅ User has this feature assigned (exists in UserClubFeatures)
**Logic:**
- Find `UserClubFeature` record by `UserId` + `ClubFeatureId`
- Update `IsActive` field
- Set `LastModified` timestamp
- Save changes
**Error Messages:**
- "کاربر یافت نشد" - User not found
- "ویژگی باشگاه یافت نشد" - Club feature not found
- "این ویژگی برای کاربر یافت نشد" - User doesn't have this feature
**Success Messages:**
- "ویژگی با موفقیت فعال شد" - Feature activated successfully
- "ویژگی با موفقیت غیرفعال شد" - Feature deactivated successfully
---
### 2. gRPC Layer (Protobuf + WebApi)
#### Proto Definition
**File:** `/CMS/src/CMSMicroservice.Protobuf/Protos/clubmembership.proto`
**Added RPC Methods:**
```protobuf
rpc GetUserClubFeatures(GetUserClubFeaturesRequest) returns (GetUserClubFeaturesResponse){
option (google.api.http) = {
get: "/ClubFeature/GetUserFeatures"
};
};
rpc ToggleUserClubFeature(ToggleUserClubFeatureRequest) returns (ToggleUserClubFeatureResponse){
option (google.api.http) = {
post: "/ClubFeature/ToggleFeature"
body: "*"
};
};
```
**Message Definitions:**
```protobuf
message GetUserClubFeaturesRequest {
int64 user_id = 1;
}
message GetUserClubFeaturesResponse {
repeated UserClubFeatureModel features = 1;
}
message UserClubFeatureModel {
int64 id = 1;
int64 user_id = 2;
int64 club_membership_id = 3;
int64 club_feature_id = 4;
string feature_title = 5;
string feature_description = 6;
bool is_active = 7;
google.protobuf.Timestamp granted_at = 8;
string notes = 9;
}
message ToggleUserClubFeatureRequest {
int64 user_id = 1;
int64 club_feature_id = 2;
bool is_active = 3;
}
message ToggleUserClubFeatureResponse {
bool success = 1;
string message = 2;
google.protobuf.Int64Value user_club_feature_id = 3;
google.protobuf.BoolValue is_active = 4;
}
```
---
#### gRPC Service Implementation
**File:** `/CMS/src/CMSMicroservice.WebApi/Services/ClubMembershipService.cs`
**Added Methods:**
```csharp
public override async Task<GetUserClubFeaturesResponse> GetUserClubFeatures(
GetUserClubFeaturesRequest request,
ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<
GetUserClubFeaturesRequest,
GetUserClubFeaturesQuery,
GetUserClubFeaturesResponse>(request, context);
}
public override async Task<Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>
ToggleUserClubFeature(
ToggleUserClubFeatureRequest request,
ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<
ToggleUserClubFeatureRequest,
ToggleUserClubFeatureCommand,
Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>(request, context);
}
```
---
#### AutoMapper Profile
**File:** `/CMS/src/CMSMicroservice.WebApi/Common/Mappings/ClubFeatureProfile.cs`
**Mappings:**
1. `GetUserClubFeaturesRequest``GetUserClubFeaturesQuery`
2. `UserClubFeatureDto``UserClubFeatureModel` (Proto)
3. `List<UserClubFeatureDto>``GetUserClubFeaturesResponse`
4. `ToggleUserClubFeatureRequest``ToggleUserClubFeatureCommand`
5. `ToggleUserClubFeatureResponse` (App) → `ToggleUserClubFeatureResponse` (Proto)
**Special Handling:**
- DateTime conversion to `Timestamp` (Protobuf format)
- Null-safe mapping for optional fields
- Fully qualified type names to avoid ambiguity
---
## API Endpoints
### 1. Get User Club Features
**Method:** GET
**Endpoint:** `/ClubFeature/GetUserFeatures`
**Request:**
```json
{
"user_id": 123
}
```
**Response:**
```json
{
"features": [
{
"id": 1,
"user_id": 123,
"club_membership_id": 456,
"club_feature_id": 1,
"feature_title": "دسترسی به فروشگاه تخفیف",
"feature_description": "امکان خرید از فروشگاه تخفیف",
"is_active": true,
"granted_at": "2025-12-09T18:30:00Z",
"notes": "اعطا شده به‌طور خودکار هنگام فعالسازی"
}
]
}
```
---
### 2. Toggle User Club Feature
**Method:** POST
**Endpoint:** `/ClubFeature/ToggleFeature`
**Request:**
```json
{
"user_id": 123,
"club_feature_id": 1,
"is_active": false
}
```
**Response (Success):**
```json
{
"success": true,
"message": "ویژگی با موفقیت غیرفعال شد",
"user_club_feature_id": 1,
"is_active": false
}
```
**Response (Error - User Not Found):**
```json
{
"success": false,
"message": "کاربر یافت نشد"
}
```
**Response (Error - Feature Not Found):**
```json
{
"success": false,
"message": "ویژگی باشگاه یافت نشد"
}
```
**Response (Error - User Doesn't Have Feature):**
```json
{
"success": false,
"message": "این ویژگی برای کاربر یافت نشد"
}
```
---
## Database Schema
### Table: UserClubFeatures
Existing table with newly added `IsActive` field:
```sql
CREATE TABLE [CMS].[UserClubFeatures]
(
[Id] BIGINT IDENTITY(1,1) PRIMARY KEY,
[UserId] BIGINT NOT NULL,
[ClubMembershipId] BIGINT NOT NULL,
[ClubFeatureId] BIGINT NOT NULL,
[GrantedAt] DATETIME2 NOT NULL,
[IsActive] BIT NOT NULL DEFAULT 1, -- ← NEW FIELD
[Notes] NVARCHAR(MAX) NULL,
[Created] DATETIME2 NOT NULL,
[CreatedBy] NVARCHAR(MAX) NULL,
[LastModified] DATETIME2 NULL,
[LastModifiedBy] NVARCHAR(MAX) NULL,
[IsDeleted] BIT NOT NULL DEFAULT 0,
CONSTRAINT FK_UserClubFeatures_Users FOREIGN KEY ([UserId])
REFERENCES [Identity].[Users]([Id]),
CONSTRAINT FK_UserClubFeatures_ClubMembership FOREIGN KEY ([ClubMembershipId])
REFERENCES [CMS].[ClubMembership]([Id]),
CONSTRAINT FK_UserClubFeatures_ClubFeatures FOREIGN KEY ([ClubFeatureId])
REFERENCES [CMS].[ClubFeatures]([Id])
);
```
---
## Usage Examples
### Admin Panel Scenario
#### 1. View User's Club Features
```csharp
// Admin selects user ID: 123
var request = new GetUserClubFeaturesRequest { UserId = 123 };
var response = await client.GetUserClubFeaturesAsync(request);
// Display in grid:
foreach (var feature in response.Features)
{
Console.WriteLine($"Feature: {feature.FeatureTitle}");
Console.WriteLine($"Status: {(feature.IsActive ? "فعال" : "غیرفعال")}");
Console.WriteLine($"Granted: {feature.GrantedAt}");
Console.WriteLine("---");
}
```
**Output:**
```
Feature: دسترسی به فروشگاه تخفیف
Status: فعال
Granted: 2025-12-09 18:30:00
---
Feature: دسترسی به کمیسیون هفتگی
Status: فعال
Granted: 2025-12-09 18:30:00
---
Feature: دسترسی به شارژ شبکه
Status: غیرفعال
Granted: 2025-12-09 18:30:00
---
```
---
#### 2. Disable a Feature
```csharp
// Admin clicks "Disable" on Feature ID: 3
var request = new ToggleUserClubFeatureRequest
{
UserId = 123,
ClubFeatureId = 3,
IsActive = false
};
var response = await client.ToggleUserClubFeatureAsync(request);
if (response.Success)
{
Console.WriteLine(response.Message);
// Output: ویژگی با موفقیت غیرفعال شد
}
```
---
#### 3. Re-enable a Feature
```csharp
// Admin clicks "Enable" on Feature ID: 3
var request = new ToggleUserClubFeatureRequest
{
UserId = 123,
ClubFeatureId = 3,
IsActive = true
};
var response = await client.ToggleUserClubFeatureAsync(request);
if (response.Success)
{
Console.WriteLine(response.Message);
// Output: ویژگی با موفقیت فعال شد
}
```
---
## Testing Checklist
### Unit Tests (Recommended)
- [ ] GetUserClubFeaturesQueryHandler returns correct DTOs
- [ ] ToggleUserClubFeatureCommandHandler validates user exists
- [ ] ToggleUserClubFeatureCommandHandler validates feature exists
- [ ] ToggleUserClubFeatureCommandHandler validates user has feature
- [ ] ToggleUserClubFeatureCommandHandler updates IsActive correctly
- [ ] ToggleUserClubFeatureCommandHandler sets LastModified timestamp
### Integration Tests
- [ ] gRPC GetUserClubFeatures endpoint returns data
- [ ] gRPC ToggleUserClubFeature endpoint updates database
- [ ] AutoMapper mappings work correctly
- [ ] Proto serialization/deserialization works
### Manual Testing
1. **Get Features:**
```bash
grpcurl -d '{"user_id": 123}' \
-plaintext localhost:5000 \
clubmembership.ClubMembershipContract/GetUserClubFeatures
```
2. **Disable Feature:**
```bash
grpcurl -d '{"user_id": 123, "club_feature_id": 1, "is_active": false}' \
-plaintext localhost:5000 \
clubmembership.ClubMembershipContract/ToggleUserClubFeature
```
3. **Verify in Database:**
```sql
SELECT Id, UserId, ClubFeatureId, IsActive, LastModified
FROM CMS.UserClubFeatures
WHERE UserId = 123;
```
---
## Build Status
✅ **All projects build successfully**
- CMSMicroservice.Domain: ✅
- CMSMicroservice.Application: ✅ (0 errors, 274 warnings)
- CMSMicroservice.Protobuf: ✅
- CMSMicroservice.WebApi: ✅ (0 errors, 17 warnings)
---
## Next Steps (Optional Enhancements)
1. **Authorization:**
- Add `[Authorize(Roles = "Admin")]` attribute
- Validate admin permissions before toggling
2. **Audit Logging:**
- Log who changed the feature status
- Track `LastModifiedBy` field
3. **Bulk Operations:**
- Add endpoint to toggle multiple features at once
- Add endpoint to enable/disable all features for a user
4. **History Tracking:**
- Create `UserClubFeatureHistory` table
- Log every status change with timestamp and reason
5. **Notifications:**
- Send notification to user when feature is disabled
- Email/SMS alert for important features
6. **Business Rules:**
- Add validation: prevent disabling critical features
- Add expiration dates for features
- Add feature dependencies (e.g., Feature B requires Feature A)
---
## Summary
✅ Created CQRS Query + Command for club feature management
✅ Created gRPC Proto definitions and services
✅ Created AutoMapper mappings
✅ All builds successful
✅ Ready for deployment and testing
**Total Files Created:** 8
**Total Lines of Code:** ~350
**Build Errors:** 0
**Status:** ✅ Complete and ready for use
@@ -7,8 +7,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
@@ -3,18 +3,14 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<GetClubMembershipQueryHandler> _logger;
public GetClubMembershipQueryHandler(IApplicationDbContext context, ILogger<GetClubMembershipQueryHandler> logger)
public GetClubMembershipQueryHandler(IApplicationDbContext context)
{
_context = context;
_logger = logger;
}
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
{
_logger.LogInformation("GetClubMembership called for UserId: {UserId}", request.UserId);
var membership = await _context.ClubMemberships
.AsNoTracking()
.Where(x => x.UserId == request.UserId)
@@ -31,26 +27,6 @@ public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQu
})
.FirstOrDefaultAsync(cancellationToken);
// اگر کاربر عضویت نداره، یک DTO با وضعیت غیرفعال برگردون
if (membership == null)
{
_logger.LogInformation("No membership found for UserId: {UserId}, returning inactive status", request.UserId);
return new ClubMembershipDto
{
Id = 0,
UserId = request.UserId,
IsActive = false,
ActivatedAt = null,
InitialContribution = 0,
TotalEarned = 0,
Created = DateTimeOffset.UtcNow,
LastModified = null
};
}
_logger.LogInformation("Membership found for UserId: {UserId}, IsActive: {IsActive}, Id: {Id}",
request.UserId, membership.IsActive, membership.Id);
return membership;
}
}
@@ -1,25 +0,0 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
/// <summary>
/// Query برای دریافت پرداخت‌های کمیسیون کاربر جاری (از JWT)
/// </summary>
public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsResponseDto>
{
/// <summary>
/// فیلتر وضعیت
/// </summary>
public CommissionPayoutStatus? Status { get; init; }
/// <summary>
/// شماره هفته (اختیاری)
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}
@@ -1,73 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Extensions;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissionPayoutsQuery, GetMyCommissionPayoutsResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public GetMyCommissionPayoutsQueryHandler(
IApplicationDbContext context,
ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<GetMyCommissionPayoutsResponseDto> Handle(GetMyCommissionPayoutsQuery request, CancellationToken cancellationToken)
{
// دریافت UserId از JWT (فقط برای Customer API)
if (!long.TryParse(_currentUser.UserId, out var userId))
{
throw new UnauthorizedAccessException("User not authenticated");
}
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Where(x => x.UserId == userId)
.AsNoTracking()
.AsQueryable();
// فیلترها
if (request.Status.HasValue)
{
query = query.Where(x => x.Status == request.Status.Value);
}
if (request.WeekDefinitionId.HasValue)
{
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
// مرتب‌سازی: جدیدترین اول
query = query.OrderByDescending(x => x.Created);
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetMyCommissionPayoutsResponseModel
{
Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
BalancesEarned = x.BalancesEarned,
TotalAmount = x.TotalAmount,
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
Status = x.Status,
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
DatePersian = ""
})
.ToListAsync(cancellationToken);
return new GetMyCommissionPayoutsResponseDto
{
MetaData = meta,
Models = models
};
}
}
@@ -1,25 +0,0 @@
using FluentValidation;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsQueryValidator : AbstractValidator<GetMyCommissionPayoutsQuery>
{
public GetMyCommissionPayoutsQueryValidator()
{
RuleFor(x => x.PaginationState)
.NotNull()
.WithMessage("Pagination state is required");
When(x => x.PaginationState != null, () =>
{
RuleFor(x => x.PaginationState!.PageNumber)
.GreaterThan(0)
.WithMessage("Page number must be greater than 0");
RuleFor(x => x.PaginationState!.PageSize)
.GreaterThan(0)
.LessThanOrEqualTo(100)
.WithMessage("Page size must be between 1 and 100");
});
}
}
@@ -1,23 +0,0 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyCommissionPayouts;
public class GetMyCommissionPayoutsResponseDto
{
public MetaData? MetaData { get; set; }
public List<GetMyCommissionPayoutsResponseModel> Models { get; set; } = new();
}
public class GetMyCommissionPayoutsResponseModel
{
public long Id { get; set; }
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public int BalancesEarned { get; set; }
public long TotalAmount { get; set; }
public string AmountFormatted { get; set; } = string.Empty;
public CommissionPayoutStatus Status { get; set; }
public DateTime? CalculatedDate { get; set; }
public string DatePersian { get; set; } = string.Empty;
}
@@ -1,24 +0,0 @@
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
/// <summary>
/// Query برای دریافت تعادل‌های هفتگی کاربر جاری (از JWT)
/// </summary>
public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseDto>
{
/// <summary>
/// شناسه تعریف هفته (اختیاری)
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
/// </summary>
public bool OnlyActive { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}
@@ -1,47 +0,0 @@
using CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetMyWeeklyBalances;
/// <summary>
/// Handler برای دریافت تعادل‌های هفتگی کاربر جاری
/// </summary>
public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto>
{
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
private readonly ILogger<GetMyWeeklyBalancesQueryHandler> _logger;
public GetMyWeeklyBalancesQueryHandler(
ICurrentUserService currentUserService,
IMediator mediator,
ILogger<GetMyWeeklyBalancesQueryHandler> logger)
{
_currentUserService = currentUserService;
_mediator = mediator;
_logger = logger;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetMyWeeklyBalancesQuery request, CancellationToken cancellationToken)
{
// دریافت UserId از JWT
if (!long.TryParse(_currentUserService.UserId, out var userId) || userId <= 0)
{
_logger.LogWarning("GetMyWeeklyBalances called without valid user authentication");
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
}
_logger.LogInformation("GetMyWeeklyBalances for UserId: {UserId}, WeekDefinitionId: {WeekDefinitionId}",
userId, request.WeekDefinitionId);
// فراخوانی GetUserWeeklyBalancesQuery با UserId از JWT
var query = new GetUserWeeklyBalancesQuery
{
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
OnlyActive = request.OnlyActive,
PaginationState = request.PaginationState
};
return await _mediator.Send(query, cancellationToken);
}
}
@@ -4,16 +4,13 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserCommissionPayoutsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
}
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
@@ -24,20 +21,10 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
.AsNoTracking()
.AsQueryable();
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0)
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == userId.Value);
query = query.Where(x => x.UserId == request.UserId.Value);
}
if (request.Status.HasValue)
@@ -4,16 +4,13 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserWeeklyBalancesQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
}
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
@@ -24,20 +21,10 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
.AsNoTracking()
.AsQueryable();
// اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{
if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0)
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == userId.Value);
query = query.Where(x => x.UserId == request.UserId.Value);
}
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
@@ -1,17 +0,0 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// سرویس بررسی مجوز کاربر بر اساس نقش‌های JWT
/// </summary>
public interface IPermissionService
{
/// <summary>
/// دریافت نقش‌های کاربر فعلی از JWT Claims
/// </summary>
Task<IReadOnlyList<string>> GetUserRolesAsync(CancellationToken cancellationToken);
/// <summary>
/// بررسی اینکه آیا کاربر فعلی مجوز مشخصی دارد
/// </summary>
Task<bool> HasPermissionAsync(string permission, CancellationToken cancellationToken);
}
@@ -1,127 +0,0 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// ثوابت نام مجوزها — دسته‌بندی شده بر اساس حوزه
/// </summary>
public static class PermissionNames
{
// Dashboard
public const string DashboardView = "dashboard.view";
// Orders
public const string OrdersView = "orders.view";
public const string OrdersCreate = "orders.create";
public const string OrdersUpdate = "orders.update";
public const string OrdersDelete = "orders.delete";
public const string OrdersCancel = "orders.cancel";
public const string OrdersApprove = "orders.approve";
// Products
public const string ProductsView = "products.view";
public const string ProductsCreate = "products.create";
public const string ProductsUpdate = "products.update";
public const string ProductsDelete = "products.delete";
// Users
public const string UsersView = "users.view";
public const string UsersUpdate = "users.update";
public const string UsersDelete = "users.delete";
// Commission
public const string CommissionView = "commission.view";
public const string CommissionApproveWithdrawal = "commission.approve_withdrawal";
// Public Messages
public const string PublicMessagesView = "publicmessages.view";
public const string PublicMessagesCreate = "publicmessages.create";
public const string PublicMessagesUpdate = "publicmessages.update";
public const string PublicMessagesPublish = "publicmessages.publish";
// Manual Payments
public const string ManualPaymentsView = "manualpayments.view";
public const string ManualPaymentsCreate = "manualpayments.create";
public const string ManualPaymentsApprove = "manualpayments.approve";
// Settings
public const string SettingsView = "settings.view";
public const string SettingsUpdate = "settings.update";
public const string SettingsDelete = "settings.delete";
public const string SettingsManageConfiguration = "settings.manage_configuration";
public const string SettingsManageVat = "settings.manage_vat";
// Reports
public const string ReportsView = "reports.view";
}
/// <summary>
/// نام نقش‌ها
/// </summary>
public static class RoleNames
{
public const string SuperAdmin = "Administrator";
public const string Admin = "Admin";
public const string Inspector = "Inspector";
}
/// <summary>
/// تنظیمات نقش→مجوز — ماتریس دسترسی
/// </summary>
public static class RolePermissionConfig
{
private static readonly Dictionary<string, HashSet<string>> RolePermissions = new(StringComparer.OrdinalIgnoreCase)
{
[RoleNames.SuperAdmin] = new(StringComparer.OrdinalIgnoreCase) { "*" }, // Full access
[RoleNames.Admin] = new(StringComparer.OrdinalIgnoreCase)
{
PermissionNames.DashboardView,
PermissionNames.OrdersView,
PermissionNames.OrdersCreate,
PermissionNames.OrdersUpdate,
PermissionNames.OrdersCancel,
PermissionNames.ProductsView,
PermissionNames.ProductsCreate,
PermissionNames.ProductsUpdate,
PermissionNames.ProductsDelete,
PermissionNames.UsersView,
PermissionNames.UsersUpdate,
PermissionNames.CommissionView,
PermissionNames.CommissionApproveWithdrawal,
PermissionNames.PublicMessagesView,
PermissionNames.PublicMessagesCreate,
PermissionNames.PublicMessagesUpdate,
PermissionNames.PublicMessagesPublish,
PermissionNames.ManualPaymentsView,
PermissionNames.ManualPaymentsCreate,
PermissionNames.ReportsView
},
[RoleNames.Inspector] = new(StringComparer.OrdinalIgnoreCase)
{
PermissionNames.DashboardView,
PermissionNames.OrdersView,
PermissionNames.UsersView,
PermissionNames.CommissionView,
PermissionNames.PublicMessagesView,
PermissionNames.ReportsView
}
};
/// <summary>
/// بررسی اینکه آیا نقش مشخصی مجوز خاصی دارد
/// </summary>
public static bool HasPermission(string role, string permission)
{
if (string.IsNullOrWhiteSpace(role) || string.IsNullOrWhiteSpace(permission))
return false;
if (!RolePermissions.TryGetValue(role, out var permissions))
return false;
// Wildcard: SuperAdmin has full access
if (permissions.Contains("*"))
return true;
return permissions.Contains(permission);
}
}
@@ -1,15 +0,0 @@
namespace CMSMicroservice.Application.Common.Authorization;
/// <summary>
/// Attribute برای مشخص کردن مجوز لازم برای دسترسی به یک متد gRPC
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class RequiresPermissionAttribute : Attribute
{
public RequiresPermissionAttribute(string permission)
{
Permission = permission ?? throw new ArgumentNullException(nameof(permission));
}
public string Permission { get; }
}
@@ -51,7 +51,6 @@ public interface IApplicationDbContext
DbSet<DiscountProduct> DiscountProducts { get; }
DbSet<DiscountCategory> DiscountCategories { get; }
DbSet<DiscountProductCategory> DiscountProductCategories { get; }
DbSet<DiscountProductImage> DiscountProductImages { get; }
DbSet<DiscountShoppingCart> DiscountShoppingCarts { get; }
DbSet<DiscountOrder> DiscountOrders { get; }
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
@@ -61,11 +60,6 @@ public interface IApplicationDbContext
DbSet<State> States { get; }
DbSet<City> Cities { get; }
// ============= Inventory Management =============
DbSet<Warehouse> Warehouses { get; }
DbSet<InventoryItem> InventoryItems { get; }
DbSet<StockMovement> StockMovements { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary>
@@ -1,37 +0,0 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Service for uploading files to FMS (File Management Service)
/// </summary>
public interface IFileManagementService
{
/// <summary>
/// Uploads a file to FMS and returns the stored file path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw file bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The stored file path returned by FMS, or null if upload failed</returns>
Task<string?> UploadFileAsync(string directory, byte[] fileBytes, string mime, string? fileName, CancellationToken cancellationToken = default);
/// <summary>
/// Uploads an image to FMS with optimization (resize + compress)
/// Returns both main image path and thumbnail path
/// </summary>
/// <param name="directory">Target directory path (e.g. "Images/Products")</param>
/// <param name="fileBytes">Raw image bytes</param>
/// <param name="mime">MIME type (e.g. "image/jpeg")</param>
/// <param name="fileName">Original file name</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tuple of (mainImagePath, thumbnailPath), either can be null if upload failed</returns>
Task<(string? MainImagePath, string? ThumbnailPath)> UploadImageWithThumbnailAsync(
string directory, byte[] fileBytes, string mime, string? fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a file from FMS by its ID
/// </summary>
Task<bool> DeleteFileAsync(long fileId, CancellationToken cancellationToken = default);
}
@@ -1,216 +0,0 @@
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس مدیریت موجودی - لایه بالاتر برای عملیات business
/// این سرویس مسئول همگام‌سازی موجودی بین InventoryItem و Product.RemainingCount است
/// </summary>
public interface IInventoryService
{
#region Initialization
/// <summary>
/// ایجاد رکورد موجودی برای محصول جدید
/// این متد باید در CreateProductCommandHandler و CreateDiscountProductCommandHandler فراخوانی شود
/// </summary>
/// <param name="productId">شناسه محصول (Product.Id یا DiscountProduct.Id)</param>
/// <param name="productType">نوع محصول (RegularProduct یا DiscountProduct)</param>
/// <param name="initialQuantity">موجودی اولیه</param>
/// <param name="warehouseId">شناسه انبار (پیش‌فرض: انبار اصلی)</param>
/// <param name="lowStockThreshold">آستانه هشدار کم‌موجودی</param>
/// <param name="ct">CancellationToken</param>
/// <returns>شناسه InventoryItem ایجاد شده</returns>
Task<long> InitializeInventoryAsync(
long productId,
ProductType productType,
int initialQuantity,
long? warehouseId = null,
int lowStockThreshold = 10,
CancellationToken ct = default);
#endregion
#region Query Operations
/// <summary>
/// دریافت موجودی یک محصول
/// </summary>
Task<InventoryItem?> GetInventoryAsync(
long productId,
ProductType productType,
long? warehouseId = null,
CancellationToken ct = default);
/// <summary>
/// دریافت موجودی قابل فروش (Quantity - ReservedQuantity)
/// </summary>
Task<int> GetAvailableQuantityAsync(
long productId,
ProductType productType,
long? warehouseId = null,
CancellationToken ct = default);
/// <summary>
/// بررسی اینکه آیا موجودی کافی برای فروش وجود دارد
/// </summary>
Task<bool> CheckAvailabilityAsync(
long productId,
ProductType productType,
int requiredQuantity,
long? warehouseId = null,
CancellationToken ct = default);
/// <summary>
/// دریافت لیست محصولات کم‌موجود
/// </summary>
Task<List<InventoryItem>> GetLowStockItemsAsync(
ProductType? productType = null,
long? warehouseId = null,
int count = 50,
CancellationToken ct = default);
/// <summary>
/// دریافت تاریخچه حرکات موجودی
/// </summary>
Task<List<StockMovement>> GetStockMovementsAsync(
long productId,
ProductType productType,
DateTime? fromDate = null,
DateTime? toDate = null,
CancellationToken ct = default);
#endregion
#region Order Flow Operations
/// <summary>
/// رزرو موجودی برای سفارش pending
/// این متد در PlaceOrderCommandHandler فراخوانی می‌شود
/// فقط ReservedQuantity را افزایش می‌دهد، Quantity تغییر نمی‌کند
/// </summary>
/// <param name="productId">شناسه محصول</param>
/// <param name="productType">نوع محصول</param>
/// <param name="quantity">تعداد رزرو</param>
/// <param name="orderId">شناسه سفارش (Order.Id یا DiscountOrder.Id)</param>
/// <param name="ct">CancellationToken</param>
/// <returns>true اگر رزرو موفق بود</returns>
Task<bool> ReserveStockAsync(
long productId,
ProductType productType,
int quantity,
long? orderId = null,
CancellationToken ct = default);
/// <summary>
/// آزادسازی رزرو (لغو سفارش یا timeout)
/// این متد در CancelOrderCommandHandler فراخوانی می‌شود
/// </summary>
Task<bool> ReleaseReservationAsync(
long productId,
ProductType productType,
int quantity,
long? orderId = null,
CancellationToken ct = default);
/// <summary>
/// تایید فروش - کسر واقعی موجودی
/// این متد در CompleteOrderPaymentCommandHandler فراخوانی می‌شود
/// ReservedQuantity کاهش می‌یابد، Quantity کاهش می‌یابد، Product.RemainingCount sync می‌شود
/// </summary>
Task<bool> ConfirmSaleAsync(
long productId,
ProductType productType,
int quantity,
long? orderId = null,
CancellationToken ct = default);
#endregion
#region Stock Management Operations
/// <summary>
/// ورود کالا به انبار (Restock)
/// </summary>
/// <param name="productId">شناسه محصول</param>
/// <param name="productType">نوع محصول</param>
/// <param name="quantity">تعداد ورودی</param>
/// <param name="referenceNumber">شماره مرجع (مثل شماره فاکتور خرید)</param>
/// <param name="note">یادداشت</param>
/// <param name="performedByUserId">شناسه کاربر انجام‌دهنده</param>
/// <param name="ct">CancellationToken</param>
Task<bool> AddStockAsync(
long productId,
ProductType productType,
int quantity,
string? referenceNumber = null,
string? note = null,
long? performedByUserId = null,
CancellationToken ct = default);
/// <summary>
/// تعدیل موجودی (تنظیم به مقدار جدید)
/// </summary>
Task<bool> AdjustStockAsync(
long productId,
ProductType productType,
int newQuantity,
string? note = null,
long? performedByUserId = null,
CancellationToken ct = default);
/// <summary>
/// ثبت برگشت کالا از مشتری
/// </summary>
Task<bool> ProcessReturnAsync(
long productId,
ProductType productType,
int quantity,
long? orderId = null,
string? note = null,
long? performedByUserId = null,
CancellationToken ct = default);
/// <summary>
/// ثبت ضایعات/مفقودی
/// </summary>
Task<bool> RecordLossAsync(
long productId,
ProductType productType,
int quantity,
StockMovementType lossType, // Damaged or Lost
string? note = null,
long? performedByUserId = null,
CancellationToken ct = default);
#endregion
#region Bulk Operations
/// <summary>
/// رزرو موجودی برای چند آیتم (یک سفارش با چند محصول)
/// </summary>
Task<bool> BulkReserveStockAsync(
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
long? orderId = null,
CancellationToken ct = default);
/// <summary>
/// آزادسازی رزرو برای چند آیتم
/// </summary>
Task<bool> BulkReleaseReservationAsync(
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
long? orderId = null,
CancellationToken ct = default);
/// <summary>
/// تایید فروش برای چند آیتم
/// </summary>
Task<bool> BulkConfirmSaleAsync(
IEnumerable<(long ProductId, ProductType ProductType, int Quantity)> items,
long? orderId = null,
CancellationToken ct = default);
#endregion
}
@@ -0,0 +1,21 @@
using CMSMicroservice.Application.UserCartsCQ.Queries.GetAllUserCartsByFilter;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserCartsProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
config.NewConfig<UserCart,GetAllUserCartsByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.ProductId, src => src.ProductId)
.Map(dest => dest.ProductTitle, src => src.Product.Title)
.Map(dest => dest.ProductShortInfomation, src => src.Product.ShortInfomation)
.Map(dest => dest.ProductDiscount, src => src.Product.Discount)
.Map(dest => dest.ProductPrice, src => src.Product.Price)
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
.Map(dest => dest.Created, src => src.Created)
;
}
}
@@ -0,0 +1,58 @@
using CMSMicroservice.Application.UserOrderCQ.Queries.GetAllUserOrderByFilter;
using CMSMicroservice.Application.UserOrderCQ.Queries.GetUserOrder;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserOrderProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
config.NewConfig<UserOrder,GetUserOrderResponseDto>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Amount, src => src.Amount)
.Map(dest => dest.PackageId, src => src.PackageId)
.Map(dest => dest.TransactionId, src => src.TransactionId)
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
;
config.NewConfig<UserOrder,GetAllUserOrderByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Amount, src => src.Amount)
.Map(dest => dest.PackageId, src => src.PackageId)
.Map(dest => dest.TransactionId, src => src.TransactionId)
.Map(dest => dest.PaymentStatus, src => src.PaymentStatus)
.Map(dest => dest.PaymentDate, src => src.PaymentDate)
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserAddressId, src => src.UserAddressId)
.Map(dest => dest.PaymentMethod, src => src.PaymentMethod)
.Map(dest => dest.UserAddressText, src => src.UserAddress.Address)
.Map(dest => dest.FactorDetails, src => src.FactorDetails.Select(s=>s.Adapt<GetUserOrderResponseFactorDetail>()))
;
config.NewConfig<FactorDetails,GetUserOrderResponseFactorDetail>()
.Map(dest => dest.ProductId, src => src.ProductId)
.Map(dest => dest.ProductTitle, src => src.Product.Title)
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
.Map(dest => dest.UnitPrice, src => src.Product.Price)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
;
config.NewConfig<FactorDetails,GetAllUserOrderByFilterResponseModelFactorDetail>()
.Map(dest => dest.ProductId, src => src.ProductId)
.Map(dest => dest.ProductTitle, src => src.Product.Title)
.Map(dest => dest.ProductThumbnailPath, src => src.Product.ThumbnailPath)
.Map(dest => dest.UnitPrice, src => src.Product.Price)
.Map(dest => dest.Count, src => src.Count)
.Map(dest => dest.UnitDiscountPrice, src => src.Product.Price*(src.Product.Discount/100))
;
}
}
@@ -1,83 +0,0 @@
namespace CMSMicroservice.Application.Common.Services;
/// <summary>
/// سرویس محاسبه مالیات بر ارزش افزوده (VAT)
/// </summary>
public static class VatCalculator
{
/// <summary>
/// نرخ VAT ایران - 9 درصد
/// </summary>
public const decimal VAT_RATE = 0.09m;
/// <summary>
/// نرخ VAT به صورت درصد (9)
/// </summary>
public const int VAT_PERCENT = 9;
/// <summary>
/// محاسبه VAT از مبلغ خالص
/// </summary>
/// <param name="netAmount">مبلغ خالص (بدون مالیات)</param>
/// <returns>مبلغ VAT</returns>
public static long CalculateVat(long netAmount)
{
return (long)(netAmount * VAT_RATE);
}
/// <summary>
/// محاسبه مبلغ ناخالص (شامل VAT) از مبلغ خالص
/// </summary>
/// <param name="netAmount">مبلغ خالص</param>
/// <returns>مبلغ ناخالص (خالص + VAT)</returns>
public static long CalculateGrossAmount(long netAmount)
{
return netAmount + CalculateVat(netAmount);
}
/// <summary>
/// استخراج مبلغ خالص از مبلغ ناخالص
/// </summary>
/// <param name="grossAmount">مبلغ ناخالص (شامل VAT)</param>
/// <returns>مبلغ خالص</returns>
public static long ExtractNetAmount(long grossAmount)
{
return (long)(grossAmount / (1 + VAT_RATE));
}
/// <summary>
/// استخراج VAT از مبلغ ناخالص
/// </summary>
/// <param name="grossAmount">مبلغ ناخالص (شامل VAT)</param>
/// <returns>مبلغ VAT</returns>
public static long ExtractVatFromGross(long grossAmount)
{
return grossAmount - ExtractNetAmount(grossAmount);
}
/// <summary>
/// جزئیات محاسبه VAT
/// </summary>
public record VatBreakdown(
long NetAmount,
long VatAmount,
long GrossAmount,
decimal VatRate
);
/// <summary>
/// محاسبه کامل جزئیات VAT
/// </summary>
/// <param name="netAmount">مبلغ خالص</param>
/// <returns>جزئیات کامل VAT</returns>
public static VatBreakdown CalculateBreakdown(long netAmount)
{
var vatAmount = CalculateVat(netAmount);
return new VatBreakdown(
NetAmount: netAmount,
VatAmount: vatAmount,
GrossAmount: netAmount + vatAmount,
VatRate: VAT_RATE
);
}
}
@@ -210,7 +210,7 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
var mainLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = 0,
CurrentBalance = wallet.Balance,
ChangeValue = SystemConstants.DayaLoanAmount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
@@ -230,7 +230,7 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
ChangeValue = 0,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id
@@ -1,12 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProductImage;
public class AddDiscountProductImageCommand : IRequest<long>
{
public long DiscountProductId { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { get; set; }
public string? AltText { get; set; }
}
@@ -1,47 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddDiscountProductImage;
public class AddDiscountProductImageCommandHandler : IRequestHandler<AddDiscountProductImageCommand, long>
{
private readonly IApplicationDbContext _context;
public AddDiscountProductImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(AddDiscountProductImageCommand request, CancellationToken cancellationToken)
{
// Verify product exists
var productExists = await _context.DiscountProducts
.AnyAsync(p => p.Id == request.DiscountProductId, cancellationToken);
if (!productExists)
throw new InvalidOperationException($"DiscountProduct with Id {request.DiscountProductId} not found.");
// Get the max sort order for this product
var maxSortOrder = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId)
.MaxAsync(i => (int?)i.SortOrder, cancellationToken) ?? 0;
var image = new DiscountProductImage
{
DiscountProductId = request.DiscountProductId,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
Title = request.Title,
AltText = request.AltText,
SortOrder = maxSortOrder + 1,
IsActive = true
};
_context.DiscountProductImages.Add(image);
await _context.SaveChangesAsync(cancellationToken);
return image.Id;
}
}
@@ -1,20 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddToCustomerCart;
/// <summary>
/// Command برای افزودن محصول به سبد خرید کاربر فعلی
/// </summary>
public class AddToCustomerCartCommand : IRequest<AddToCustomerCartCommandResponse>
{
public long ProductId { get; set; }
public int Count { get; set; }
// UserId from ICurrentUserService
}
public class AddToCustomerCartCommandResponse
{
public long Id { get; set; }
public string Message { get; set; } = string.Empty;
public bool Success { get; set; }
}
@@ -1,80 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.AddToCustomerCart;
public class AddToCustomerCartCommandHandler : IRequestHandler<AddToCustomerCartCommand, AddToCustomerCartCommandResponse>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public AddToCustomerCartCommandHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<AddToCustomerCartCommandResponse> Handle(AddToCustomerCartCommand request, CancellationToken cancellationToken)
{
// Extract UserId from JWT token
var userId = long.TryParse(_currentUser.UserId, out var id) ? id : 0;
if (userId == 0)
{
throw new UnauthorizedAccessException("User not authenticated");
}
// Check if product exists and is not deleted
var product = await _context.Products
.FirstOrDefaultAsync(p => p.Id == request.ProductId && !p.IsDeleted, cancellationToken);
if (product == null)
{
return new AddToCustomerCartCommandResponse
{
Success = false,
Message = "محصول یافت نشد یا حذف شده است"
};
}
// Check if item already exists in cart
var existingCartItem = await _context.UserCarts
.FirstOrDefaultAsync(uc => uc.UserId == userId && uc.ProductId == request.ProductId, cancellationToken);
if (existingCartItem != null)
{
// Update count
existingCartItem.Count += request.Count;
_context.UserCarts.Update(existingCartItem);
await _context.SaveChangesAsync(cancellationToken);
return new AddToCustomerCartCommandResponse
{
Id = existingCartItem.Id,
Success = true,
Message = "تعداد محصول در سبد خرید به‌روزرسانی شد"
};
}
// Create new cart item
var cartItem = new UserCart
{
UserId = userId,
ProductId = request.ProductId,
Count = request.Count,
Created = DateTime.UtcNow
};
_context.UserCarts.Add(cartItem);
await _context.SaveChangesAsync(cancellationToken);
return new AddToCustomerCartCommandResponse
{
Id = cartItem.Id,
Success = true,
Message = "محصول به سبد خرید اضافه شد"
};
}
}
@@ -8,14 +8,10 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.CompleteOrderPayme
public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderPaymentCommand, CompleteOrderPaymentResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
public CompleteOrderPaymentCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
public CompleteOrderPaymentCommandHandler(IApplicationDbContext context)
{
_context = context;
_inventoryService = inventoryService;
}
public async Task<CompleteOrderPaymentResponseDto> Handle(CompleteOrderPaymentCommand request, CancellationToken cancellationToken)
@@ -67,18 +63,12 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
userWallet.DiscountBalance -= order.DiscountBalanceUsed;
}
// تایید فروش و کسر موجودی از طریق InventoryService
// Update product stock and sale count
foreach (var orderDetail in order.OrderDetails)
{
await _inventoryService.ConfirmSaleAsync(
orderDetail.ProductId,
ProductType.DiscountProduct,
orderDetail.Count,
order.Id,
cancellationToken);
// افزایش تعداد فروش
orderDetail.Product.SaleCount += orderDetail.Count;
var product = orderDetail.Product;
product.RemainingCount -= orderDetail.Count;
product.SaleCount += orderDetail.Count;
}
await _context.SaveChangesAsync(cancellationToken);
@@ -92,17 +82,7 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
}
else
{
// Payment failed - آزادسازی رزرو
foreach (var orderDetail in order.OrderDetails)
{
await _inventoryService.ReleaseReservationAsync(
orderDetail.ProductId,
ProductType.DiscountProduct,
orderDetail.Count,
order.Id,
cancellationToken);
}
// Payment failed
transaction.PaymentStatus = PaymentStatus.Reject;
order.PaymentStatus = PaymentStatus.Reject;
@@ -11,5 +11,6 @@ public class CreateDiscountProductCommand : IRequest<long>
public int MaxDiscountPercent { get; set; }
public string ImagePath { get; set; }
public string ThumbnailPath { get; set; }
public int RemainingCount { get; set; }
public List<long> CategoryIds { get; set; } = new();
}
@@ -1,6 +1,5 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
@@ -9,14 +8,10 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.CreateDiscountProd
public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscountProductCommand, long>
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
public CreateDiscountProductCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
public CreateDiscountProductCommandHandler(IApplicationDbContext context)
{
_context = context;
_inventoryService = inventoryService;
}
public async Task<long> Handle(CreateDiscountProductCommand request, CancellationToken cancellationToken)
@@ -30,7 +25,7 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
MaxDiscountPercent = request.MaxDiscountPercent,
ImagePath = request.ImagePath,
ThumbnailPath = request.ThumbnailPath,
RemainingCount = 0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود
RemainingCount = request.RemainingCount,
Rate = 0,
SaleCount = 0,
ViewCount = 0,
@@ -40,13 +35,6 @@ public class CreateDiscountProductCommandHandler : IRequestHandler<CreateDiscoun
_context.DiscountProducts.Add(product);
await _context.SaveChangesAsync(cancellationToken);
// ایجاد رکورد موجودی در سیستم انبارداری با موجودی اولیه صفر
await _inventoryService.InitializeInventoryAsync(
product.Id,
ProductType.DiscountProduct,
0, // موجودی اولیه صفر - باید از طریق Inventory اضافه شود
ct: cancellationToken);
// Add product categories
if (request.CategoryIds.Any())
{
@@ -11,17 +11,26 @@ public class CreateDiscountProductCommandValidator : AbstractValidator<CreateDis
.MaximumLength(200).WithMessage("عنوان محصول نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(v => v.ShortInfomation)
.MaximumLength(500).WithMessage("توضیحات کوتاه نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(v => !string.IsNullOrEmpty(v.ShortInfomation));
.NotEmpty().WithMessage("توضیحات کوتاه الزامی است")
.MaximumLength(500).WithMessage("توضیحات کوتاه نمی‌تواند بیشتر از 500 کاراکتر باشد");
RuleFor(v => v.FullInformation)
.MaximumLength(10000).WithMessage("توضیحات کامل نمی‌تواند بیشتر از 10000 کاراکتر باشد")
.When(v => !string.IsNullOrEmpty(v.FullInformation));
.NotEmpty().WithMessage("توضیحات کامل الزامی است")
.MaximumLength(2000).WithMessage("توضیحات کامل نمی‌تواند بیشتر از 2000 کاراکتر باشد");
RuleFor(v => v.Price)
.GreaterThan(0).WithMessage("قیمت باید بیشتر از صفر باشد");
RuleFor(v => v.MaxDiscountPercent)
.InclusiveBetween(0, 100).WithMessage("درصد تخفیف باید بین 0 تا 100 باشد");
RuleFor(v => v.RemainingCount)
.GreaterThanOrEqualTo(0).WithMessage("موجودی نمی‌تواند منفی باشد");
RuleFor(v => v.ImagePath)
.NotEmpty().WithMessage("تصویر محصول الزامی است");
RuleFor(v => v.ThumbnailPath)
.NotEmpty().WithMessage("تصویر بندانگشتی الزامی است");
}
}
@@ -1,8 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.DeleteDiscountProductImage;
public class DeleteDiscountProductImageCommand : IRequest<bool>
{
public long Id { get; set; }
}
@@ -1,41 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.DeleteDiscountProductImage;
public class DeleteDiscountProductImageCommandHandler : IRequestHandler<DeleteDiscountProductImageCommand, bool>
{
private readonly IApplicationDbContext _context;
public DeleteDiscountProductImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<bool> Handle(DeleteDiscountProductImageCommand request, CancellationToken cancellationToken)
{
var image = await _context.DiscountProductImages
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (image == null)
return false;
_context.DiscountProductImages.Remove(image);
await _context.SaveChangesAsync(cancellationToken);
// Reorder remaining images for this product
var remainingImages = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == image.DiscountProductId)
.OrderBy(i => i.SortOrder)
.ToListAsync(cancellationToken);
for (int i = 0; i < remainingImages.Count; i++)
{
remainingImages[i].SortOrder = i + 1;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -1,5 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Services;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
@@ -11,14 +10,10 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Commands.PlaceOrder;
public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IInventoryService _inventoryService;
public PlaceOrderCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService)
public PlaceOrderCommandHandler(IApplicationDbContext context)
{
_context = context;
_inventoryService = inventoryService;
}
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
@@ -114,10 +109,9 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
var gatewayAmountRequired = totalAmount - actualDiscountBalanceUsed;
// Calculate VAT using centralized calculator
var vatBreakdown = VatCalculator.CalculateBreakdown(gatewayAmountRequired);
var vatAmount = vatBreakdown.VatAmount;
var finalGatewayAmount = vatBreakdown.GrossAmount;
// Calculate VAT (9%)
var vatAmount = (gatewayAmountRequired * 9) / 100;
var finalGatewayAmount = gatewayAmountRequired + vatAmount;
// Create transaction for gateway payment
var transaction = new Transaction
@@ -156,17 +150,6 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
_context.DiscountOrderDetails.AddRange(orderDetails);
// رزرو موجودی برای سفارش pending
foreach (var cartItem in cartItems)
{
await _inventoryService.ReserveStockAsync(
cartItem.ProductId,
ProductType.DiscountProduct,
cartItem.Count,
order.Id,
cancellationToken);
}
// Clear cart
_context.DiscountShoppingCarts.RemoveRange(cartItems);
@@ -1,18 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.RemoveFromCustomerCart;
/// <summary>
/// Command برای حذف محصول از سبد خرید
/// </summary>
public class RemoveFromCustomerCartCommand : IRequest<RemoveFromCustomerCartCommandResponse>
{
public long CartItemId { get; set; }
// UserId from ICurrentUserService
}
public class RemoveFromCustomerCartCommandResponse
{
public string Message { get; set; } = string.Empty;
public bool Success { get; set; }
}
@@ -1,49 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.RemoveFromCustomerCart;
public class RemoveFromCustomerCartCommandHandler : IRequestHandler<RemoveFromCustomerCartCommand, RemoveFromCustomerCartCommandResponse>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public RemoveFromCustomerCartCommandHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<RemoveFromCustomerCartCommandResponse> Handle(RemoveFromCustomerCartCommand request, CancellationToken cancellationToken)
{
// Extract UserId from JWT token
var userId = long.TryParse(_currentUser.UserId, out var id) ? id : 0;
if (userId == 0)
{
throw new UnauthorizedAccessException("User not authenticated");
}
// Find and remove cart item
var cartItem = await _context.UserCarts
.FirstOrDefaultAsync(uc => uc.Id == request.CartItemId && uc.UserId == userId, cancellationToken);
if (cartItem == null)
{
return new RemoveFromCustomerCartCommandResponse
{
Success = false,
Message = "آیتم سبد خرید یافت نشد"
};
}
_context.UserCarts.Remove(cartItem);
await _context.SaveChangesAsync(cancellationToken);
return new RemoveFromCustomerCartCommandResponse
{
Success = true,
Message = "آیتم از سبد خرید حذف شد"
};
}
}
@@ -1,9 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.ReorderDiscountProductImages;
public class ReorderDiscountProductImagesCommand : IRequest<bool>
{
public long DiscountProductId { get; set; }
public List<long> ImageIds { get; set; } = new();
}
@@ -1,43 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.ReorderDiscountProductImages;
public class ReorderDiscountProductImagesCommandHandler : IRequestHandler<ReorderDiscountProductImagesCommand, bool>
{
private readonly IApplicationDbContext _context;
public ReorderDiscountProductImagesCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<bool> Handle(ReorderDiscountProductImagesCommand request, CancellationToken cancellationToken)
{
var images = await _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId)
.ToListAsync(cancellationToken);
if (!images.Any())
return false;
// Validate all image IDs belong to this product
var imageIdSet = images.Select(i => i.Id).ToHashSet();
if (!request.ImageIds.All(id => imageIdSet.Contains(id)))
return false;
// Update sort order based on the new order
for (int i = 0; i < request.ImageIds.Count; i++)
{
var image = images.FirstOrDefault(img => img.Id == request.ImageIds[i]);
if (image != null)
{
image.SortOrder = i + 1;
}
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -1,19 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCustomerCartItem;
/// <summary>
/// Command برای به‌روزرسانی تعداد محصول در سبد خرید
/// </summary>
public class UpdateCustomerCartItemCommand : IRequest<UpdateCustomerCartItemCommandResponse>
{
public long CartItemId { get; set; }
public int Count { get; set; }
// UserId from ICurrentUserService
}
public class UpdateCustomerCartItemCommandResponse
{
public string Message { get; set; } = string.Empty;
public bool Success { get; set; }
}
@@ -1,64 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateCustomerCartItem;
public class UpdateCustomerCartItemCommandHandler : IRequestHandler<UpdateCustomerCartItemCommand, UpdateCustomerCartItemCommandResponse>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public UpdateCustomerCartItemCommandHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<UpdateCustomerCartItemCommandResponse> Handle(UpdateCustomerCartItemCommand request, CancellationToken cancellationToken)
{
// Extract UserId from JWT token
var userId = long.TryParse(_currentUser.UserId, out var id) ? id : 0;
if (userId == 0)
{
throw new UnauthorizedAccessException("User not authenticated");
}
// Find cart item
var cartItem = await _context.UserCarts
.FirstOrDefaultAsync(uc => uc.Id == request.CartItemId && uc.UserId == userId, cancellationToken);
if (cartItem == null)
{
return new UpdateCustomerCartItemCommandResponse
{
Success = false,
Message = "آیتم سبد خرید یافت نشد"
};
}
// Update count
if (request.Count <= 0)
{
// Remove item if count is 0 or negative
_context.UserCarts.Remove(cartItem);
await _context.SaveChangesAsync(cancellationToken);
return new UpdateCustomerCartItemCommandResponse
{
Success = true,
Message = "آیتم از سبد خرید حذف شد"
};
}
cartItem.Count = request.Count;
_context.UserCarts.Update(cartItem);
await _context.SaveChangesAsync(cancellationToken);
return new UpdateCustomerCartItemCommandResponse
{
Success = true,
Message = "تعداد آیتم به‌روزرسانی شد"
};
}
}
@@ -14,12 +14,12 @@ public class UpdateDiscountProductCommandValidator : AbstractValidator<UpdateDis
.MaximumLength(200).WithMessage("عنوان محصول نباید بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.ShortInfomation)
.MaximumLength(500).WithMessage("توضیحات کوتاه نباید بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.ShortInfomation));
.NotEmpty().WithMessage("توضیحات کوتاه الزامی است")
.MaximumLength(500).WithMessage("توضیحات کوتاه نباید بیشتر از 500 کاراکتر باشد");
RuleFor(x => x.FullInformation)
.MaximumLength(10000).WithMessage("توضیحات کامل نباید بیشتر از 10000 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.FullInformation));
.NotEmpty().WithMessage("توضیحات کامل الزامی است")
.MaximumLength(5000).WithMessage("توضیحات کامل نباید بیشتر از 5000 کاراکتر باشد");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("قیمت باید بزرگتر از صفر باشد");
@@ -27,6 +27,14 @@ public class UpdateDiscountProductCommandValidator : AbstractValidator<UpdateDis
RuleFor(x => x.MaxDiscountPercent)
.InclusiveBetween(0, 100).WithMessage("درصد تخفیف باید بین 0 تا 100 باشد");
RuleFor(x => x.ImagePath)
.NotEmpty().WithMessage("مسیر تصویر اصلی الزامی است")
.MaximumLength(500).WithMessage("مسیر تصویر نباید بیشتر از 500 کاراکتر باشد");
RuleFor(x => x.ThumbnailPath)
.NotEmpty().WithMessage("مسیر تصویر بندانگشتی الزامی است")
.MaximumLength(500).WithMessage("مسیر تصویر نباید بیشتر از 500 کاراکتر باشد");
RuleFor(x => x.RemainingCount)
.GreaterThanOrEqualTo(0).WithMessage("موجودی نمی‌تواند منفی باشد");
@@ -1,13 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProductImage;
public class UpdateDiscountProductImageCommand : IRequest<bool>
{
public long Id { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { get; set; }
public string? AltText { get; set; }
public bool IsActive { get; set; }
}
@@ -1,33 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Commands.UpdateDiscountProductImage;
public class UpdateDiscountProductImageCommandHandler : IRequestHandler<UpdateDiscountProductImageCommand, bool>
{
private readonly IApplicationDbContext _context;
public UpdateDiscountProductImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<bool> Handle(UpdateDiscountProductImageCommand request, CancellationToken cancellationToken)
{
var image = await _context.DiscountProductImages
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (image == null)
return false;
image.ImagePath = request.ImagePath;
image.ThumbnailPath = request.ThumbnailPath;
image.Title = request.Title;
image.AltText = request.AltText;
image.IsActive = request.IsActive;
await _context.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -1,102 +0,0 @@
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetAllDiscountOrders;
/// <summary>
/// کوئری دریافت همه سفارشات فروشگاه تخفیفی برای ادمین
/// </summary>
public class GetAllDiscountOrdersQuery : IRequest<GetAllDiscountOrdersResponseDto>
{
public PaginationState? PaginationQuery { get; set; }
/// <summary>
/// فیلتر بر اساس شناسه کاربر
/// </summary>
public long? UserId { get; set; }
/// <summary>
/// فیلتر بر اساس وضعیت پرداخت
/// </summary>
public PaymentStatus? PaymentStatus { get; set; }
/// <summary>
/// فیلتر بر اساس وضعیت ارسال
/// </summary>
public DeliveryStatus? DeliveryStatus { get; set; }
/// <summary>
/// جستجو بر اساس موبایل کاربر
/// </summary>
public string? UserMobile { get; set; }
/// <summary>
/// جستجو بر اساس کد رهگیری
/// </summary>
public string? TrackingCode { get; set; }
/// <summary>
/// فیلتر از تاریخ
/// </summary>
public DateTime? FromDate { get; set; }
/// <summary>
/// فیلتر تا تاریخ
/// </summary>
public DateTime? ToDate { get; set; }
/// <summary>
/// حداقل مبلغ سفارش
/// </summary>
public long? MinAmount { get; set; }
/// <summary>
/// حداکثر مبلغ سفارش
/// </summary>
public long? MaxAmount { get; set; }
}
public class GetAllDiscountOrdersResponseDto
{
public MetaData MetaData { get; set; } = new();
public List<AdminOrderDto> Models { get; set; } = new();
}
public class AdminOrderDto
{
public long Id { get; set; }
// اطلاعات کاربر
public long UserId { get; set; }
public string UserFullName { get; set; } = string.Empty;
public string UserMobile { get; set; } = string.Empty;
// مبالغ
public long TotalAmount { get; set; }
public long DiscountBalanceUsed { get; set; }
public long GatewayAmountPaid { get; set; }
public long VatAmount { get; set; }
// وضعیت ها
public PaymentStatus PaymentStatus { get; set; }
public DateTime? PaymentDate { get; set; }
public DeliveryStatus DeliveryStatus { get; set; }
public DateTime? DeliveryDate { get; set; }
// آدرس تحویل
public string? ShippingAddress { get; set; }
public string? ReceiverName { get; set; }
public string? ReceiverMobile { get; set; }
// رهگیری
public string? TrackingCode { get; set; }
public string? AdminNote { get; set; }
// تاریخ ها
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
// تعداد آیتم ها
public int ItemsCount { get; set; }
}
@@ -1,124 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetAllDiscountOrders;
public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOrdersQuery, GetAllDiscountOrdersResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllDiscountOrdersQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllDiscountOrdersResponseDto> Handle(GetAllDiscountOrdersQuery request, CancellationToken cancellationToken)
{
var query = _context.DiscountOrders
.Include(o => o.User)
.Include(o => o.UserAddress)
.AsQueryable();
// فیلتر بر اساس شناسه کاربر
if (request.UserId.HasValue)
{
query = query.Where(o => o.UserId == request.UserId.Value);
}
// فیلتر بر اساس وضعیت پرداخت
if (request.PaymentStatus.HasValue)
{
query = query.Where(o => o.PaymentStatus == request.PaymentStatus.Value);
}
// فیلتر بر اساس وضعیت ارسال
if (request.DeliveryStatus.HasValue)
{
query = query.Where(o => o.DeliveryStatus == request.DeliveryStatus.Value);
}
// جستجو بر اساس موبایل کاربر
if (!string.IsNullOrWhiteSpace(request.UserMobile))
{
query = query.Where(o => o.User.Mobile.Contains(request.UserMobile));
}
// جستجو بر اساس کد رهگیری
if (!string.IsNullOrWhiteSpace(request.TrackingCode))
{
query = query.Where(o => o.TrackingCode != null && o.TrackingCode.Contains(request.TrackingCode));
}
// فیلتر از تاریخ
if (request.FromDate.HasValue)
{
query = query.Where(o => o.Created >= request.FromDate.Value);
}
// فیلتر تا تاریخ
if (request.ToDate.HasValue)
{
query = query.Where(o => o.Created <= request.ToDate.Value);
}
// فیلتر حداقل مبلغ
if (request.MinAmount.HasValue)
{
query = query.Where(o => o.TotalAmount >= request.MinAmount.Value);
}
// فیلتر حداکثر مبلغ
if (request.MaxAmount.HasValue)
{
query = query.Where(o => o.TotalAmount <= request.MaxAmount.Value);
}
var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 };
var orders = await query
.OrderByDescending(o => o.Created)
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.Select(o => new AdminOrderDto
{
Id = o.Id,
UserId = o.UserId,
UserFullName = (o.User.FirstName ?? "") + " " + (o.User.LastName ?? ""),
UserMobile = o.User.Mobile,
TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid,
VatAmount = o.VatAmount,
PaymentStatus = o.PaymentStatus,
PaymentDate = o.PaymentDate,
DeliveryStatus = o.DeliveryStatus,
DeliveryDate = null, // TODO: Add DeliveryDate to DiscountOrder if needed
ShippingAddress = o.UserAddress.Address,
ReceiverName = o.UserAddress.Title,
ReceiverMobile = o.User.Mobile,
TrackingCode = o.TrackingCode,
AdminNote = o.DeliveryDescription,
Created = o.Created,
LastModified = o.LastModified,
ItemsCount = o.OrderDetails.Count
})
.ToListAsync(cancellationToken);
return new GetAllDiscountOrdersResponseDto
{
MetaData = new MetaData
{
TotalCount = totalCount,
PageSize = pagination.PageSize,
CurrentPage = pagination.PageNumber,
TotalPage = (int)Math.Ceiling(totalCount / (double)pagination.PageSize)
},
Models = orders
};
}
}
@@ -1,11 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart;
/// <summary>
/// Query برای دریافت سبد خرید کاربر فعلی
/// </summary>
public class GetCustomerCartQuery : IRequest<GetCustomerCartQueryResponse>
{
// UserId from ICurrentUserService
}
@@ -1,68 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart;
public class GetCustomerCartQueryHandler : IRequestHandler<GetCustomerCartQuery, GetCustomerCartQueryResponse>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public GetCustomerCartQueryHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<GetCustomerCartQueryResponse> Handle(GetCustomerCartQuery request, CancellationToken cancellationToken)
{
// Extract UserId from JWT token
var userId = long.TryParse(_currentUser.UserId, out var id) ? id : 0;
if (userId == 0)
{
throw new UnauthorizedAccessException("User not authenticated");
}
// Get all cart items for the current user
var cartItems = await _context.UserCarts
.Include(uc => uc.Product)
.Where(uc => uc.UserId == userId)
.ToListAsync(cancellationToken);
var response = new GetCustomerCartQueryResponse
{
TotalItemsCount = cartItems.Sum(c => c.Count),
Message = cartItems.Count > 0 ? "سبد خرید با موفقیت بازیابی شد" : "سبد خرید خالی است"
};
foreach (var item in cartItems)
{
// Use Product.ThumbnailPath directly
var thumbnailPath = item.Product?.ThumbnailPath ?? string.Empty;
var itemPrice = item.Product?.Price ?? 0;
var itemDiscount = item.Product?.Discount ?? 0;
var finalPrice = itemPrice * (100 - itemDiscount) / 100;
var totalItemPrice = finalPrice * item.Count;
response.Items.Add(new CustomerCartItemModel
{
Id = item.Id,
ProductId = item.ProductId,
ProductTitle = item.Product?.Title ?? string.Empty,
ProductShortInformation = item.Product?.ShortInfomation ?? string.Empty, // Typo in DB: ShortInfomation
ProductPrice = itemPrice,
ProductDiscount = itemDiscount,
ProductThumbnailPath = thumbnailPath,
Count = item.Count,
TotalItemPrice = totalItemPrice,
Created = item.Created
});
response.TotalPrice += totalItemPrice;
}
return response;
}
}
@@ -1,23 +0,0 @@
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetCustomerCart;
public class GetCustomerCartQueryResponse
{
public List<CustomerCartItemModel> Items { get; set; } = new();
public long TotalPrice { get; set; }
public int TotalItemsCount { get; set; }
public string Message { get; set; } = string.Empty;
}
public class CustomerCartItemModel
{
public long Id { get; set; }
public long ProductId { get; set; }
public string ProductTitle { get; set; } = string.Empty;
public string ProductShortInformation { get; set; } = string.Empty;
public long ProductPrice { get; set; }
public int ProductDiscount { get; set; }
public string ProductThumbnailPath { get; set; } = string.Empty;
public int Count { get; set; }
public long TotalItemPrice { get; set; }
public DateTime Created { get; set; }
}
@@ -1,21 +0,0 @@
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountProductImages;
public class GetDiscountProductImagesQuery : IRequest<List<DiscountProductImageDto>>
{
public long DiscountProductId { get; set; }
public bool OnlyActive { get; set; } = true;
}
public class DiscountProductImageDto
{
public long Id { get; set; }
public long DiscountProductId { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string ThumbnailPath { get; set; } = string.Empty;
public string? Title { get; set; }
public string? AltText { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
@@ -1,39 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountProductImages;
public class GetDiscountProductImagesQueryHandler : IRequestHandler<GetDiscountProductImagesQuery, List<DiscountProductImageDto>>
{
private readonly IApplicationDbContext _context;
public GetDiscountProductImagesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<DiscountProductImageDto>> Handle(GetDiscountProductImagesQuery request, CancellationToken cancellationToken)
{
var query = _context.DiscountProductImages
.Where(i => i.DiscountProductId == request.DiscountProductId);
if (request.OnlyActive)
query = query.Where(i => i.IsActive);
return await query
.OrderBy(i => i.SortOrder)
.Select(i => new DiscountProductImageDto
{
Id = i.Id,
DiscountProductId = i.DiscountProductId,
ImagePath = i.ImagePath,
ThumbnailPath = i.ThumbnailPath,
Title = i.Title,
AltText = i.AltText,
SortOrder = i.SortOrder,
IsActive = i.IsActive
})
.ToListAsync(cancellationToken);
}
}
@@ -1,166 +0,0 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountSalesReport;
/// <summary>
/// کوئری گزارش فروش فروشگاه تخفیفی
/// </summary>
public class GetDiscountSalesReportQuery : IRequest<DiscountSalesReportDto>
{
/// <summary>
/// از تاریخ
/// </summary>
public DateTime? FromDate { get; set; }
/// <summary>
/// تا تاریخ
/// </summary>
public DateTime? ToDate { get; set; }
/// <summary>
/// نوع گزارش
/// </summary>
public SalesReportType ReportType { get; set; } = SalesReportType.Summary;
}
public enum SalesReportType
{
/// <summary>
/// خلاصه کلی
/// </summary>
Summary,
/// <summary>
/// روزانه
/// </summary>
Daily,
/// <summary>
/// هفتگی
/// </summary>
Weekly,
/// <summary>
/// ماهانه
/// </summary>
Monthly
}
public class DiscountSalesReportDto
{
// خلاصه کلی
public SalesSummary Summary { get; set; } = new();
// جزئیات زمانی (برای گزارش‌های روزانه، هفتگی، ماهانه)
public List<SalesPeriodDto> Periods { get; set; } = new();
// پرفروش‌ترین محصولات
public List<TopSellingProductDto> TopProducts { get; set; } = new();
}
public class SalesSummary
{
/// <summary>
/// تعداد کل سفارشات
/// </summary>
public int TotalOrders { get; set; }
/// <summary>
/// تعداد سفارشات موفق (پرداخت شده)
/// </summary>
public int CompletedOrders { get; set; }
/// <summary>
/// تعداد سفارشات در انتظار پرداخت
/// </summary>
public int PendingOrders { get; set; }
/// <summary>
/// تعداد سفارشات لغو شده
/// </summary>
public int CancelledOrders { get; set; }
/// <summary>
/// جمع کل فروش (TotalAmount)
/// </summary>
public long TotalSalesAmount { get; set; }
/// <summary>
/// جمع تخفیف استفاده شده (DiscountBalanceUsed)
/// </summary>
public long TotalDiscountUsed { get; set; }
/// <summary>
/// جمع پرداخت از درگاه (GatewayAmountPaid)
/// </summary>
public long TotalGatewayPaid { get; set; }
/// <summary>
/// جمع VAT
/// </summary>
public long TotalVatAmount { get; set; }
/// <summary>
/// میانگین ارزش سفارش
/// </summary>
public long AverageOrderValue { get; set; }
/// <summary>
/// تعداد کاربران یکتا
/// </summary>
public int UniqueCustomers { get; set; }
/// <summary>
/// تعداد کل محصولات فروخته شده
/// </summary>
public int TotalProductsSold { get; set; }
}
public class SalesPeriodDto
{
/// <summary>
/// نام دوره (مثل: 1403/10/11 یا هفته 41)
/// </summary>
public string PeriodLabel { get; set; } = string.Empty;
/// <summary>
/// تاریخ شروع دوره
/// </summary>
public DateTime PeriodStart { get; set; }
/// <summary>
/// تاریخ پایان دوره
/// </summary>
public DateTime PeriodEnd { get; set; }
/// <summary>
/// تعداد سفارشات
/// </summary>
public int OrdersCount { get; set; }
/// <summary>
/// جمع فروش
/// </summary>
public long TotalAmount { get; set; }
/// <summary>
/// جمع تخفیف
/// </summary>
public long DiscountUsed { get; set; }
/// <summary>
/// جمع پرداخت درگاه
/// </summary>
public long GatewayPaid { get; set; }
}
public class TopSellingProductDto
{
public long ProductId { get; set; }
public string ProductTitle { get; set; } = string.Empty;
public string? ImagePath { get; set; }
public int QuantitySold { get; set; }
public long TotalRevenue { get; set; }
public int OrdersCount { get; set; }
}
@@ -1,193 +0,0 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetDiscountSalesReport;
public class GetDiscountSalesReportQueryHandler : IRequestHandler<GetDiscountSalesReportQuery, DiscountSalesReportDto>
{
private readonly IApplicationDbContext _context;
private static readonly PersianCalendar PersianCalendar = new();
public GetDiscountSalesReportQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<DiscountSalesReportDto> Handle(GetDiscountSalesReportQuery request, CancellationToken cancellationToken)
{
var result = new DiscountSalesReportDto();
// Base query for orders
var ordersQuery = _context.DiscountOrders.AsQueryable();
// Apply date filters
if (request.FromDate.HasValue)
{
ordersQuery = ordersQuery.Where(o => o.Created >= request.FromDate.Value);
}
if (request.ToDate.HasValue)
{
ordersQuery = ordersQuery.Where(o => o.Created <= request.ToDate.Value);
}
// Get all matching orders for summary
var orders = await ordersQuery.ToListAsync(cancellationToken);
var completedOrders = orders.Where(o => o.PaymentStatus == PaymentStatus.Success).ToList();
// Calculate summary
result.Summary = new SalesSummary
{
TotalOrders = orders.Count,
CompletedOrders = completedOrders.Count,
PendingOrders = orders.Count(o => o.PaymentStatus == PaymentStatus.Pending),
CancelledOrders = orders.Count(o => o.PaymentStatus == PaymentStatus.Reject),
TotalSalesAmount = completedOrders.Sum(o => o.TotalAmount),
TotalDiscountUsed = completedOrders.Sum(o => o.DiscountBalanceUsed),
TotalGatewayPaid = completedOrders.Sum(o => o.GatewayAmountPaid),
TotalVatAmount = completedOrders.Sum(o => o.VatAmount),
AverageOrderValue = completedOrders.Any() ? (long)completedOrders.Average(o => o.TotalAmount) : 0,
UniqueCustomers = orders.Select(o => o.UserId).Distinct().Count()
};
// Get total products sold
var orderIds = completedOrders.Select(o => o.Id).ToList();
result.Summary.TotalProductsSold = await _context.DiscountOrderDetails
.Where(d => orderIds.Contains(d.DiscountOrderId))
.SumAsync(d => d.Count, cancellationToken);
// Generate period-based reports
if (request.ReportType != SalesReportType.Summary && completedOrders.Any())
{
result.Periods = GeneratePeriodReport(completedOrders, request.ReportType);
}
// Get top selling products
result.TopProducts = await GetTopSellingProducts(orderIds, cancellationToken);
return result;
}
private List<SalesPeriodDto> GeneratePeriodReport(List<Domain.Entities.DiscountShop.DiscountOrder> orders, SalesReportType reportType)
{
var periods = new List<SalesPeriodDto>();
var groupedOrders = reportType switch
{
SalesReportType.Daily => orders.GroupBy(o => o.Created.Date),
SalesReportType.Weekly => orders.GroupBy(o => GetStartOfWeek(o.Created)),
SalesReportType.Monthly => orders.GroupBy(o => new DateTime(o.Created.Year, o.Created.Month, 1)),
_ => orders.GroupBy(o => o.Created.Date)
};
foreach (var group in groupedOrders.OrderBy(g => g.Key))
{
var periodStart = group.Key;
var periodEnd = reportType switch
{
SalesReportType.Daily => periodStart.AddDays(1).AddSeconds(-1),
SalesReportType.Weekly => periodStart.AddDays(7).AddSeconds(-1),
SalesReportType.Monthly => periodStart.AddMonths(1).AddSeconds(-1),
_ => periodStart.AddDays(1).AddSeconds(-1)
};
periods.Add(new SalesPeriodDto
{
PeriodLabel = GetPersianPeriodLabel(periodStart, reportType),
PeriodStart = periodStart,
PeriodEnd = periodEnd,
OrdersCount = group.Count(),
TotalAmount = group.Sum(o => o.TotalAmount),
DiscountUsed = group.Sum(o => o.DiscountBalanceUsed),
GatewayPaid = group.Sum(o => o.GatewayAmountPaid)
});
}
return periods;
}
private async Task<List<TopSellingProductDto>> GetTopSellingProducts(List<long> orderIds, CancellationToken cancellationToken)
{
if (!orderIds.Any())
return new List<TopSellingProductDto>();
var topProducts = await _context.DiscountOrderDetails
.Where(d => orderIds.Contains(d.DiscountOrderId))
.GroupBy(d => d.ProductId)
.Select(g => new
{
ProductId = g.Key,
QuantitySold = g.Sum(d => d.Count),
TotalRevenue = g.Sum(d => d.FinalPrice),
OrdersCount = g.Select(d => d.DiscountOrderId).Distinct().Count()
})
.OrderByDescending(x => x.QuantitySold)
.Take(10)
.ToListAsync(cancellationToken);
var productIds = topProducts.Select(p => p.ProductId).ToList();
var products = await _context.DiscountProducts
.Where(p => productIds.Contains(p.Id))
.Select(p => new { p.Id, p.Title, p.ThumbnailPath })
.ToListAsync(cancellationToken);
return topProducts.Select(tp => new TopSellingProductDto
{
ProductId = tp.ProductId,
ProductTitle = products.FirstOrDefault(p => p.Id == tp.ProductId)?.Title ?? "نامشخص",
ImagePath = products.FirstOrDefault(p => p.Id == tp.ProductId)?.ThumbnailPath,
QuantitySold = tp.QuantitySold,
TotalRevenue = tp.TotalRevenue,
OrdersCount = tp.OrdersCount
}).ToList();
}
private static DateTime GetStartOfWeek(DateTime date)
{
// شروع هفته از شنبه (Saturday = 6 in DayOfWeek)
var diff = ((int)date.DayOfWeek + 1) % 7; // Saturday = 0
return date.AddDays(-diff).Date;
}
private static string GetPersianPeriodLabel(DateTime date, SalesReportType reportType)
{
var persianYear = PersianCalendar.GetYear(date);
var persianMonth = PersianCalendar.GetMonth(date);
var persianDay = PersianCalendar.GetDayOfMonth(date);
return reportType switch
{
SalesReportType.Daily => $"{persianYear}/{persianMonth:D2}/{persianDay:D2}",
SalesReportType.Weekly => $"هفته {GetPersianWeekOfYear(date)} - {persianYear}",
SalesReportType.Monthly => $"{GetPersianMonthName(persianMonth)} {persianYear}",
_ => $"{persianYear}/{persianMonth:D2}/{persianDay:D2}"
};
}
private static int GetPersianWeekOfYear(DateTime date)
{
var firstDayOfYear = PersianCalendar.ToDateTime(PersianCalendar.GetYear(date), 1, 1, 0, 0, 0, 0);
var daysSinceStart = (date - firstDayOfYear).Days;
return (daysSinceStart / 7) + 1;
}
private static string GetPersianMonthName(int month) => month switch
{
1 => "فروردین",
2 => "اردیبهشت",
3 => "خرداد",
4 => "تیر",
5 => "مرداد",
6 => "شهریور",
7 => "مهر",
8 => "آبان",
9 => "آذر",
10 => "دی",
11 => "بهمن",
12 => "اسفند",
_ => "نامشخص"
};
}
@@ -1,5 +0,0 @@
namespace CMSMicroservice.Application.HealthCQ.Queries.GetSystemHealth;
public class GetSystemHealthQuery : IRequest<GetSystemHealthResponseDto>
{
}
@@ -1,112 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace CMSMicroservice.Application.HealthCQ.Queries.GetSystemHealth;
public class GetSystemHealthQueryHandler : IRequestHandler<GetSystemHealthQuery, GetSystemHealthResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IConfiguration _configuration;
public GetSystemHealthQueryHandler(IApplicationDbContext context, IConfiguration configuration)
{
_context = context;
_configuration = configuration;
}
public async Task<GetSystemHealthResponseDto> Handle(GetSystemHealthQuery request, CancellationToken cancellationToken)
{
var services = new List<ServiceHealthDto>();
var overallHealthy = true;
// Database Health Check
var dbHealth = await CheckDatabaseHealth(cancellationToken);
services.Add(dbHealth);
if (dbHealth.Status != HealthStatusDto.Healthy) overallHealthy = false;
// Memory Health Check
var memoryHealth = CheckMemoryHealth();
services.Add(memoryHealth);
if (memoryHealth.Status != HealthStatusDto.Healthy) overallHealthy = false;
// External Services Health (if any)
// TODO: Add external service health checks
return new GetSystemHealthResponseDto
{
OverallHealthy = overallHealthy,
Services = services,
CheckedAt = DateTime.UtcNow,
Version = GetApplicationVersion(),
Environment = _configuration["Environment"] ?? "Unknown"
};
}
private async Task<ServiceHealthDto> CheckDatabaseHealth(CancellationToken cancellationToken)
{
var startTime = DateTime.UtcNow;
try
{
// Simple database connectivity check
var canConnect = await _context.Users.AnyAsync(cancellationToken);
var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds;
return new ServiceHealthDto
{
ServiceName = "Database",
Status = HealthStatusDto.Healthy,
Description = "Database connection is healthy",
ResponseTimeMs = (long)responseTime,
LastCheck = DateTime.UtcNow,
Details = new List<HealthDetailDto>
{
new() { Key = "ConnectionString", Value = "Connected", Status = HealthStatusDto.Healthy },
new() { Key = "ResponseTime", Value = $"{responseTime:F2}ms", Status = responseTime < 1000 ? HealthStatusDto.Healthy : HealthStatusDto.Degraded }
}
};
}
catch (Exception ex)
{
var responseTime = (DateTime.UtcNow - startTime).TotalMilliseconds;
return new ServiceHealthDto
{
ServiceName = "Database",
Status = HealthStatusDto.Unhealthy,
Description = $"Database connection failed: {ex.Message}",
ResponseTimeMs = (long)responseTime,
LastCheck = DateTime.UtcNow,
Details = new List<HealthDetailDto>
{
new() { Key = "Error", Value = ex.Message, Status = HealthStatusDto.Unhealthy }
}
};
}
}
private ServiceHealthDto CheckMemoryHealth()
{
var process = System.Diagnostics.Process.GetCurrentProcess();
var workingSetMB = process.WorkingSet64 / 1024 / 1024;
var status = workingSetMB < 500 ? HealthStatusDto.Healthy :
workingSetMB < 1000 ? HealthStatusDto.Degraded : HealthStatusDto.Unhealthy;
return new ServiceHealthDto
{
ServiceName = "Memory",
Status = status,
Description = $"Current memory usage: {workingSetMB}MB",
ResponseTimeMs = 0,
LastCheck = DateTime.UtcNow,
Details = new List<HealthDetailDto>
{
new() { Key = "WorkingSet", Value = $"{workingSetMB}MB", Status = status },
new() { Key = "ProcessName", Value = process.ProcessName, Status = HealthStatusDto.Healthy }
}
};
}
private string GetApplicationVersion()
{
return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown";
}
}
@@ -1,35 +0,0 @@
namespace CMSMicroservice.Application.HealthCQ.Queries.GetSystemHealth;
public class GetSystemHealthResponseDto
{
public bool OverallHealthy { get; set; }
public List<ServiceHealthDto> Services { get; set; } = new();
public DateTime CheckedAt { get; set; }
public string Version { get; set; } = string.Empty;
public string Environment { get; set; } = string.Empty;
}
public class ServiceHealthDto
{
public string ServiceName { get; set; } = string.Empty;
public HealthStatusDto Status { get; set; }
public string Description { get; set; } = string.Empty;
public long ResponseTimeMs { get; set; }
public DateTime LastCheck { get; set; }
public List<HealthDetailDto> Details { get; set; } = new();
}
public class HealthDetailDto
{
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public HealthStatusDto Status { get; set; }
}
public enum HealthStatusDto
{
Unknown = 0,
Healthy = 1,
Degraded = 2,
Unhealthy = 3
}
@@ -1,24 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.CreateInventoryItem;
/// <summary>
/// Command برای ایجاد آیتم موجودی جدید
/// </summary>
public record CreateInventoryItemCommand : IRequest<CreateInventoryItemResponseDto>
{
/// <summary>شناسه محصول عادی</summary>
public long? ProductId { get; init; }
/// <summary>شناسه محصول تخفیفی</summary>
public long? DiscountProductId { get; init; }
/// <summary>شناسه انبار</summary>
public long WarehouseId { get; init; }
/// <summary>تعداد موجودی</summary>
public int Quantity { get; init; }
/// <summary>حداقل موجودی (هشدار)</summary>
public int MinQuantity { get; init; }
/// <summary>حداکثر موجودی</summary>
public int MaxQuantity { get; init; }
/// <summary>فعال؟</summary>
public bool IsActive { get; init; } = true;
}
@@ -1,84 +0,0 @@
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.CreateInventoryItem;
public class CreateInventoryItemCommandHandler : IRequestHandler<CreateInventoryItemCommand, CreateInventoryItemResponseDto>
{
private readonly IApplicationDbContext _context;
public CreateInventoryItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<CreateInventoryItemResponseDto> Handle(CreateInventoryItemCommand request, CancellationToken cancellationToken)
{
// بررسی اینکه حداقل یکی از Product یا DiscountProduct تعریف شده باشد
if (request.ProductId == null && request.DiscountProductId == null)
{
throw new ArgumentException("Either ProductId or DiscountProductId must be provided");
}
// بررسی اینکه هر دو ProductId و DiscountProductId تعریف نشده باشند
if (request.ProductId != null && request.DiscountProductId != null)
{
throw new ArgumentException("Only one of ProductId or DiscountProductId can be provided");
}
// بررسی وجود آیتم موجودی قبلی برای همین محصول در همین انبار
bool existingItem;
if (request.ProductId.HasValue)
{
existingItem = await _context.InventoryItems
.AnyAsync(i => i.ProductId == request.ProductId.Value && i.WarehouseId == request.WarehouseId, cancellationToken);
}
else
{
existingItem = await _context.InventoryItems
.AnyAsync(i => i.DiscountProductId == request.DiscountProductId!.Value && i.WarehouseId == request.WarehouseId, cancellationToken);
}
if (existingItem)
{
throw new InvalidOperationException("Inventory item already exists for this product in this warehouse");
}
var productType = request.ProductId.HasValue ? ProductType.RegularProduct : ProductType.DiscountProduct;
var entity = new InventoryItem
{
ProductId = request.ProductId,
DiscountProductId = request.DiscountProductId,
ProductType = productType,
WarehouseId = request.WarehouseId,
Quantity = request.Quantity,
LowStockThreshold = request.MinQuantity,
MaxStockLevel = request.MaxQuantity,
ReservedQuantity = 0
};
await _context.InventoryItems.AddAsync(entity, cancellationToken);
// ثبت حرکت موجودی اولیه اگر موجودی اولیه بیشتر از صفر باشد
if (request.Quantity > 0)
{
var stockMovement = new StockMovement
{
InventoryItemId = entity.Id,
MovementType = StockMovementType.InitialStock,
Quantity = request.Quantity,
QuantityBefore = 0,
QuantityAfter = request.Quantity,
Note = "Initial stock creation",
ReferenceNumber = $"INIT-{entity.Id}"
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
return new CreateInventoryItemResponseDto { Id = entity.Id };
}
}
@@ -1,27 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.CreateInventoryItem;
public class CreateInventoryItemCommandValidator : AbstractValidator<CreateInventoryItemCommand>
{
public CreateInventoryItemCommandValidator()
{
RuleFor(x => x.WarehouseId)
.GreaterThan(0).WithMessage("شناسه انبار معتبر نیست");
RuleFor(x => x.Quantity)
.GreaterThanOrEqualTo(0).WithMessage("تعداد موجودی نمی‌تواند منفی باشد");
RuleFor(x => x.MinQuantity)
.GreaterThanOrEqualTo(0).WithMessage("حداقل موجودی نمی‌تواند منفی باشد");
RuleFor(x => x.MaxQuantity)
.GreaterThanOrEqualTo(0).WithMessage("حداکثر موجودی نمی‌تواند منفی باشد");
RuleFor(x => x)
.Must(x => x.ProductId.HasValue || x.DiscountProductId.HasValue)
.WithMessage("حداقل یکی از شناسه محصول یا شناسه محصول تخفیفی باید مشخص شود");
RuleFor(x => x)
.Must(x => !(x.ProductId.HasValue && x.DiscountProductId.HasValue))
.WithMessage("فقط یکی از شناسه محصول یا شناسه محصول تخفیفی باید مشخص شود");
}
}
@@ -1,7 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.CreateInventoryItem;
public class CreateInventoryItemResponseDto
{
/// <summary>شناسه آیتم موجودی ایجاد شده</summary>
public long Id { get; set; }
}
@@ -1,6 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.DeleteInventoryItem;
/// <summary>
/// Command برای حذف آیتم موجودی
/// </summary>
public record DeleteInventoryItemCommand(long Id) : IRequest<DeleteInventoryItemResponseDto>;
@@ -1,35 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.DeleteInventoryItem;
public class DeleteInventoryItemCommandHandler : IRequestHandler<DeleteInventoryItemCommand, DeleteInventoryItemResponseDto>
{
private readonly IApplicationDbContext _context;
public DeleteInventoryItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<DeleteInventoryItemResponseDto> Handle(DeleteInventoryItemCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
// بررسی اینکه موجودی رزرو نداشته باشد
if (item.ReservedQuantity > 0)
{
throw new InvalidOperationException("Cannot delete inventory item with reserved quantity");
}
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync(cancellationToken);
return new DeleteInventoryItemResponseDto { Success = true };
}
}
@@ -1,10 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.DeleteInventoryItem;
public class DeleteInventoryItemCommandValidator : AbstractValidator<DeleteInventoryItemCommand>
{
public DeleteInventoryItemCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
}
}
@@ -1,6 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.DeleteInventoryItem;
public class DeleteInventoryItemResponseDto
{
public bool Success { get; set; }
}
@@ -1,18 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.IncreaseInventory;
/// <summary>
/// Command برای اضافه کردن موجودی (خرید)
/// </summary>
public record IncreaseInventoryCommand : IRequest<IncreaseInventoryResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>تعداد افزایش</summary>
public int Quantity { get; init; }
/// <summary>شماره مرجع</summary>
public string? ReferenceNumber { get; init; }
/// <summary>شناسه کاربر انجام‌دهنده</summary>
public long? PerformedByUserId { get; init; }
/// <summary>یادداشت</summary>
public string? Note { get; init; }
}
@@ -1,72 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.IncreaseInventory;
public class IncreaseInventoryCommandHandler : IRequestHandler<IncreaseInventoryCommand, IncreaseInventoryResponseDto>
{
private readonly IApplicationDbContext _context;
public IncreaseInventoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<IncreaseInventoryResponseDto> Handle(IncreaseInventoryCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
var previousQuantity = item.Quantity;
item.Quantity += request.Quantity;
item.LastRestockedAt = DateTime.UtcNow;
// ثبت حرکت موجودی
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = StockMovementType.Restock,
Quantity = request.Quantity,
QuantityBefore = previousQuantity,
QuantityAfter = item.Quantity,
Note = request.Note ?? "Stock increased",
ReferenceNumber = request.ReferenceNumber ?? $"ADD-{DateTime.UtcNow:yyyyMMddHHmmss}",
PerformedByUserId = request.PerformedByUserId
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
// همگام‌سازی با Product.RemainingCount یا DiscountProduct.RemainingCount
if (item.ProductType == ProductType.RegularProduct && item.ProductId.HasValue)
{
var product = await _context.Products.FindAsync(new object[] { item.ProductId.Value }, cancellationToken);
if (product != null)
{
product.RemainingCount = item.Quantity;
}
}
else if (item.ProductType == ProductType.DiscountProduct && item.DiscountProductId.HasValue)
{
var discountProduct = await _context.DiscountProducts.FindAsync(
new object[] { item.DiscountProductId.Value }, cancellationToken);
if (discountProduct != null)
{
discountProduct.RemainingCount = item.Quantity;
}
}
await _context.SaveChangesAsync(cancellationToken);
return new IncreaseInventoryResponseDto
{
Success = true,
NewQuantity = item.Quantity
};
}
}
@@ -1,13 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.IncreaseInventory;
public class IncreaseInventoryCommandValidator : AbstractValidator<IncreaseInventoryCommand>
{
public IncreaseInventoryCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("تعداد افزایش باید بیشتر از صفر باشد");
}
}
@@ -1,7 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.IncreaseInventory;
public class IncreaseInventoryResponseDto
{
public bool Success { get; set; }
public int NewQuantity { get; set; }
}
@@ -1,22 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
/// <summary>
/// Command برای کم کردن موجودی (فروش)
/// </summary>
public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>تعداد کاهش</summary>
public int Quantity { get; init; }
/// <summary>شناسه سفارش عادی</summary>
public long? OrderId { get; init; }
/// <summary>شناسه سفارش تخفیفی</summary>
public long? DiscountOrderId { get; init; }
/// <summary>شماره مرجع</summary>
public string? ReferenceNumber { get; init; }
/// <summary>شناسه کاربر انجام‌دهنده</summary>
public long? PerformedByUserId { get; init; }
/// <summary>آیا از موجودی رزرو شده کم شود؟</summary>
public bool FromReserved { get; init; } = true;
}
@@ -1,92 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
public class ReduceInventoryCommandHandler : IRequestHandler<ReduceInventoryCommand, ReduceInventoryResponseDto>
{
private readonly IApplicationDbContext _context;
public ReduceInventoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ReduceInventoryResponseDto> Handle(ReduceInventoryCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
var previousQuantity = item.Quantity;
if (request.FromReserved)
{
// کم کردن از موجودی رزرو شده
if (item.ReservedQuantity < request.Quantity)
{
throw new InvalidOperationException($"Insufficient reserved stock. Reserved: {item.ReservedQuantity}, Requested: {request.Quantity}");
}
item.ReservedQuantity -= request.Quantity;
item.Quantity -= request.Quantity;
}
else
{
// کم کردن مستقیم از موجودی
if (item.AvailableQuantity < request.Quantity)
{
throw new InvalidOperationException($"Insufficient available stock. Available: {item.AvailableQuantity}, Requested: {request.Quantity}");
}
item.Quantity -= request.Quantity;
}
item.LastSoldAt = DateTime.UtcNow;
// ثبت حرکت موجودی
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = StockMovementType.Sale,
Quantity = request.Quantity,
QuantityBefore = previousQuantity,
QuantityAfter = item.Quantity,
Note = "Sale confirmed",
ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}",
OrderId = request.OrderId,
DiscountOrderId = request.DiscountOrderId,
PerformedByUserId = request.PerformedByUserId
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
// همگام‌سازی با Product.RemainingCount یا DiscountProduct.RemainingCount
if (item.ProductType == ProductType.RegularProduct && item.ProductId.HasValue)
{
var product = await _context.Products.FindAsync(new object[] { item.ProductId.Value }, cancellationToken);
if (product != null)
{
product.RemainingCount = item.Quantity;
}
}
else if (item.ProductType == ProductType.DiscountProduct && item.DiscountProductId.HasValue)
{
var discountProduct = await _context.DiscountProducts.FindAsync(
new object[] { item.DiscountProductId.Value }, cancellationToken);
if (discountProduct != null)
{
discountProduct.RemainingCount = item.Quantity;
}
}
await _context.SaveChangesAsync(cancellationToken);
return new ReduceInventoryResponseDto { Success = true };
}
}
@@ -1,13 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
public class ReduceInventoryCommandValidator : AbstractValidator<ReduceInventoryCommand>
{
public ReduceInventoryCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("تعداد کاهش باید بیشتر از صفر باشد");
}
}
@@ -1,6 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
public class ReduceInventoryResponseDto
{
public bool Success { get; set; }
}
@@ -1,20 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReleaseReservedInventory;
/// <summary>
/// Command برای آزاد کردن موجودی رزرو شده
/// </summary>
public record ReleaseReservedInventoryCommand : IRequest<ReleaseReservedInventoryResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>تعداد آزادسازی</summary>
public int Quantity { get; init; }
/// <summary>شناسه سفارش عادی</summary>
public long? OrderId { get; init; }
/// <summary>شناسه سفارش تخفیفی</summary>
public long? DiscountOrderId { get; init; }
/// <summary>شماره مرجع</summary>
public string? ReferenceNumber { get; init; }
/// <summary>شناسه کاربر انجام‌دهنده</summary>
public long? PerformedByUserId { get; init; }
}
@@ -1,53 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReleaseReservedInventory;
public class ReleaseReservedInventoryCommandHandler : IRequestHandler<ReleaseReservedInventoryCommand, ReleaseReservedInventoryResponseDto>
{
private readonly IApplicationDbContext _context;
public ReleaseReservedInventoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ReleaseReservedInventoryResponseDto> Handle(ReleaseReservedInventoryCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
if (item.ReservedQuantity < request.Quantity)
{
throw new InvalidOperationException($"Cannot release more than reserved. Reserved: {item.ReservedQuantity}, Requested: {request.Quantity}");
}
item.ReservedQuantity -= request.Quantity;
// ثبت حرکت موجودی
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = StockMovementType.Released,
Quantity = request.Quantity,
QuantityBefore = item.Quantity,
QuantityAfter = item.Quantity,
Note = "Reservation released",
ReferenceNumber = request.ReferenceNumber ?? $"REL-{DateTime.UtcNow:yyyyMMddHHmmss}",
OrderId = request.OrderId,
DiscountOrderId = request.DiscountOrderId,
PerformedByUserId = request.PerformedByUserId
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return new ReleaseReservedInventoryResponseDto { Success = true };
}
}
@@ -1,13 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReleaseReservedInventory;
public class ReleaseReservedInventoryCommandValidator : AbstractValidator<ReleaseReservedInventoryCommand>
{
public ReleaseReservedInventoryCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("تعداد آزادسازی باید بیشتر از صفر باشد");
}
}
@@ -1,6 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReleaseReservedInventory;
public class ReleaseReservedInventoryResponseDto
{
public bool Success { get; set; }
}
@@ -1,20 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReserveInventory;
/// <summary>
/// Command برای رزرو کردن موجودی
/// </summary>
public record ReserveInventoryCommand : IRequest<ReserveInventoryResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>تعداد رزرو</summary>
public int Quantity { get; init; }
/// <summary>شناسه سفارش عادی</summary>
public long? OrderId { get; init; }
/// <summary>شناسه سفارش تخفیفی</summary>
public long? DiscountOrderId { get; init; }
/// <summary>شماره مرجع</summary>
public string? ReferenceNumber { get; init; }
/// <summary>شناسه کاربر انجام‌دهنده</summary>
public long? PerformedByUserId { get; init; }
}
@@ -1,65 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReserveInventory;
public class ReserveInventoryCommandHandler : IRequestHandler<ReserveInventoryCommand, ReserveInventoryResponseDto>
{
private readonly IApplicationDbContext _context;
public ReserveInventoryCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ReserveInventoryResponseDto> Handle(ReserveInventoryCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
var availableQuantity = item.AvailableQuantity;
if (availableQuantity < request.Quantity)
{
return new ReserveInventoryResponseDto
{
Success = false,
Message = $"Insufficient stock. Available: {availableQuantity}, Requested: {request.Quantity}",
AvailableQuantity = availableQuantity
};
}
item.ReservedQuantity += request.Quantity;
// ثبت حرکت موجودی
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = StockMovementType.Reserved,
Quantity = request.Quantity,
QuantityBefore = item.Quantity,
QuantityAfter = item.Quantity, // موجودی اصلی تغییر نمی‌کند، فقط رزرو می‌شود
Note = "Stock reserved",
ReferenceNumber = request.ReferenceNumber ?? $"RSV-{DateTime.UtcNow:yyyyMMddHHmmss}",
OrderId = request.OrderId,
DiscountOrderId = request.DiscountOrderId,
PerformedByUserId = request.PerformedByUserId
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return new ReserveInventoryResponseDto
{
Success = true,
Message = "Stock reserved successfully",
AvailableQuantity = item.AvailableQuantity
};
}
}
@@ -1,13 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReserveInventory;
public class ReserveInventoryCommandValidator : AbstractValidator<ReserveInventoryCommand>
{
public ReserveInventoryCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("تعداد رزرو باید بیشتر از صفر باشد");
}
}
@@ -1,8 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReserveInventory;
public class ReserveInventoryResponseDto
{
public bool Success { get; set; }
public string? Message { get; set; }
public int AvailableQuantity { get; set; }
}
@@ -1,16 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryItem;
/// <summary>
/// Command برای آپدیت آیتم موجودی
/// </summary>
public record UpdateInventoryItemCommand : IRequest<UpdateInventoryItemResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>حداقل موجودی (LowStockThreshold)</summary>
public int? MinimumStock { get; init; }
/// <summary>حداکثر موجودی (MaxStockLevel)</summary>
public int? MaximumStock { get; init; }
/// <summary>نقطه سفارش مجدد</summary>
public int? ReorderPoint { get; init; }
}
@@ -1,54 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryItem;
public class UpdateInventoryItemCommandHandler : IRequestHandler<UpdateInventoryItemCommand, UpdateInventoryItemResponseDto>
{
private readonly IApplicationDbContext _context;
public UpdateInventoryItemCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<UpdateInventoryItemResponseDto> Handle(UpdateInventoryItemCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
if (request.MinimumStock.HasValue)
{
item.LowStockThreshold = request.MinimumStock.Value;
}
if (request.MaximumStock.HasValue)
{
item.MaxStockLevel = request.MaximumStock.Value;
}
if (request.ReorderPoint.HasValue)
{
item.ReorderPoint = request.ReorderPoint.Value;
}
await _context.SaveChangesAsync(cancellationToken);
return new UpdateInventoryItemResponseDto
{
Id = item.Id,
ProductId = item.ProductId ?? item.DiscountProductId ?? 0,
WarehouseId = item.WarehouseId,
Quantity = item.Quantity,
ReservedQuantity = item.ReservedQuantity,
AvailableQuantity = item.AvailableQuantity,
MinimumStock = item.LowStockThreshold,
MaximumStock = item.MaxStockLevel,
ReorderPoint = item.ReorderPoint
};
}
}
@@ -1,22 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryItem;
public class UpdateInventoryItemCommandValidator : AbstractValidator<UpdateInventoryItemCommand>
{
public UpdateInventoryItemCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.MinimumStock)
.GreaterThanOrEqualTo(0).When(x => x.MinimumStock.HasValue)
.WithMessage("حداقل موجودی نمی‌تواند منفی باشد");
RuleFor(x => x.MaximumStock)
.GreaterThanOrEqualTo(0).When(x => x.MaximumStock.HasValue)
.WithMessage("حداکثر موجودی نمی‌تواند منفی باشد");
RuleFor(x => x.ReorderPoint)
.GreaterThanOrEqualTo(0).When(x => x.ReorderPoint.HasValue)
.WithMessage("نقطه سفارش مجدد نمی‌تواند منفی باشد");
}
}
@@ -1,14 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryItem;
public class UpdateInventoryItemResponseDto
{
public long Id { get; set; }
public long ProductId { get; set; }
public long WarehouseId { get; set; }
public int Quantity { get; set; }
public int ReservedQuantity { get; set; }
public int AvailableQuantity { get; set; }
public int MinimumStock { get; set; }
public int MaximumStock { get; set; }
public int ReorderPoint { get; set; }
}
@@ -1,18 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryQuantity;
/// <summary>
/// Command برای آپدیت کردن موجودی یک آیتم
/// </summary>
public record UpdateInventoryQuantityCommand : IRequest<UpdateInventoryQuantityResponseDto>
{
/// <summary>شناسه آیتم موجودی</summary>
public long Id { get; init; }
/// <summary>تعداد جدید موجودی</summary>
public int NewQuantity { get; init; }
/// <summary>شماره مرجع</summary>
public string? ReferenceNumber { get; init; }
/// <summary>شناسه کاربر انجام‌دهنده</summary>
public long? PerformedByUserId { get; init; }
/// <summary>یادداشت</summary>
public string? Note { get; init; }
}
@@ -1,56 +0,0 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryQuantity;
public class UpdateInventoryQuantityCommandHandler : IRequestHandler<UpdateInventoryQuantityCommand, UpdateInventoryQuantityResponseDto>
{
private readonly IApplicationDbContext _context;
public UpdateInventoryQuantityCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<UpdateInventoryQuantityResponseDto> Handle(UpdateInventoryQuantityCommand request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.FirstOrDefaultAsync(i => i.Id == request.Id, cancellationToken);
if (item == null)
{
throw new NotFoundException(nameof(Domain.Entities.InventoryItem), request.Id);
}
var previousQuantity = item.Quantity;
var difference = request.NewQuantity - previousQuantity;
item.Quantity = request.NewQuantity;
// ثبت حرکت موجودی
var movementType = difference > 0 ? StockMovementType.AdjustmentPlus : StockMovementType.AdjustmentMinus;
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = movementType,
Quantity = Math.Abs(difference),
QuantityBefore = previousQuantity,
QuantityAfter = request.NewQuantity,
Note = request.Note ?? "Manual quantity adjustment",
ReferenceNumber = request.ReferenceNumber ?? $"ADJ-{DateTime.UtcNow:yyyyMMddHHmmss}",
PerformedByUserId = request.PerformedByUserId
};
await _context.StockMovements.AddAsync(stockMovement, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return new UpdateInventoryQuantityResponseDto
{
Success = true,
PreviousQuantity = previousQuantity,
NewQuantity = request.NewQuantity
};
}
}
@@ -1,13 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryQuantity;
public class UpdateInventoryQuantityCommandValidator : AbstractValidator<UpdateInventoryQuantityCommand>
{
public UpdateInventoryQuantityCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("شناسه آیتم موجودی معتبر نیست");
RuleFor(x => x.NewQuantity)
.GreaterThanOrEqualTo(0).WithMessage("تعداد موجودی نمی‌تواند منفی باشد");
}
}
@@ -1,8 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.UpdateInventoryQuantity;
public class UpdateInventoryQuantityResponseDto
{
public bool Success { get; set; }
public int PreviousQuantity { get; set; }
public int NewQuantity { get; set; }
}
@@ -1,20 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetAllInventoryItems;
/// <summary>
/// Query برای جستجوی آیتم های موجودی
/// </summary>
public record GetAllInventoryItemsQuery : IRequest<GetAllInventoryItemsResponseDto>
{
public long? WarehouseId { get; init; }
public long? ProductId { get; init; }
public long? DiscountProductId { get; init; }
public ProductType? ProductType { get; init; }
public string? SearchTerm { get; init; }
public bool? IsActive { get; init; }
public bool? IsLowStock { get; init; }
public bool? IsOutOfStock { get; init; }
public int Skip { get; init; } = 0;
public int Take { get; init; } = 50;
}
@@ -1,97 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetAllInventoryItems;
public class GetAllInventoryItemsQueryHandler : IRequestHandler<GetAllInventoryItemsQuery, GetAllInventoryItemsResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllInventoryItemsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllInventoryItemsResponseDto> Handle(GetAllInventoryItemsQuery request, CancellationToken cancellationToken)
{
var query = _context.InventoryItems
.AsNoTracking()
.Include(i => i.Warehouse)
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.AsQueryable();
// فیلترها
if (request.WarehouseId.HasValue)
{
query = query.Where(i => i.WarehouseId == request.WarehouseId.Value);
}
if (request.ProductId.HasValue)
{
query = query.Where(i => i.ProductId == request.ProductId.Value);
}
if (request.DiscountProductId.HasValue)
{
query = query.Where(i => i.DiscountProductId == request.DiscountProductId.Value);
}
if (request.ProductType.HasValue)
{
query = query.Where(i => i.ProductType == request.ProductType.Value);
}
if (!string.IsNullOrEmpty(request.SearchTerm))
{
query = query.Where(i =>
(i.Product != null && i.Product.Title.Contains(request.SearchTerm)) ||
(i.DiscountProduct != null && i.DiscountProduct.Title.Contains(request.SearchTerm)));
}
if (request.IsActive.HasValue)
{
// فعلاً بدون فیلتر IsActive چون entity این فیلد رو نداره
}
if (request.IsLowStock == true)
{
query = query.Where(i => i.Quantity <= i.LowStockThreshold);
}
if (request.IsOutOfStock == true)
{
query = query.Where(i => i.AvailableQuantity <= 0);
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(i => i.Created)
.Skip(request.Skip)
.Take(request.Take)
.Select(i => new InventoryItemListDto
{
Id = i.Id,
ProductId = i.ProductId,
DiscountProductId = i.DiscountProductId,
ProductType = i.ProductType,
WarehouseId = i.WarehouseId,
WarehouseName = i.Warehouse != null ? i.Warehouse.Name : null,
ProductTitle = i.Product != null ? i.Product.Title : (i.DiscountProduct != null ? i.DiscountProduct.Title : null),
ProductPrice = i.Product != null ? i.Product.Price : (i.DiscountProduct != null ? i.DiscountProduct.Price : 0),
Quantity = i.Quantity,
ReservedQuantity = i.ReservedQuantity,
AvailableQuantity = i.AvailableQuantity,
LowStockThreshold = i.LowStockThreshold,
MaxStockLevel = i.MaxStockLevel,
LastRestockedAt = i.LastRestockedAt,
LastSoldAt = i.LastSoldAt,
Created = i.Created
})
.ToListAsync(cancellationToken);
return new GetAllInventoryItemsResponseDto
{
Items = items,
TotalCount = totalCount
};
}
}
@@ -1,30 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetAllInventoryItems;
public class GetAllInventoryItemsResponseDto
{
public List<InventoryItemListDto> Items { get; set; } = new();
public int TotalCount { get; set; }
}
public class InventoryItemListDto
{
public long Id { get; set; }
public long? ProductId { get; set; }
public long? DiscountProductId { get; set; }
public ProductType ProductType { get; set; }
public long WarehouseId { get; set; }
public string? WarehouseName { get; set; }
public string? ProductTitle { get; set; }
public decimal? ProductPrice { get; set; }
public int Quantity { get; set; }
public int ReservedQuantity { get; set; }
public int AvailableQuantity { get; set; }
public int LowStockThreshold { get; set; }
public int MaxStockLevel { get; set; }
public DateTime? LastRestockedAt { get; set; }
public DateTime? LastSoldAt { get; set; }
public bool IsActive { get; set; }
public DateTime Created { get; set; }
}
@@ -1,16 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryByProduct;
/// <summary>
/// Query برای دریافت آیتم موجودی با ProductId یا DiscountProductId
/// </summary>
public record GetInventoryByProductQuery : IRequest<GetInventoryByProductResponseDto?>
{
/// <summary>شناسه محصول</summary>
public long ProductId { get; init; }
/// <summary>نوع محصول</summary>
public ProductType ProductType { get; init; }
/// <summary>شناسه انبار (اختیاری)</summary>
public long? WarehouseId { get; init; }
}
@@ -1,63 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryByProduct;
public class GetInventoryByProductQueryHandler : IRequestHandler<GetInventoryByProductQuery, GetInventoryByProductResponseDto?>
{
private readonly IApplicationDbContext _context;
public GetInventoryByProductQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetInventoryByProductResponseDto?> Handle(GetInventoryByProductQuery request, CancellationToken cancellationToken)
{
var query = _context.InventoryItems
.AsNoTracking()
.Include(i => i.Warehouse)
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.AsQueryable();
// فیلتر بر اساس نوع محصول
if (request.ProductType == ProductType.RegularProduct)
{
query = query.Where(i => i.ProductId == request.ProductId);
}
else
{
query = query.Where(i => i.DiscountProductId == request.ProductId);
}
// فیلتر بر اساس انبار (اختیاری)
if (request.WarehouseId.HasValue)
{
query = query.Where(i => i.WarehouseId == request.WarehouseId.Value);
}
var item = await query
.Select(i => new GetInventoryByProductResponseDto
{
Id = i.Id,
ProductId = i.ProductId,
DiscountProductId = i.DiscountProductId,
ProductType = i.ProductType,
WarehouseId = i.WarehouseId,
WarehouseName = i.Warehouse != null ? i.Warehouse.Name : null,
ProductTitle = i.Product != null ? i.Product.Title : (i.DiscountProduct != null ? i.DiscountProduct.Title : null),
ProductPrice = i.Product != null ? i.Product.Price : (i.DiscountProduct != null ? i.DiscountProduct.Price : 0),
Quantity = i.Quantity,
ReservedQuantity = i.ReservedQuantity,
AvailableQuantity = i.AvailableQuantity,
LowStockThreshold = i.LowStockThreshold,
MaxStockLevel = i.MaxStockLevel,
LastRestockedAt = i.LastRestockedAt,
LastSoldAt = i.LastSoldAt,
Created = i.Created
})
.FirstOrDefaultAsync(cancellationToken);
return item;
}
}
@@ -1,23 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryByProduct;
public class GetInventoryByProductResponseDto
{
public long Id { get; set; }
public long? ProductId { get; set; }
public long? DiscountProductId { get; set; }
public ProductType ProductType { get; set; }
public long WarehouseId { get; set; }
public string? WarehouseName { get; set; }
public string? ProductTitle { get; set; }
public decimal? ProductPrice { get; set; }
public int Quantity { get; set; }
public int ReservedQuantity { get; set; }
public int AvailableQuantity { get; set; }
public int LowStockThreshold { get; set; }
public int MaxStockLevel { get; set; }
public DateTime? LastRestockedAt { get; set; }
public DateTime? LastSoldAt { get; set; }
public DateTime Created { get; set; }
}
@@ -1,6 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryItem;
/// <summary>
/// Query برای دریافت آیتم موجودی با شناسه
/// </summary>
public record GetInventoryItemQuery(long Id) : IRequest<GetInventoryItemResponseDto?>;
@@ -1,43 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryItem;
public class GetInventoryItemQueryHandler : IRequestHandler<GetInventoryItemQuery, GetInventoryItemResponseDto?>
{
private readonly IApplicationDbContext _context;
public GetInventoryItemQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetInventoryItemResponseDto?> Handle(GetInventoryItemQuery request, CancellationToken cancellationToken)
{
var item = await _context.InventoryItems
.AsNoTracking()
.Include(i => i.Warehouse)
.Include(i => i.Product)
.Include(i => i.DiscountProduct)
.Where(i => i.Id == request.Id)
.Select(i => new GetInventoryItemResponseDto
{
Id = i.Id,
ProductId = i.ProductId,
DiscountProductId = i.DiscountProductId,
ProductType = i.ProductType,
WarehouseId = i.WarehouseId,
WarehouseName = i.Warehouse != null ? i.Warehouse.Name : null,
ProductTitle = i.Product != null ? i.Product.Title : (i.DiscountProduct != null ? i.DiscountProduct.Title : null),
ProductPrice = i.Product != null ? i.Product.Price : (i.DiscountProduct != null ? i.DiscountProduct.Price : 0),
Quantity = i.Quantity,
ReservedQuantity = i.ReservedQuantity,
AvailableQuantity = i.AvailableQuantity,
LowStockThreshold = i.LowStockThreshold,
MaxStockLevel = i.MaxStockLevel,
LastRestockedAt = i.LastRestockedAt,
LastSoldAt = i.LastSoldAt,
Created = i.Created
})
.FirstOrDefaultAsync(cancellationToken);
return item;
}
}
@@ -1,23 +0,0 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetInventoryItem;
public class GetInventoryItemResponseDto
{
public long Id { get; set; }
public long? ProductId { get; set; }
public long? DiscountProductId { get; set; }
public ProductType ProductType { get; set; }
public long WarehouseId { get; set; }
public string? WarehouseName { get; set; }
public string? ProductTitle { get; set; }
public decimal? ProductPrice { get; set; }
public int Quantity { get; set; }
public int ReservedQuantity { get; set; }
public int AvailableQuantity { get; set; }
public int LowStockThreshold { get; set; }
public int MaxStockLevel { get; set; }
public DateTime? LastRestockedAt { get; set; }
public DateTime? LastSoldAt { get; set; }
public DateTime Created { get; set; }
}
@@ -1,10 +0,0 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Queries.GetLowStockItems;
/// <summary>
/// Query برای دریافت آیتم های کم موجود
/// </summary>
public record GetLowStockItemsQuery : IRequest<GetLowStockItemsResponseDto>
{
public long? WarehouseId { get; init; }
public int Count { get; init; } = 50;
}

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