139 Commits

Author SHA1 Message Date
masoodafar-web b42d9e141d feat: Implement file management and authorization features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m9s
- Add RequiresPermissionAttribute for gRPC method access control.
- Create IFileManagementService interface for file upload and management.
- Implement AddProductImageCommand and handler for adding product images.
- Implement CreateNewProductsCommand and handler for creating new products with image uploads.
- Implement DeleteProductsCommand and handler for deleting products and their associations.
- Implement RemoveProductImageCommand and handler for removing product images from galleries.
- Implement UpdateProductsCommand and handler for updating product details and images.
- Create GetProductGalleryQuery and handler for retrieving product galleries.
- Implement PermissionService for role-based access control using JWT claims.
- Implement FileManagementService for handling file uploads and image optimization.
- Define gRPC service and messages for file management in fms.proto.
- Add FluentValidation for request validation in various commands.
- Create PermissionInterceptor for enforcing permissions on gRPC methods.
2026-02-10 22:04:54 +03:30
masoodafar-web f64b6be7da feat: Implement GetMyWeeklyBalances query and handler, enhance ClubMembership service with JWT userId handling 2026-02-08 00:24:59 +03:30
masoodafar-web 9185aa227d feat: Add discount balance and change value to wallet change log
- Added CurrentDiscountBalance and ChangeDiscountValue properties to GetCustomerWalletChangeLogResponseDto.
- Updated userwallet.proto to include current_discount_balance and change_discount_value fields.
- Enhanced CommissionProfile mapping to support GetMyCommissionPayouts requests and responses.
- Implemented GetMyCommissionPayouts query and handler to retrieve user commission payouts.
- Added validation for GetMyCommissionPayouts query.
- Updated UserOrderService to handle user orders, including VAT calculations and wallet transactions.
- Enhanced UserWalletService to include new properties in wallet change log responses.
2026-02-07 22:03:49 +03:30
masoodafar-web b2d676b555 feat: Implement customer profile and referral queries
- Add GetCustomerProfileResponseDto for retrieving customer profile information.
- Create GetCustomerReferralsQuery and GetCustomerReferralsQueryHandler to fetch customer referrals with pagination and filtering options.
- Introduce GetCustomerReferralsResponseDto to structure the response for customer referrals.
- Implement GetCustomerSettingsQuery and GetCustomerSettingsQueryHandler to retrieve user settings.
- Add GetCustomerOrder and GetCustomerOrderQueryHandler for fetching specific customer orders.
- Create GetCustomerOrderHistoryQuery and GetCustomerOrderHistoryQueryHandler to retrieve order history with filtering options.
- Implement GetCustomerOrdersQuery and GetCustomerOrdersQueryHandler for fetching multiple customer orders with filters.
- Add GetCustomerWalletChangeLogQuery and GetCustomerWalletChangeLogQueryHandler for retrieving wallet change logs.
- Implement GetCustomerWithdrawalSettingsQuery and GetCustomerWithdrawalSettingsQueryHandler for fetching withdrawal settings.
- Create GetCustomerWithdrawalsQuery and GetCustomerWithdrawalsQueryHandler to retrieve customer withdrawal requests.
2026-02-05 23:01:50 +03:30
masoodafar-web b41342dcad feat: implement JWT token generation in VerifyOtpTokenCommandHandler and update related configurations 2026-02-03 00:02:04 +03:30
masoodafar-web c3eeb16856 refactor: rename response properties in PackageService for consistency 2026-02-01 22:25:25 +03:30
masoodafar-web 794dd01ac0 feat: update Protobuf definitions and add customer-facing APIs for various services 2026-02-01 22:16:36 +03:30
masoodafar-web 658d076bdf Complete FrontOffice BFF to CMS Migration
- Migrated all 9 services from FrontOffice.BFF to CMS architecture
- Enhanced user.proto with 7 additional Customer API endpoints:
  * UpdateCustomerProfile, GetCustomerProfile
  * ChangeCustomerPassword with validation
  * GetCustomerReferrals with commission stats
  * UploadCustomerAvatar with file validation
  * GetCustomerSettings, UpdateCustomerSettings
- All services now support Customer endpoints with /Customer/ prefix
- Mock implementations with realistic Persian data
- Fixed namespace conflicts and compilation issues
- Comprehensive testing completed for all endpoints
- Services migrated: Categories, City, UserCarts, Products, UserWallet,
  Transaction, UserOrder, Package, User (enhanced)
2026-01-30 08:53:09 +03:30
masoodafar-web 96daf899c7 fix: increase Docker daemon startup timeout to 3 minutes
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m12s
2026-01-29 17:13:41 +03:30
masoodafar-web eddc82dfce refactor: use docker-sshpass image (fully offline)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m23s
2026-01-29 06:49:26 +03:30
masoodafar-web 0c95b03f0b fix: ensure sshpass is installed only if not present before deployment
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 06:05:46 +03:30
masoodafar-web 45c01fc157 refactor: merge two jobs into one pipeline
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 06:00:45 +03:30
masoodafar-web 55a775f51c perf: remove unnecessary apt-get install git
Build and Deploy to Kubernetes / publish-packages (push) Successful in 1m1s
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 05:57:07 +03:30
masoodafar-web 5b0558ee24 fix: streamline Kubernetes deployment process by removing SSH and adding kubectl installation
Build and Deploy to Kubernetes / publish-packages (push) Successful in 1m13s
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 05:54:44 +03:30
masoodafar-web cabf3886de fix: use rollout restart for simpler deployment
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Build and Deploy to Kubernetes / publish-packages (push) Has been cancelled
2026-01-29 05:53:08 +03:30
masoodafar-web 0baed05fd4 fix: use cached docker:dind from Nexus
Build and Deploy to Kubernetes / publish-packages (push) Successful in 49s
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 05:41:09 +03:30
masoodafar-web 7c4c978d3f trigger: re-run pipeline with deploy step
Build and Deploy to Kubernetes / publish-packages (push) Successful in 1m39s
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 10s
2026-01-29 05:20:24 +03:30
masoodafar-web e327eb23ba feat: add kubernetes deployment step via SSH
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Build and Deploy to Kubernetes / publish-packages (push) Has been cancelled
2026-01-29 05:18:47 +03:30
masoodafar-web 20280cf07f fix: proper restore and disable auto-push in CI
Build and Deploy to Kubernetes / publish-packages (push) Successful in 1m33s
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m7s
- Add restore step before build
- Add CI=true env to disable PushToFoursatNuget target
- Add --allow-insecure-connections to csproj push command
- Add Condition to skip push target in CI
2026-01-29 04:59:48 +03:30
masoodafar-web c67caca0ae fix: add --allow-insecure-connections to nuget push
Build and Deploy to Kubernetes / publish-packages (push) Failing after 1m6s
Build and Deploy to Kubernetes / build-and-deploy (push) Has been skipped
2026-01-29 04:56:40 +03:30
masoodafar-web e14a653fa8 feat: add publish-packages job to CI/CD workflow
Build and Deploy to Kubernetes / publish-packages (push) Failing after 1m6s
Build and Deploy to Kubernetes / build-and-deploy (push) Has been skipped
- Auto-detect and pack *Protobuf*.csproj files
- Push to Nexus with --skip-duplicate
- Build job now depends on publish-packages
2026-01-29 04:44:39 +03:30
masoodafar-web 4968878773 Revert: use only Nexus (no fallback)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m29s
2026-01-29 03:44:38 +03:30
masoodafar-web 8ffb53996b Add nuget.org as fallback source
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 03:41:15 +03:30
masoodafar-web 1a01fd9057 Remove afrino NuGet sources - use only Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 4m24s
2026-01-29 03:14:38 +03:30
masoodafar-web 52af1bfaad chore: تنظیم Nexus برای NuGet و Docker offline
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m13s
- Dockerfile: استفاده از Nexus Docker (194.5.195.53:32082)
- NuGet.config: استفاده از Nexus NuGet با HTTP و allowInsecureConnections
- workflow: حذف kubectl dependency، اضافه کردن 32082 به insecure-registries
- csproj: تغییر push آدرس به Nexus foursat-nuget-hosted
2026-01-29 02:56:14 +03:30
masoodafar-web 2fd5300c1e fix: Add allowInsecureConnections attribute to Nexus package source in NuGet.config
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 02:48:03 +03:30
masoodafar-web c76af47a4c fix: Update Nexus package source URL in NuGet.config
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 45s
2026-01-29 02:46:43 +03:30
masoodafar-web 77be841221 fix: Add NuGet.config for package source configuration and update restore command in Dockerfile
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m5s
2026-01-29 02:44:55 +03:30
masoodafar-web 4743b52cae fix: Remove dependency installation step for offline builds in kub-deploy.yml
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m4s
2026-01-29 02:41:22 +03:30
masoodafar-web 0b0fa3aa61 fix: Simplify kubectl installation and update deployment messages in kub-deploy.yml
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-29 02:36:21 +03:30
masoodafar-web 839250e132 fix: Update Dockerfile to use internal registry for .NET SDK and ASP.NET images
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 10s
2026-01-29 02:25:38 +03:30
masoodafar-web 8894e72a8e fix: Update kubectl installation to use a fixed version instead of fetching stable version
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m9s
2026-01-29 02:20:40 +03:30
masoodafar-web 1c130b14e1 fix: Update Dockerfile to use .NET 9.0 images from internal registry
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11s
2026-01-29 02:15:45 +03:30
masoodafar-web 793d81f1dd fix: Remove NotEmpty validation for ShortInfomation and FullInformation in discount product validators
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m48s
2026-01-05 01:43:23 +03:30
masoodafar-web f6704eaa99 fix: Implement product lookup in RecordLoss method to accurately reduce inventory on loss
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m50s
2026-01-05 00:58:52 +03:30
masoodafar-web d03fbe0a98 fix: Remove RemainingCount property from CreateDiscountProduct and related commands, initializing inventory with zero
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m56s
2026-01-04 23:51:35 +03:30
masoodafar-web f9c90bb871 fix: Synchronize RemainingCount for Regular and Discount products in inventory commands
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m40s
2026-01-04 23:29:02 +03:30
masoodafar-web 70c4b15a23 fix: Refactor AdjustStock method to streamline inventory updates and improve response structure
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m18s
2026-01-04 22:58:25 +03:30
masoodafar-web 5e662b5984 fix: Implement product lookup for AddStock and AdjustStock methods in InventoryService
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-04 22:38:46 +03:30
masoodafar-web 16ba2fa6fb fix: Update sorting and calculation of available quantity in GetLowStockItems query
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m46s
2026-01-04 22:22:42 +03:30
masoodafar-web 389568af2b fix: Reset wallet balance and discount balance to zero in DayaLoan processing
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m7s
2026-01-04 21:46:37 +03:30
Admin 98f3036bd0 fix: Remove domain from insecure-registries, use IP only
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m8s
2026-01-03 15:15:24 +00:00
masoodafar-web 631646776e u
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-01-03 18:41:39 +03:30
masoodafar-web d8031a0d17 fix: update registry URL in deployment workflows to the correct address
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 5m58s
2026-01-03 17:28:10 +03:30
masoodafar-web ca9b02a902 chore: update Protobuf project version to 0.0.169
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m4s
2026-01-03 15:38:33 +03:30
masoodafar-web dde4c68b2f feat: Implement inventory and warehouse management features
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m33s
- Add GetLowStockItemsResponseDto and LowStockItemDto for low stock item queries.
- Create CreateStockMovementCommand and its handler for managing stock movements.
- Implement CreateStockMovementCommandValidator for validating stock movement commands.
- Add GetStockMovementsQuery and its handler to retrieve stock movement records.
- Create GetStockMovementsResponseDto and StockMovementListDto for stock movement responses.
- Implement GetStockMovementsByInventoryItemQuery and its handler for fetching movements by inventory item.
- Add CreateWarehouseCommand and its handler for creating new warehouses.
- Implement CreateWarehouseCommandValidator for warehouse creation validation.
- Add DeleteWarehouseCommand and its handler for removing warehouses.
- Implement SetDefaultWarehouseCommand and its handler for setting a default warehouse.
- Create UpdateWarehouseCommand and its handler for updating warehouse details.
- Implement GetAllWarehousesQuery and its handler to retrieve all warehouses.
- Add GetWarehouseQuery and its handler for fetching a specific warehouse by ID.
- Implement SearchWarehousesQuery and its handler for searching warehouses based on criteria.
2026-01-03 11:59:08 +03:30
masoodafar-web 8c3d710253 feat(mappings): expand InventoryProfile with comprehensive CQRS mappings for warehouses and inventory items
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m37s
2026-01-03 10:05:47 +03:30
masoodafar-web a81fb39a32 feat(mappings): add InventoryProfile for mapping Inventory entities to Protobuf DTOs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m41s
2026-01-03 09:02:09 +03:30
masoodafar-web f8731bc7fc feat: register inventory-related repositories and services in ConfigureServices
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m39s
2026-01-03 08:43:22 +03:30
masoodafar-web d3d021c007 feat(migrations): add ImageDocumentId column to ManualPayments table
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m36s
feat(mappings): implement DiscountCategoryProfile for mapping between application DTOs and protobuf responses
2026-01-03 07:37:45 +03:30
masoodafar-web bea43a0569 Add ImagePath column to ManualPayments table in CMS schema
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m52s
2026-01-02 02:13:57 +03:30
masoodafar-web 1cf501711f Implement Inventory Management Service with CRUD operations for warehouses and inventory items, stock operations, and bulk processing capabilities.
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m48s
2026-01-02 00:45:37 +03:30
masoodafar-web f0117eb1d5 Add migration for DiscountProductImages table
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m53s
- Created a new table `DiscountProductImages` in the CMS schema.
- Added columns for image details including `Title`, `AltText`, `ImagePath`, `ThumbnailPath`, `SortOrder`, `IsActive`, `Created`, `CreatedBy`, `LastModified`, `LastModifiedBy`, and `IsDeleted`.
- Established a foreign key relationship with the `DiscountProducts` table.
- Created indexes on `DiscountProductId` and a composite index on `DiscountProductId` and `SortOrder`.
2026-01-01 02:26:33 +03:30
masoodafar-web 500e169141 feat: add Id property to WeekDefinitionItemDto and map it in CommissionProfile - Enhanced week definition handling by including the Id in the DTO and mapping configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m51s
2025-12-28 22:21:43 +03:30
masoodafar-web ed45a1759b feat: update user weekly balances model and mappings - Enhanced UserWeeklyBalanceModel with new properties and updated mappings in response DTO and profile
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m34s
2025-12-27 21:52:42 +03:30
masoodafar-web 7087df23ef feat: remove CheckDayaLoanStatusCommandHandler and ProcessDayaLoanApprovalCommandHandler - Deleted unused command handlers to streamline codebase
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m48s
2025-12-27 06:40:30 +03:30
masoodafar-web 6374d5c343 feat: implement CheckDayaLoanStatusCommandHandler - Added handler for checking Daya loan status and updating contracts, including error handling and logging
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m10s
2025-12-27 06:21:52 +03:30
masoodafar-web 1dbb3ee502 feat: implement unified command for Daya loan status check and processing - Refactored DayaLoanCheckWorker to use CheckAndProcessDayaLoansCommand, enhancing functionality and logging
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m50s
2025-12-27 02:30:40 +03:30
masoodafar-web cd2549775a feat: add background job configuration for weekly commission calculation - Updated appsettings and Program.cs to enable configurable scheduling for weekly commission jobs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m9s
2025-12-27 01:37:43 +03:30
masoodafar-web 322242fbab feat: add mapping profile for AppVersion - Implemented mappings for AppVersionItemDto to proto messages and response creation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m40s
2025-12-26 09:36:21 +03:30
masoodafar-web 3d42c91d21 feat: add SMS notification for Daya loan approval - Implemented SMS sending functionality and created SmsTemplates for message formatting
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m11s
2025-12-26 09:20:15 +03:30
masoodafar-web 9448f3fff0 refactor: remove LockedWalletAmount from DayaLoan processing logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m56s
2025-12-26 09:07:28 +03:30
masoodafar-web 02e2f8111f feat: implement Kavenegar SMS service and refactor system configurations to use static constants
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m38s
2025-12-26 09:04:27 +03:30
masoodafar-web 9d2b5ad2d4 fix: update database connection and remove production config
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m4s
2025-12-26 06:31:19 +03:30
masoodafar-web 8940362575 fix: update MaxDepth validation range to allow values between 1 and 100 - Bump Protobuf version to 0.0.159
Build and Deploy to Production / build-and-deploy (push) Successful in 3m8s
2025-12-25 19:30:06 +03:30
masoodafar-web bca3b7fe62 feat: add ReferralCode to NetworkTree nodes - Added ReferralCode to SP_GetNetworkTree stored procedure - Added ReferralCode property to NetworkTreeDto and NetworkTreeNodeDto - Added referral_code field to networkmembership.proto - Updated Handler and Profile mappings
Build and Deploy to Production / build-and-deploy (push) Successful in 1m45s
2025-12-25 19:21:36 +03:30
masoodafar-web c03b3705c3 fix(NetworkTree): Use Convert.ToBoolean for IsClubActive and IsActivatedInTargetWeek
Build and Deploy to Production / build-and-deploy (push) Successful in 2m27s
SP returns BIT which maps to Boolean in C#, not Int32
2025-12-25 19:13:06 +03:30
masoodafar-web a9140bec04 chore: bump CMSMicroservice.Protobuf to 0.0.158
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2025-12-25 19:08:51 +03:30
masoodafar-web 17c2958b9c feat(AppVersion): Add version tracking system for frontend cache invalidation
Build and Deploy to Production / build-and-deploy (push) Successful in 1m37s
- Add AppVersion entity with CurrentVersion, MinRequiredVersion, RequiresFullCacheClear
- Add GetAppVersion query to check app version and compare with client version
- Add GetAllAppVersions query for admin panel
- Add UpdateAppVersion command to update/create app versions
- Add appversion.proto for gRPC communication
- Add AppVersionService for gRPC endpoints
- Add database migration for AppVersions table
2025-12-25 18:06:12 +03:30
masoodafar-web 2e54aa7aca feat: update boolean handling for database compatibility
Build and Deploy to Production / build-and-deploy (push) Successful in 1m45s
2025-12-25 02:34:06 +03:30
masoodafar-web 7b26073c63 update
Build and Deploy to Production / build-and-deploy (push) Successful in 1m40s
2025-12-25 01:24:58 +03:30
masoodafar-web 28e94d6137 feat: update appsettings for production environment and clear default connection string 2025-12-25 01:24:58 +03:30
masoodafar-web c6ee356cbd update 2025-12-25 01:24:58 +03:30
masoodafar-web 2d09a69be9 feat: add DatabaseFacade property to IApplicationDbContext for raw SQL access 2025-12-25 01:24:58 +03:30
masoud 7cebb27171 Update src/CMSMicroservice.Infrastructure/BackgroundJobs/ChatikaAccountActivationJob.cs 2025-12-25 01:24:58 +03:30
masoodafar-web 68489a8374 feat: update database connection string in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 330f0cae5a feat: enable Chatika integration in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web b16f01b6e4 chore: fix trailing whitespace in appsettings.json 2025-12-25 01:24:58 +03:30
masoodafar-web 13f24d523b feat: refine user filtering logic in Chatika account activation and Daya loan check jobs 2025-12-25 01:24:58 +03:30
admin 41308e189a Add production deployment workflow
Build and Deploy to Production / build-and-deploy (push) Successful in 1m46s
2025-12-23 20:16:13 +00:00
masoodafar-web 8953d86755 chore: fix trailing whitespace in appsettings.json 2025-12-23 23:31:47 +03:30
masoodafar-web ef8d5d4d97 feat: add configuration option to enable/disable Chatika integration 2025-12-23 23:09:18 +03:30
masoodafar-web 288b85a59b feat: implement Chatika account activation job for new club members 2025-12-23 22:52:49 +03:30
masoodafar-web 30144a07c8 feat: enhance club membership activation with pool and features 2025-12-23 00:02:14 +03:30
masoodafar-web 5bcf6c9840 feat: add network leg position assignment for new users 2025-12-22 22:39:15 +03:30
masoodafar-web 9d51e70a7f chore: fix trailing whitespace in appsettings.json 2025-12-20 06:29:47 +03:30
masoodafar-web a31c88219f chore: remove appsettings.Production.json file 2025-12-20 06:18:03 +03:30
masoodafar-web ceeb29d843 chore: add Seq API key in appsettings.json 2025-12-20 06:07:13 +03:30
masoodafar-web 0ba6af9049 chore: update Seq server URL in appsettings.json 2025-12-20 05:22:47 +03:30
masoodafar-web 5ad69cb3fa update 2025-12-20 04:54:59 +03:30
masoodafar-web e383c64e5b update
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:47:41 +03:30
masoodafar-web f5642f88fb update
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:33:25 +03:30
masoodafar-web 14b5db645b chore: format appsettings.json for consistency
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:30:45 +03:30
masoodafar-web 14b4022de9 chore: update database connection string in appsettings.json
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:25:54 +03:30
masoodafar-web d24f326fbc chore: update cms-stage workflow branch to stage_new
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:21:28 +03:30
masoodafar-web 50ba9e3794 chore: remove duplicate cms-stage workflow file 2025-12-20 04:21:15 +03:30
masoodafar-web c5915bb074 feat: add GitHub Actions workflow for NuGet and Docker image deployment
Push nuget and docker image Actions Workflow / Deploy (push) Has been cancelled
2025-12-20 04:19:22 +03:30
masoodafar-web 25476ba120 feat: implement commission calculation strategy with ORM and SP options
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 1m43s
2025-12-20 03:15:28 +03:30
masoodafar-web 9864302bab feat: implement user wallet charging and transaction logging for Daya loan processing 2025-12-19 23:14:42 +03:30
masoodafar-web 8b784101be feat: refactor week number handling to use WeekDefinitionId across multiple entities and queries 2025-12-19 10:41:22 +03:30
masoodafar-web 3cb95dc349 feat: enhance user club feature management with sorting and creation timestamps 2025-12-19 00:30:47 +03:30
masoodafar-web c7211fb9a7 feat: update entity configurations to specify column types for cities, states, and countries 2025-12-18 03:49:47 +03:30
masoodafar-web ead0cf4235 feat: add geography entities with countries, states and cities 2025-12-18 00:43:55 +03:30
masoodafar-web 26e1243cfe feat: add base package payment initiation and verification
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m39s
2025-12-16 02:34:57 +03:30
masoodafar-web 189812e874 feat: update Daya API base address to production
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m18s
2025-12-14 20:49:31 +03:30
masoodafar-web 730e5a252b feat: add target week activation flag to network membership
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m28s
2025-12-14 03:35:40 +03:30
masoodafar-web cf75f14003 feat: add club membership filters and activation details to network tree
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m13s
2025-12-14 02:56:04 +03:30
masoodafar-web 6ae1b0cd70 feat: update commission payout model with user names and timestamps
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m11s
2025-12-12 19:31:32 +03:30
masoodafar-web 2ae6034fbb refactor: remove admin user id from manual payment command
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m13s
2025-12-12 10:22:09 +03:30
masoodafar-web 094846ce8b feat: add manual membership payment processing with wallet and order management
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m8s
2025-12-12 10:07:14 +03:30
masoodafar-web b19cf5e32e fix: use Gregorian week number for API and Persian for display
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m18s
2025-12-12 09:39:20 +03:30
masoodafar-web b1c3fcfd66 feat: add Persian calendar support for week numbers and dates
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m35s
2025-12-12 09:18:31 +03:30
masoodafar-web 1f6c5a1f45 fix: update default sorting from descending to ascending Created
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m43s
2025-12-12 07:49:54 +03:30
masoodafar-web 99b217d5b5 fix: change weekly balance sorting from WeekNumber to Created
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m39s
2025-12-12 07:32:22 +03:30
masoodafar-web 8ee3fe6f7b fix: update week calculation to start from Saturday
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m2s
2025-12-12 07:07:28 +03:30
masoodafar-web 3c7ac68eeb feat: enhance network membership response with detailed stats and hierarchy
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m14s
2025-12-12 06:07:23 +03:30
masoodafar-web f27418cff4 fix: adjust network tree depth limit and update commission mapping
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m5s
2025-12-12 05:42:19 +03:30
masoodafar-web aba534e07c fix: update week calculation to use Saturday as the start of the week
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m12s
2025-12-12 04:37:34 +03:30
masoodafar-web af3a29ed27 fix: adjust week calculation to FirstDay and Saturday
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m9s
2025-12-12 04:23:14 +03:30
masoodafar-web db951699f8 fix: handle null pool and adjust week calculation rules
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2025-12-12 04:22:16 +03:30
masoodafar-web bb6b7c709c feat: add search text filter for user query
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m40s
2025-12-12 03:18:18 +03:30
masoodafar-web 12749ccb01 chore: bump version to 0.0.146
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m40s
2025-12-12 01:41:48 +03:30
masoodafar-web ff1c1d5d61 feat: add IsActive field to UserClubFeatures for admin management 2025-12-12 01:40:26 +03:30
masoodafar-web aa66ca10c8 feat: add Mapster mapping and update network tree request
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m11s
2025-12-08 04:10:46 +03:30
masoud 69d5ecf0d7 revert: Back to simple MigrateAsync - it's already idempotent
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m5s
MigrateAsync is smart enough to:
- Create DB if it doesn't exist
- Apply only pending migrations
- Skip if DB is up to date

The orphaned CMS.mdf files were cleaned from SQL Server.
2025-12-07 21:27:46 +00:00
masoud 1dbe90d020 fix: Use EnsureCreatedAsync instead of MigrateAsync for initial db setup
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m9s
- EnsureCreatedAsync creates db only if it doesn't exist
- Returns false if db already exists (idempotent)
- Prevents 'CMS.mdf already exists' error
- Still applies pending migrations when needed
2025-12-07 21:21:47 +00:00
masoud 7ec9c4077c fix: Improve database migration strategy - only migrate if needed
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m16s
- Check if database exists before creating
- Only apply pending migrations
- Skip migration if database is up to date
- Prevents 'file already exists' error on restart
2025-12-07 21:16:49 +00:00
masoud 70a06933a9 fix: update kubeconfig for deployment (127.0.0.1 → 194.5.195.53)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m21s
2025-12-07 19:16:58 +00:00
masoud d51ef47c16 docs: add multi-remote push note
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 2m15s
2025-12-07 18:50:16 +00:00
masoud d9ccfcc587 fix: replace tabs with spaces in workflow file
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m33s
2025-12-07 18:00:58 +00:00
masoud 9e3e0c68c2 trigger: rebuild with HTTP/2 config 2025-12-07 18:00:01 +00:00
masoud 6f3bb3e2c3 chore: trigger workflow for HTTP/2 deployment 2025-12-07 17:58:13 +00:00
masoud 0fe39cf9f6 feat: enable pure HTTP/2 for gRPC compatibility with nginx ingress 2025-12-07 17:53:43 +00:00
masoud e5fc13b9e2 chore: migrate from IP to domain (git.foursat.afrino.co) 2025-12-07 17:02:17 +00:00
masoodafar-web e7937fe7ee fix: Remove trailing whitespace in Seq config 2025-12-07 20:09:01 +03:30
masoodafar-web 9fb5119fc1 fix: Remove trailing whitespace in Seq config 2025-12-07 19:46:36 +03:30
masoodafar-web 4d6d0dfc9a feat: Add kubectl deployment and image rollout steps 2025-12-06 22:57:25 +03:30
masoodafar-web adcc776230 feat: Add Kestrel HTTP/2 configuration for macOS 2025-12-06 22:24:28 +03:30
masoodafar-web a0da7eb8e0 feat: Implement real Daya API integration with configurable mock/real service 2025-12-06 21:02:51 +03:30
689 changed files with 92371 additions and 6697 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ name: Push nuget and docker image Actions Workflow
on: on:
push: push:
branches: branches:
- stage - stage_new
jobs: jobs:
Deploy: Deploy:
runs-on: windows runs-on: windows
+56 -35
View File
@@ -8,61 +8,82 @@ on:
env: env:
REGISTRY: 194.5.195.53:30080 REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms IMAGE_NAME: admin/cms
K8S_SERVER: 194.5.195.53
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker:latest image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged 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: steps:
- name: Install git - name: Start Docker daemon
run: apk add --no-cache git
- name: Clone repo
run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/CMS.git .
- name: Start Docker daemon with insecure registry
run: | run: |
mkdir -p /etc/docker mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON' cat > /etc/docker/daemon.json << 'DAEMON'
{ {
"insecure-registries": ["194.5.195.53:30080", "gitea-svc:3000"] "insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
} }
DAEMON DAEMON
mkdir -p ~/.docker echo "🚀 Starting Docker daemon..."
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 & dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2 # 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
done done
docker info
# Final check
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
- 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!"
- name: Build Docker Image - name: Build Docker Image
run: | run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \ docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest .
-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 - name: Push to Registry
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin 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 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!"
+88
View File
@@ -0,0 +1,88 @@
name: Build and Deploy to Production
on:
push:
branches:
- production
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/cms
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
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,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
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"]
}
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
dockerd &
for i in $(seq 1 30); do
docker info >/dev/null 2>&1 && break || sleep 2
done
docker info
- name: Checkout code
run: |
git clone --depth 1 --branch production http://gitea-svc:3000/admin/CMS.git .
git log -1 --format="%H %s"
- 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
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 }}:prod
- 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"
+5
View File
@@ -492,3 +492,8 @@ fabric.properties
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
/src/.idea /src/.idea
# Environment-specific configuration files with sensitive data
**/appsettings.Staging.json
**/appsettings.Production.json
**/appsettings.*.local.json
+1
View File
@@ -0,0 +1 @@
# Trigger
+7 -4
View File
@@ -1,14 +1,17 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build
WORKDIR /src WORKDIR /src
# Copy NuGet config first
COPY src/NuGet.config ./
# Copy solution and project files # Copy solution and project files
COPY src/ ./ COPY src/ ./
# Restore and publish # Restore with Nexus config and publish
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" 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 RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
+210 -23
View File
@@ -1,38 +1,92 @@
# CMS Microservice - Network & Club Commission System # CMS Microservice - Network & Club Commission + Inventory Management System
[![Status](https://img.shields.io/badge/Status-Production%20Ready-success)]() [![Status](https://img.shields.io/badge/Status-Active%20Development-success)]()
[![Progress](https://img.shields.io/badge/Progress-85%25-blue)]() [![Progress](https://img.shields.io/badge/Inventory%20System-Phase%202%20Complete-blue)]()
[![MVP](https://img.shields.io/badge/MVP-100%25%20Complete-brightgreen)]() [![Phase](https://img.shields.io/badge/Next-Business%20Services-orange)]()
## 📊 Project Status (2025-12-01) ## 📊 Project Status (January 2026)
**Overall Progress**: 85% Complete (7/10 phases) ### 🏪 Inventory Management System - NEW!
**Production Readiness**: 95% **Progress**: Phase 2 Complete (50%)
**Architecture**: Clean Architecture + CQRS + Repository Pattern
#### ✅ Completed Phases
1.**Phase 1: Infrastructure & Domain Layer**
- Domain Entities: `InventoryItem`, `StockMovement`, `Warehouse`
- Domain Enums: `StockMovementType`
- EF Core Configurations with proper indexing
- Database migration applied
2.**Phase 2: Repository Pattern & CQRS**
- Repository Interfaces & Implementations
- CQRS Commands (17 commands)
- CQRS Queries (35 queries)
- MediatR Handlers (52 handlers)
#### 🔄 In Progress
3. 🔄 **Phase 3: Business Services Layer**
4.**Phase 4: DTOs & AutoMapper**
5.**Phase 5: API Controllers**
---
### 💼 Commission System - Production Ready
**Progress**: 85% Complete
**MVP Status**: ✅ 100% Complete **MVP Status**: ✅ 100% Complete
### ✅ Completed Phases (7) #### ✅ Completed Features
1.Domain Layer (Entities, Enums, Value Objects) -Binary network tree with automatic placement
2. ✅ Club Membership System - ✅ Club membership (Member/Trial) with commission rates
3.Binary Network Tree -Weekly commission calculation (Lesser Leg algorithm)
4. **Commission Calculation & Background Worker** (MVP) - ✅ Background worker with Hangfire
5.Protobuf gRPC Services -Email + SMS notifications (MailKit + Kavenegar)
6. ✅ History & Configuration Management - ✅ Health check endpoints (Kubernetes-ready)
7. ✅ Database Migration & Seed Data
### 🟡 Partially Complete (1) ### 🟡 Partially Complete
- Phase 10: Withdrawal & Settlement (40%) - Phase 10: Withdrawal & Settlement (40%)
- ✅ Commands & Database - ✅ Commands & Database
- ❌ Payment Gateway Integration - ❌ Payment Gateway Integration
### ❌ Not Started (1) ### ❌ Not Started
- Phase 9: Club Shop & Product Integration (0%) - Phase 9: Club Shop & Product Integration (0%)
### ⏸️ Postponed (1)
- Phase 7: Testing (Unit, Integration, Load tests)
--- ---
## 🚀 Recent Updates (2025-12-01) ## 🚀 Recent Updates (January 2026)
### 🏪 Inventory Management System - NEW! ✅
**Complete CQRS-based inventory management with:**
#### Domain Layer:
-`InventoryItem` - Multi-warehouse product tracking with min/max thresholds
-`StockMovement` - Complete audit trail with 8 movement types
-`Warehouse` - Multi-location support with default warehouse
#### Repository Pattern:
-`IInventoryItemRepository` - 25+ methods for inventory operations
-`IStockMovementRepository` - Movement tracking & analytics
-`IWarehouseRepository` - Warehouse management & statistics
#### CQRS Commands (17 total):
- **Inventory:** Create, Update, Delete, Reserve, Release, Reduce, Increase
- **Movement:** Create, BulkCreate, Delete
- **Warehouse:** Create, Update, Delete, SetDefault, Activate, BulkCreate
#### CQRS Queries (35 total):
- **Inventory:** GetById, Search, LowStock, OutOfStock, CheckAvailability
- **Movement:** GetHistory, GetByOrder, Search, Analytics, DailyVolume, TopMoving
- **Warehouse:** GetById, Search, GetStats, GetLowStock, GetAllStats
#### Business Features:
- ✅ Multi-warehouse inventory management
- ✅ Stock reservation system for orders
- ✅ Automatic movement tracking
- ✅ Low stock & out-of-stock alerts
- ✅ Advanced analytics & reporting
- ✅ Bulk operations support
- ✅ Transaction-safe operations
---
### Email & SMS Notifications - COMPLETED ✅ ### Email & SMS Notifications - COMPLETED ✅
-**MailKit 4.14.1** for Email (SMTP with HTML templates) -**MailKit 4.14.1** for Email (SMTP with HTML templates)
@@ -63,8 +117,38 @@
**Clean Architecture** with 4 layers: **Clean Architecture** with 4 layers:
``` ```
CMSMicroservice.Domain/ # Entities, Enums, Interfaces 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) 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 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.WebApi/ # gRPC Services, Controllers
CMSMicroservice.Protobuf/ # Protocol Buffers definitions CMSMicroservice.Protobuf/ # Protocol Buffers definitions
``` ```
@@ -84,6 +168,7 @@ CMSMicroservice.Protobuf/ # Protocol Buffers definitions
## 📖 Documentation ## 📖 Documentation
- **[Development Plan](docs/development-plan.md)** - NEW: Inventory system roadmap
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress - **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions - **[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 - **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
@@ -92,6 +177,74 @@ 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 ## 🚀 Quick Start
### Prerequisites ### Prerequisites
@@ -197,7 +350,25 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
## 📊 What's Remaining? ## 📊 What's Remaining?
### High Priority ### 🏪 Inventory System (Current Focus)
1. **Phase 3: Business Services** (In Progress)
- `IInventoryManagementService` - High-level operations
- `IStockMovementService` - Movement orchestration
- `IWarehouseService` - Warehouse business logic
- `IInventoryReportingService` - Advanced reporting
2. **Phase 4: DTOs & AutoMapper** (Next)
- Request/Response DTOs
- AutoMapper profiles
- Validation rules
3. **Phase 5: API Controllers** (Planned)
- `InventoryController` - REST API
- `WarehouseController` - Warehouse management
- `StockMovementController` - Movement tracking
- Swagger documentation
### 💼 Commission System
1. **Payment Gateway Integration** (Phase 10 - 1 week) 1. **Payment Gateway Integration** (Phase 10 - 1 week)
- Daya or Bank Mellat API integration - Daya or Bank Mellat API integration
- IBAN transfer automation - IBAN transfer automation
@@ -230,6 +401,7 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
## 🎯 MVP Features (100% Complete) ## 🎯 MVP Features (100% Complete)
### 💼 Commission System:
✅ Binary network tree with automatic placement ✅ Binary network tree with automatic placement
✅ Club membership (Member/Trial) with different commission rates ✅ Club membership (Member/Trial) with different commission rates
✅ Weekly commission calculation (Lesser Leg algorithm) ✅ Weekly commission calculation (Lesser Leg algorithm)
@@ -244,15 +416,30 @@ curl http://localhost:5133/health/live # Liveness probe (K8s)
✅ Structured logging (AlertService for Sentry/Slack) ✅ Structured logging (AlertService for Sentry/Slack)
✅ JWT authentication context (CurrentUserService) ✅ 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 ## 👥 Team
**Development**: FourSat Team **Development**: FourSat Team
**Last Updated**: 2025-12-01 **Last Updated**: January 2026
--- ---
## 📝 License ## 📝 License
Proprietary - FourSat Company Proprietary - FourSat Company
# Multi-remote push enabled
+1
View File
@@ -0,0 +1 @@
Docs moved to /totalDoc — see totalDoc/INDEX.md
+421
View File
@@ -0,0 +1,421 @@
{
"info": {
"_postman_id": "a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2",
"name": "hushyar",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "12336342",
"_collection_link": "https://abbas-rahimzadeh-postman.postman.co/workspace/public-workspace~57c702f9-fed1-4b27-b507-0b0252812bf7/collection/12336342-a5404a90-7d5a-4e9f-a26b-bb2bc93f19a2?action=share&source=collection_link&creator=12336342"
},
"item": [
{
"name": "v1",
"item": [
{
"name": "organization",
"item": [
{
"name": "register-user",
"request": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
},
"description": "Register a new user or get existing user via organization API key. If user is new or has no organization, they will be assigned to this organization and their wallet will be charged with the organization's user_credit."
},
"response": [
{
"name": "success - new user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": true,\n \"credit_charged\": 100.0\n}"
},
{
"name": "success - existing user",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"id\": 1,\n \"mobile_number\": \"09123456789\",\n \"organization_id\": 1,\n \"organization_title\": \"Test Organization\",\n \"wallet_balance\": 100.0,\n \"is_new_user\": false,\n \"credit_charged\": 0.0\n}"
},
{
"name": "invalid api key",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "invalid-api-key",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unauthorized",
"code": 401,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"INVALID_API_KEY\",\n \"message\": \"Invalid API key\",\n \"context\": {\n \"api_key_prefix\": \"invalid-\"\n }\n}"
},
{
"name": "organization disabled",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_DISABLED\",\n \"message\": \"Organization is disabled\",\n \"context\": {\n \"organization_id\": 1\n }\n}"
},
{
"name": "organization expired",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Forbidden",
"code": 403,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"error_code\": \"ORGANIZATION_EXPIRED\",\n \"message\": \"Organization has expired\",\n \"context\": {\n \"organization_id\": 1,\n \"expires_at\": \"2024-01-01T00:00:00\"\n }\n}"
},
{
"name": "invalid mobile format",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "X-API-Key",
"value": "{{organizationApiKey}}",
"description": "Organization API Key",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"01234567890\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"mobile_number\": \"String should match pattern '^09\\\\d{9}$'\"\n}"
},
{
"name": "missing api key header",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"mobile_number\": \"09123456789\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/api/v1/organizations/register-user",
"host": [
"{{baseURL}}"
],
"path": [
"api",
"v1",
"organizations",
"register-user"
]
}
},
"status": "Unprocessable Entity",
"code": 422,
"_postman_previewlanguage": "json",
"header": [
{
"key": "content-type",
"value": "application/json"
}
],
"cookie": [],
"body": "{\n \"detail\": [\n {\n \"type\": \"missing\",\n \"loc\": [\"header\", \"x-api-key\"],\n \"msg\": \"Field required\",\n \"input\": null\n }\n ]\n}"
}
]
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "baseURL",
"value": "http://127.0.0.0:8000",
"type": "string"
},
{
"key": "access_token",
"value": "",
"type": "string"
},
{
"key": "accessToken",
"value": ""
},
{
"key": "refreshToken",
"value": ""
},
{
"key": "organizationApiKey",
"value": "",
"type": "string"
}
]
}
@@ -0,0 +1,42 @@
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
/// <summary>
/// Command برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
/// </summary>
public record UpdateAppVersionCommand : IRequest<Unit>
{
/// <summary>
/// نام اپلیکیشن (FrontOffice, BackOffice, MobileApp)
/// </summary>
public string AppName { get; init; } = string.Empty;
/// <summary>
/// شماره نسخه جدید (مثلاً 1.2.3)
/// </summary>
public string CurrentVersion { get; init; } = string.Empty;
/// <summary>
/// حداقل نسخه مورد نیاز
/// </summary>
public string? MinRequiredVersion { get; init; }
/// <summary>
/// آیا کش کامل باید پاک بشه؟
/// </summary>
public bool RequiresFullCacheClear { get; init; }
/// <summary>
/// پیام آپدیت
/// </summary>
public string? UpdateMessage { get; init; }
/// <summary>
/// توضیحات تغییرات این نسخه
/// </summary>
public string? ReleaseNotes { get; init; }
/// <summary>
/// دلیل آپدیت (برای لاگ)
/// </summary>
public string? UpdateReason { get; init; }
}
@@ -0,0 +1,65 @@
using CMSMicroservice.Domain.Entities.Configuration;
namespace CMSMicroservice.Application.AppVersionCQ.Commands.UpdateAppVersion;
/// <summary>
/// Handler برای آپدیت یا ایجاد نسخه جدید اپلیکیشن
/// </summary>
public class UpdateAppVersionCommandHandler : IRequestHandler<UpdateAppVersionCommand, Unit>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<UpdateAppVersionCommandHandler> _logger;
public UpdateAppVersionCommandHandler(
IApplicationDbContext context,
ILogger<UpdateAppVersionCommandHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task<Unit> Handle(UpdateAppVersionCommand request, CancellationToken cancellationToken)
{
// پیدا کردن نسخه موجود برای این اپ
var existingVersion = await _context.AppVersions
.Where(v => v.AppName == request.AppName && !v.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
if (existingVersion != null)
{
// آپدیت نسخه موجود
existingVersion.CurrentVersion = request.CurrentVersion;
existingVersion.MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion;
existingVersion.RequiresFullCacheClear = request.RequiresFullCacheClear;
existingVersion.UpdateMessage = request.UpdateMessage;
existingVersion.ReleaseNotes = request.ReleaseNotes;
_logger.LogInformation(
"App version updated: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
}
else
{
// ایجاد نسخه جدید
var newVersion = new AppVersion
{
AppName = request.AppName,
CurrentVersion = request.CurrentVersion,
MinRequiredVersion = request.MinRequiredVersion ?? request.CurrentVersion,
RequiresFullCacheClear = request.RequiresFullCacheClear,
UpdateMessage = request.UpdateMessage,
ReleaseNotes = request.ReleaseNotes,
IsActive = true
};
_context.AppVersions.Add(newVersion);
_logger.LogInformation(
"New app version created: {AppName} v{Version}, CacheClear={CacheClear}, Reason={Reason}",
request.AppName, request.CurrentVersion, request.RequiresFullCacheClear, request.UpdateReason);
}
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}
@@ -0,0 +1,23 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
/// <summary>
/// Query برای دریافت همه نسخه‌های اپلیکیشن‌ها
/// </summary>
public record GetAllAppVersionsQuery(bool IncludeInactive = false) : IRequest<List<AppVersionItemDto>>;
/// <summary>
/// DTO برای هر آیتم نسخه اپلیکیشن
/// </summary>
public record AppVersionItemDto
{
public long Id { get; init; }
public string AppName { get; init; } = string.Empty;
public string CurrentVersion { get; init; } = string.Empty;
public string MinRequiredVersion { get; init; } = string.Empty;
public bool RequiresFullCacheClear { get; init; }
public string? UpdateMessage { get; init; }
public string? ReleaseNotes { get; init; }
public bool IsActive { get; init; }
public DateTime Created { get; init; }
public DateTime? LastModified { get; init; }
}
@@ -0,0 +1,44 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAllAppVersions;
/// <summary>
/// Handler برای دریافت همه نسخه‌های اپلیکیشن‌ها
/// </summary>
public class GetAllAppVersionsQueryHandler : IRequestHandler<GetAllAppVersionsQuery, List<AppVersionItemDto>>
{
private readonly IApplicationDbContext _context;
public GetAllAppVersionsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<AppVersionItemDto>> Handle(GetAllAppVersionsQuery request, CancellationToken cancellationToken)
{
var query = _context.AppVersions
.Where(v => !v.IsDeleted);
if (!request.IncludeInactive)
{
query = query.Where(v => v.IsActive);
}
var versions = await query
.OrderBy(v => v.AppName)
.Select(v => new AppVersionItemDto
{
Id = v.Id,
AppName = v.AppName,
CurrentVersion = v.CurrentVersion,
MinRequiredVersion = v.MinRequiredVersion,
RequiresFullCacheClear = v.RequiresFullCacheClear,
UpdateMessage = v.UpdateMessage,
ReleaseNotes = v.ReleaseNotes,
IsActive = v.IsActive,
Created = v.Created,
LastModified = v.LastModified
})
.ToListAsync(cancellationToken);
return versions;
}
}
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
/// <summary>
/// Query برای دریافت آخرین نسخه یک اپلیکیشن
/// </summary>
public record GetAppVersionQuery(string AppName, string? CurrentClientVersion = null) : IRequest<AppVersionDto?>;
/// <summary>
/// DTO برای اطلاعات نسخه اپلیکیشن
/// </summary>
public record AppVersionDto
{
public bool Found { get; init; }
public string AppName { get; init; } = string.Empty;
public string CurrentVersion { get; init; } = string.Empty;
public string MinRequiredVersion { get; init; } = string.Empty;
public bool RequiresFullCacheClear { get; init; }
public bool RequiresUpdate { get; init; }
public string? UpdateMessage { get; init; }
public string? ReleaseNotes { get; init; }
public DateTime? LastUpdated { get; init; }
}
@@ -0,0 +1,75 @@
using CMSMicroservice.Domain.Entities.Configuration;
namespace CMSMicroservice.Application.AppVersionCQ.Queries.GetAppVersion;
/// <summary>
/// Handler برای دریافت آخرین نسخه اپلیکیشن
/// </summary>
public class GetAppVersionQueryHandler : IRequestHandler<GetAppVersionQuery, AppVersionDto?>
{
private readonly IApplicationDbContext _context;
public GetAppVersionQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<AppVersionDto?> Handle(GetAppVersionQuery request, CancellationToken cancellationToken)
{
var appVersion = await _context.AppVersions
.Where(v => v.AppName == request.AppName && v.IsActive && !v.IsDeleted)
.FirstOrDefaultAsync(cancellationToken);
if (appVersion == null)
{
return new AppVersionDto
{
Found = false,
AppName = request.AppName
};
}
// مقایسه ورژن کلاینت با حداقل نسخه مورد نیاز
bool requiresUpdate = false;
if (!string.IsNullOrEmpty(request.CurrentClientVersion) && !string.IsNullOrEmpty(appVersion.MinRequiredVersion))
{
requiresUpdate = CompareVersions(request.CurrentClientVersion, appVersion.MinRequiredVersion) < 0;
}
return new AppVersionDto
{
Found = true,
AppName = appVersion.AppName,
CurrentVersion = appVersion.CurrentVersion,
MinRequiredVersion = appVersion.MinRequiredVersion,
RequiresFullCacheClear = appVersion.RequiresFullCacheClear,
RequiresUpdate = requiresUpdate,
UpdateMessage = appVersion.UpdateMessage,
ReleaseNotes = appVersion.ReleaseNotes,
LastUpdated = appVersion.LastModified ?? appVersion.Created
};
}
/// <summary>
/// مقایسه دو نسخه (مثلاً 1.2.3 با 1.3.0)
/// برگشت: منفی = اولی کوچکتر، مثبت = اولی بزرگتر، صفر = برابر
/// </summary>
private static int CompareVersions(string version1, string version2)
{
var v1Parts = version1.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
var v2Parts = version2.Split('.').Select(s => int.TryParse(s, out var n) ? n : 0).ToArray();
var maxLen = Math.Max(v1Parts.Length, v2Parts.Length);
for (int i = 0; i < maxLen; i++)
{
var v1 = i < v1Parts.Length ? v1Parts[i] : 0;
var v2 = i < v2Parts.Length ? v2Parts[i] : 0;
if (v1 != v2)
return v1.CompareTo(v2);
}
return 0;
}
}
@@ -7,12 +7,18 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.2.2" />
<PackageReference Include="Mapster" Version="7.4.0" /> <PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" /> <PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" /> <PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CMSMicroservice.Domain\CMSMicroservice.Domain.csproj" /> <ProjectReference Include="..\CMSMicroservice.Domain\CMSMicroservice.Domain.csproj" />
@@ -0,0 +1,42 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public record GetAllCitiesByFilterQuery : IRequest<GetAllCitiesByFilterResponseDto>
{
/// <summary>
/// موقعیت صفحه بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// فیلتر
/// </summary>
public GetAllCitiesByFilterFilter? Filter { get; init; }
}
public class GetAllCitiesByFilterFilter
{
/// <summary>
/// شناسه
/// </summary>
public long? Id { get; set; }
/// <summary>
/// نام شهر (Contains)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// نام بومی شهر (Contains)
/// </summary>
public string? Native { get; set; }
/// <summary>
/// شناسه استان
/// </summary>
public long? StateId { get; set; }
}
@@ -0,0 +1,43 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryHandler : IRequestHandler<GetAllCitiesByFilterQuery, GetAllCitiesByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllCitiesByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllCitiesByFilterResponseDto> Handle(
GetAllCitiesByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.Cities
.Include(c => c.State)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.Id == null || x.Id == request.Filter.Id)
.Where(x => request.Filter.Name == null || x.Name.Contains(request.Filter.Name))
.Where(x => request.Filter.Native == null || x.Native.Contains(request.Filter.Native))
.Where(x => request.Filter.StateId == null || x.StateId == request.Filter.StateId);
}
return new GetAllCitiesByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.ProjectToType<GetAllCitiesByFilterResponseModel>()
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryValidator : AbstractValidator<GetAllCitiesByFilterQuery>
{
public GetAllCitiesByFilterQueryValidator()
{
// Validation rules اگر نیاز باشد
}
public async Task<bool> Validate(object model, string propertyName)
{
var result = await ValidateAsync(
ValidationContext<GetAllCitiesByFilterQuery>.CreateWithOptions(
(GetAllCitiesByFilterQuery)model,
x => x.IncludeProperties(propertyName)));
return result.IsValid;
}
}
@@ -0,0 +1,62 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterResponseDto
{
/// <summary>
/// متادیتا
/// </summary>
public MetaData MetaData { get; set; } = null!;
/// <summary>
/// مدل خروجی
/// </summary>
public List<GetAllCitiesByFilterResponseModel>? Models { get; set; }
}
public class GetAllCitiesByFilterResponseModel
{
/// <summary>
/// شناسه
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام شهر
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// نام بومی شهر (فارسی)
/// </summary>
public string Native { get; set; } = null!;
/// <summary>
/// عرض جغرافیایی
/// </summary>
public string Latitude { get; set; } = null!;
/// <summary>
/// طول جغرافیایی
/// </summary>
public string Longitude { get; set; } = null!;
/// <summary>
/// شناسه استان
/// </summary>
public long StateId { get; set; }
/// <summary>
/// نام استان
/// </summary>
public string StateName { get; set; } = null!;
/// <summary>
/// نام بومی استان
/// </summary>
public string StateNative { get; set; } = null!;
}
@@ -0,0 +1,24 @@
using MediatR;
namespace CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature;
/// <summary>
/// کامند برای فعال/غیرفعال کردن یک ویژگی باشگاه برای کاربر
/// </summary>
public record ToggleUserClubFeatureCommand : IRequest<ToggleUserClubFeatureResponse>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// شناسه ویژگی باشگاه
/// </summary>
public long ClubFeatureId { get; init; }
/// <summary>
/// وضعیت مورد نظر (فعال/غیرفعال)
/// </summary>
public bool IsActive { get; init; }
}
@@ -0,0 +1,78 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature;
/// <summary>
/// هندلر برای فعال/غیرفعال کردن ویژگی باشگاه کاربر
/// </summary>
public class ToggleUserClubFeatureCommandHandler : IRequestHandler<ToggleUserClubFeatureCommand, ToggleUserClubFeatureResponse>
{
private readonly IApplicationDbContext _context;
public ToggleUserClubFeatureCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<ToggleUserClubFeatureResponse> Handle(ToggleUserClubFeatureCommand request, CancellationToken cancellationToken)
{
// بررسی وجود کاربر
var userExists = await _context.Users
.AnyAsync(u => u.Id == request.UserId && !u.IsDeleted, cancellationToken);
if (!userExists)
{
return new ToggleUserClubFeatureResponse
{
Success = false,
Message = "کاربر یافت نشد"
};
}
// بررسی وجود ویژگی
var featureExists = await _context.ClubFeatures
.AnyAsync(cf => cf.Id == request.ClubFeatureId && !cf.IsDeleted, cancellationToken);
if (!featureExists)
{
return new ToggleUserClubFeatureResponse
{
Success = false,
Message = "ویژگی باشگاه یافت نشد"
};
}
// یافتن رکورد UserClubFeature
var userClubFeature = await _context.UserClubFeatures
.FirstOrDefaultAsync(
ucf => ucf.UserId == request.UserId
&& ucf.ClubFeatureId == request.ClubFeatureId
&& !ucf.IsDeleted,
cancellationToken);
if (userClubFeature == null)
{
return new ToggleUserClubFeatureResponse
{
Success = false,
Message = "این ویژگی برای کاربر یافت نشد"
};
}
// به‌روزرسانی وضعیت
userClubFeature.IsActive = request.IsActive;
userClubFeature.LastModified = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
return new ToggleUserClubFeatureResponse
{
Success = true,
Message = request.IsActive ? "ویژگی با موفقیت فعال شد" : "ویژگی با موفقیت غیرفعال شد",
UserClubFeatureId = userClubFeature.Id,
IsActive = userClubFeature.IsActive
};
}
}
@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature;
/// <summary>
/// پاسخ کامند فعال/غیرفعال کردن ویژگی باشگاه
/// </summary>
public class ToggleUserClubFeatureResponse
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام
/// </summary>
public string Message { get; set; }
/// <summary>
/// شناسه رکورد UserClubFeature
/// </summary>
public long? UserClubFeatureId { get; set; }
/// <summary>
/// وضعیت جدید
/// </summary>
public bool? IsActive { get; set; }
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
/// <summary>
/// دریافت لیست ویژگی‌های باشگاه اختصاص یافته به کاربر
/// </summary>
public record GetUserClubFeaturesQuery : IRequest<List<UserClubFeatureDto>>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
}
@@ -0,0 +1,43 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
/// <summary>
/// هندلر برای دریافت لیست ویژگی‌های باشگاه یک کاربر
/// </summary>
public class GetUserClubFeaturesQueryHandler : IRequestHandler<GetUserClubFeaturesQuery, List<UserClubFeatureDto>>
{
private readonly IApplicationDbContext _context;
public GetUserClubFeaturesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<UserClubFeatureDto>> Handle(GetUserClubFeaturesQuery request, CancellationToken cancellationToken)
{
var userClubFeatures = await _context.UserClubFeatures
.Include(ucf => ucf.ClubFeature)
.Where(ucf => ucf.UserId == request.UserId && !ucf.IsDeleted)
.Select(ucf => new UserClubFeatureDto
{
Id = ucf.Id,
UserId = ucf.UserId,
ClubMembershipId = ucf.ClubMembershipId,
ClubFeatureId = ucf.ClubFeatureId,
FeatureTitle = ucf.ClubFeature.Title,
FeatureDescription = ucf.ClubFeature.Description,
IsActive = ucf.IsActive,
GrantedAt = ucf.GrantedAt,
Notes = ucf.Notes,
SortOrder = ucf.ClubFeature.SortOrder,
CreatedAt = ucf.Created
})
.OrderBy(ucf => ucf.SortOrder)
.ToListAsync(cancellationToken);
return userClubFeatures;
}
}
@@ -0,0 +1,62 @@
namespace CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
/// <summary>
/// DTO برای ویژگی‌های باشگاه کاربر
/// </summary>
public class UserClubFeatureDto
{
/// <summary>
/// شناسه رکورد UserClubFeature
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; set; }
/// <summary>
/// شناسه عضویت باشگاه
/// </summary>
public long ClubMembershipId { get; set; }
/// <summary>
/// شناسه ویژگی باشگاه
/// </summary>
public long ClubFeatureId { get; set; }
/// <summary>
/// عنوان ویژگی
/// </summary>
public string FeatureTitle { get; set; }
/// <summary>
/// توضیحات کوتاه ویژگی
/// </summary>
public string? FeatureDescription { get; set; }
/// <summary>
/// وضعیت فعال/غیرفعال ویژگی برای این کاربر
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// تاریخ اعطای ویژگی
/// </summary>
public DateTime GrantedAt { get; set; }
/// <summary>
/// یادداشت (برای نمایش در مدال جزئیات)
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// ترتیب نمایش
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// تاریخ ایجاد رکورد
/// </summary>
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Command برای پذیرش قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand : IRequest<AcceptClubMembershipContractResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// کد OTP برای تایید
/// </summary>
public string OtpCode { get; init; }
/// <summary>
/// شناسه یکتای امضا (GUID)
/// </summary>
public string SignGuid { get; init; }
/// <summary>
/// محتوای HTML قرارداد
/// </summary>
public string ContractHtml { get; init; }
}
/// <summary>
/// DTO پاسخ پذیرش قرارداد باشگاه مشتریان
/// </summary>
public class AcceptClubMembershipContractResponseDto
{
/// <summary>
/// آیا عملیات موفق بود؟
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام نتیجه
/// </summary>
public string Message { get; set; }
/// <summary>
/// شناسه قرارداد ثبت شده
/// </summary>
public long ContractId { get; set; }
}
@@ -0,0 +1,339 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Handler برای پذیرش قرارداد باشگاه مشتریان
/// این handler:
/// 1. کد OTP را تایید می‌کند
/// 2. قرارداد را در جدول UserContract ثبت می‌کند
/// 3. باشگاه مشتری را فعال می‌کند (IsActive = true)
/// 4. مبلغ را به Pool هفته جاری اضافه می‌کند
/// 5. ویژگی‌های باشگاه را به کاربر اعطا می‌کند
/// </summary>
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IConfiguration _cfg;
private readonly IHashService _hashService;
private readonly ICurrentUserService _currentUser;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
private const int MaxAttempts = 5;
private const string OtpPurpose = "signClubContract";
public AcceptClubMembershipContractCommandHandler(
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger;
}
public async Task<AcceptClubMembershipContractResponseDto> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
request.UserId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی خرید پکیج
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "برای امضای قرارداد باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید"
};
}
// 3. بررسی عدم فعال بودن قبلی باشگاه
if (user.ClubMembership?.IsActive == true)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "شما قبلاً عضو باشگاه مشتریان شده‌اید"
};
}
// 4. تایید OTP
var otpResult = await VerifyOtpAsync(user.Mobile, request.OtpCode, cancellationToken);
if (!otpResult.Success)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = otpResult.Message
};
}
// 5. ثبت قرارداد در جدول UserContract
var contract = await _context.Contracts
.FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);
if (contract == null)
{
// اگر قرارداد وجود ندارد، یک قرارداد پیش‌فرض ایجاد کنید
contract = new Contract
{
Title = "قرارداد باشگاه مشتریان",
Description = "قوانین و مقررات باشگاه مشتریان کارابازار",
HtmlContent = request.ContractHtml,
Type = ContractType.ClubMembership
};
await _context.Contracts.AddAsync(contract, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
var userContract = new UserContract
{
UserId = user.Id,
ContractId = contract.Id,
SignGuid = request.SignGuid,
SignedPdfFile = request.ContractHtml
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. دریافت مقادیر از SystemConstants (استاتیک)
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
);
// 7. فعال‌سازی باشگاه مشتریان
ClubMembership clubMembership;
bool isNewMembership = user.ClubMembership == null;
var activationDate = DateTime.Now;
if (isNewMembership)
{
clubMembership = new ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(clubMembership, cancellationToken);
_logger.LogInformation(
"Created new club membership for UserId {UserId} via {Method}, GiftValue: {GiftValue}",
user.Id,
user.PackagePurchaseMethod,
giftValue
);
}
else
{
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
);
}
await _context.SaveChangesAsync(cancellationToken);
// 8. ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = clubMembership.Id,
UserId = clubMembership.UserId,
OldIsActive = !isNewMembership && !user.ClubMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = isNewMembership
? $"Initial activation via contract signing - {user.PackagePurchaseMethod}"
: $"Reactivated via contract signing - {user.PackagePurchaseMethod}",
PerformedBy = _currentUser.GetPerformedBy()
};
_context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
activationFeeValue
);
}
else
{
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 10. اعطای ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = clubMembership.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده هنگام امضای قرارداد"
}).ToList();
_context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
);
}
}
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
request.UserId,
userContract.Id,
clubMembership.Id
);
return new AcceptClubMembershipContractResponseDto
{
Success = true,
Message = "قرارداد باشگاه مشتریان با موفقیت ثبت شد و عضویت شما فعال گردید",
ContractId = userContract.Id
};
}
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private long GetCurrentWeekDefinitionId()
{
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
CancellationToken cancellationToken)
{
var normalizedMobile = mobile.NormalizeIranMobile();
var now = DateTime.Now;
var otp = await _context.OtpTokens
.Where(o => o.Mobile == normalizedMobile
&& o.Purpose == OtpPurpose
&& !o.IsUsed
&& o.ExpiresAt > now)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (otp == null)
{
return (false, "کد تایید پیدا نشد یا منقضی شده است");
}
if (otp.Attempts >= MaxAttempts)
{
return (false, "تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کد جدید دریافت کنید");
}
otp.Attempts++;
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not configured");
if (!_hashService.VerifyHmacSha256Hex(code, otp.CodeHash, secret))
{
await _context.SaveChangesAsync(cancellationToken);
return (false, "کد تایید نادرست است");
}
// کد صحیح است - علامت‌گذاری به عنوان استفاده شده
otp.IsUsed = true;
await _context.SaveChangesAsync(cancellationToken);
return (true, "کد تایید صحیح است");
}
}
@@ -0,0 +1,25 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandValidator : AbstractValidator<AcceptClubMembershipContractCommand>
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
.Length(6)
.WithMessage("کد تایید باید ۶ رقم باشد");
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضا الزامی است");
RuleFor(x => x.ContractHtml)
.NotEmpty()
.WithMessage("محتوای قرارداد الزامی است");
}
}
@@ -1,13 +1,16 @@
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Club; using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.History; using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Globalization;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership; namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
@@ -15,15 +18,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser; private readonly ICurrentUserService _currentUser;
private readonly IWeekDefinitionRepository _weekRepository;
private readonly ILogger<ActivateClubMembershipCommandHandler> _logger; private readonly ILogger<ActivateClubMembershipCommandHandler> _logger;
public ActivateClubMembershipCommandHandler( public ActivateClubMembershipCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IWeekDefinitionRepository weekRepository,
ILogger<ActivateClubMembershipCommandHandler> logger) ILogger<ActivateClubMembershipCommandHandler> logger)
{ {
_context = context; _context = context;
_currentUser = currentUser; _currentUser = currentUser;
_weekRepository = weekRepository;
_logger = logger; _logger = logger;
} }
@@ -70,7 +76,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException("کیف پول کاربر یافت نشد"); throw new NotFoundException("کیف پول کاربر یافت نشد");
} }
if (wallet.Balance < 56_000_000) if (wallet.Balance < SystemConstants.BasePackageAmount)
{ {
_logger.LogWarning( _logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}", "User {UserId} has insufficient balance: {Balance}",
@@ -78,7 +84,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
wallet.Balance wallet.Balance
); );
throw new BadRequestException( throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان باید حداقل 56 میلیون تومان موجودی اصلی داشته باشید" $"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
); );
} }
@@ -130,33 +136,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
var existingMembership = await _context.ClubMemberships var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken); .FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
// 6.1. دریافت مبلغ هدیه از تنظیمات // 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
var giftValueConfig = await _context.SystemConfigurations long giftValue = SystemConstants.ClubMembershipGiftValue;
.FirstOrDefaultAsync( long activationFeeValue = SystemConstants.ClubActivationFee;
c => c.Key == "Club.MembershipGiftValue" && c.IsActive,
cancellationToken
);
long giftValue = 25_200_000; // مقدار پیش‌فرض
if (giftValueConfig != null && long.TryParse(giftValueConfig.Value, out var configValue))
{
giftValue = configValue;
_logger.LogInformation( _logger.LogInformation(
"Using Club.MembershipGiftValue from configuration: {GiftValue}", "Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue giftValue, activationFeeValue
); );
}
else
{
_logger.LogWarning(
"Club.MembershipGiftValue not found in configuration, using default: {GiftValue}",
giftValue
);
}
ClubMembership entity; ClubMembership entity;
bool isNewMembership = existingMembership == null; bool isNewMembership = existingMembership == null;
var activationDate = DateTime.UtcNow; var activationDate = DateTime.Now;
if (isNewMembership) if (isNewMembership)
{ {
@@ -166,7 +157,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
UserId = user.Id, UserId = user.Id,
IsActive = true, IsActive = true,
ActivatedAt = activationDate, ActivatedAt = activationDate,
InitialContribution = 56_000_000, InitialContribution =activationFeeValue,
GiftValue = giftValue, // مقدار از تنظیمات GiftValue = giftValue, // مقدار از تنظیمات
TotalEarned = 0, TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod PurchaseMethod = user.PackagePurchaseMethod
@@ -225,6 +216,84 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
_context.ClubMembershipHistories.Add(history); _context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
{
// ایجاد Pool جدید برای این هفته
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
giftValue
);
}
else
{
// اضافه کردن به Pool موجود
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = entity.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده به‌طور خودکار هنگام فعالسازی"
}).ToList(); _context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
);
}
else
{
_logger.LogWarning(
"No club features found to grant to UserId {UserId}",
user.Id
);
}
}
_logger.LogInformation( _logger.LogInformation(
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}", "Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
user.Id, user.Id,
@@ -243,4 +312,17 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw; throw;
} }
} }
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
private long GetCurrentWeekDefinitionId()
{
var week = _weekRepository.GetCurrentWeek();
if (week == null)
{
throw new InvalidOperationException("هفته جاری در سیستم تعریف نشده است");
}
return week.Id;
}
} }
@@ -54,7 +54,7 @@ public class AssignClubFeatureCommandHandler : IRequestHandler<AssignClubFeature
UserId = request.UserId, UserId = request.UserId,
ClubMembershipId = membership.Id, ClubMembershipId = membership.Id,
ClubFeatureId = request.FeatureId, ClubFeatureId = request.FeatureId,
GrantedAt = request.GrantedAt ?? DateTime.UtcNow, GrantedAt = request.GrantedAt ?? DateTime.Now,
Notes = request.Notes Notes = request.Notes
}; };
@@ -12,6 +12,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken) public async Task<GetAllClubMembershipsResponseDto> Handle(GetAllClubMembershipsQuery request, CancellationToken cancellationToken)
{ {
var query = _context.ClubMemberships var query = _context.ClubMemberships
.Include(x => x.User)
.ApplyOrder(sortBy: request.SortBy) .ApplyOrder(sortBy: request.SortBy)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
@@ -33,6 +34,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
{ {
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
IsActive = x.IsActive, IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt, ActivatedAt = x.ActivatedAt,
InitialContribution = x.InitialContribution, InitialContribution = x.InitialContribution,
@@ -10,6 +10,7 @@ public class GetAllClubMembershipsResponseModel
{ {
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; } public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; } public long InitialContribution { get; set; }
@@ -3,14 +3,18 @@ namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership
public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?> public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQuery, ClubMembershipDto?>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ILogger<GetClubMembershipQueryHandler> _logger;
public GetClubMembershipQueryHandler(IApplicationDbContext context) public GetClubMembershipQueryHandler(IApplicationDbContext context, ILogger<GetClubMembershipQueryHandler> logger)
{ {
_context = context; _context = context;
_logger = logger;
} }
public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken) public async Task<ClubMembershipDto?> Handle(GetClubMembershipQuery request, CancellationToken cancellationToken)
{ {
_logger.LogInformation("GetClubMembership called for UserId: {UserId}", request.UserId);
var membership = await _context.ClubMemberships var membership = await _context.ClubMemberships
.AsNoTracking() .AsNoTracking()
.Where(x => x.UserId == request.UserId) .Where(x => x.UserId == request.UserId)
@@ -27,6 +31,26 @@ public class GetClubMembershipQueryHandler : IRequestHandler<GetClubMembershipQu
}) })
.FirstOrDefaultAsync(cancellationToken); .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; return membership;
} }
} }
@@ -25,7 +25,7 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
query = query.Where(x => x.UserId == request.UserId.Value); query = query.Where(x => x.UserId == request.UserId.Value);
} }
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); // پیش‌فرض: جدیدترین اول query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیش‌فرض: جدیدترین اول
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -11,7 +11,7 @@ public class GetClubStatisticsQueryHandler : IRequestHandler<GetClubStatisticsQu
public async Task<GetClubStatisticsResponseDto> Handle(GetClubStatisticsQuery request, CancellationToken cancellationToken) public async Task<GetClubStatisticsResponseDto> Handle(GetClubStatisticsQuery request, CancellationToken cancellationToken)
{ {
var now = DateTime.UtcNow; var now = DateTime.Now;
// Basic statistics // Basic statistics
var totalMembers = await _context.ClubMemberships.CountAsync(cancellationToken); var totalMembers = await _context.ClubMemberships.CountAsync(cancellationToken);
@@ -33,10 +33,10 @@ public class ApproveWithdrawalCommandHandler : IRequestHandler<ApproveWithdrawal
// Update status to Withdrawn (approved) // Update status to Withdrawn (approved)
payout.Status = CommissionPayoutStatus.Withdrawn; payout.Status = CommissionPayoutStatus.Withdrawn;
payout.WithdrawnAt = DateTime.UtcNow; payout.WithdrawnAt = DateTime.Now;
payout.ProcessedBy = _currentUser.GetPerformedBy(); payout.ProcessedBy = _currentUser.GetPerformedBy();
payout.ProcessedAt = DateTime.UtcNow; payout.ProcessedAt = DateTime.Now;
payout.LastModified = DateTime.UtcNow; payout.LastModified = DateTime.Now;
// TODO: Add PayoutHistory record // TODO: Add PayoutHistory record
// var history = new CommissionPayoutHistory // var history = new CommissionPayoutHistory
@@ -51,7 +51,7 @@ public class ApproveWithdrawalCommandHandler : IRequestHandler<ApproveWithdrawal
// Action = (int)CommissionPayoutAction.Approved, // Action = (int)CommissionPayoutAction.Approved,
// PerformedBy = "Admin", // TODO: Get from authenticated user // PerformedBy = "Admin", // TODO: Get from authenticated user
// Reason = request.Notes, // Reason = request.Notes,
// Created = DateTime.UtcNow // Created = DateTime.Now
// }; // };
// _context.CommissionPayoutHistories.Add(history); // _context.CommissionPayoutHistories.Add(history);
@@ -8,7 +8,7 @@ public record CalculateWeeklyBalancesCommand : IRequest<int>
/// <summary> /// <summary>
/// شماره هفته (فرمت: YYYY-Www مثل 2025-W01) /// شماره هفته (فرمت: YYYY-Www مثل 2025-W01)
/// </summary> /// </summary>
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// آیا محاسبه مجدد انجام شود؟ (پیش‌فرض: false) /// آیا محاسبه مجدد انجام شود؟ (پیش‌فرض: false)
@@ -1,24 +1,37 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances; namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int> public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWeeklyBalancesCommand, int>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public CalculateWeeklyBalancesCommandHandler(IApplicationDbContext context) public CalculateWeeklyBalancesCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{ {
_context = context; _context = context;
_weekRepository = weekRepository;
} }
public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken) public async Task<int> Handle(CalculateWeeklyBalancesCommand request, CancellationToken cancellationToken)
{ {
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود محاسبه قبلی // بررسی وجود محاسبه قبلی
var existingBalances = await _context.NetworkWeeklyBalances var existingBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (existingBalances.Any() && !request.ForceRecalculate) if (existingBalances.Any() && !request.ForceRecalculate)
{ {
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید"); throw new InvalidOperationException($"تعادل‌های هفته {request.WeekDefinitionId} قبلاً محاسبه شده است. برای محاسبه مجدد از ForceRecalculate استفاده کنید");
} }
// حذف محاسبات قبلی در صورت ForceRecalculate // حذف محاسبات قبلی در صورت ForceRecalculate
@@ -28,44 +41,48 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
// دریافت کاربران فعال در شبکه // دریافت همه کاربرانی که عضو فعال باشگاه هستند
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
// دریافت کاربران فعال در شبکه که عضو باشگاه هستند
// نکته: شرط NetworkParentId.HasValue نداریم چون ریشه شبکه (اولین نفر) هم باید حساب بشه
var usersInNetwork = await _context.Users var usersInNetwork = await _context.Users
.Where(x => x.NetworkParentId.HasValue) .Where(x => activeClubMemberUserIds.Contains(x.Id))
.Select(x => new { x.Id }) .Select(x => new { x.Id })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// دریافت باقیمانده‌های هفته قبل // دریافت باقیمانده‌های هفته قبل
var previousWeekNumber = GetPreviousWeekNumber(request.WeekNumber); var previousWeekDefinitionId = GetPreviousWeekDefinitionId(request.WeekDefinitionId);
var previousWeekCarryovers = await _context.NetworkWeeklyBalances Dictionary<long, (int LeftLegRemainder, int RightLegRemainder)> previousWeekCarryovers;
.Where(x => x.WeekNumber == previousWeekNumber)
.Select(x => new if (previousWeekDefinitionId.HasValue)
{ {
x.UserId, previousWeekCarryovers = await _context.NetworkWeeklyBalances
x.LeftLegRemainder, .Where(x => x.WeekDefinitionId == previousWeekDefinitionId.Value)
x.RightLegRemainder .ToDictionaryAsync(
}) x => x.UserId,
.ToDictionaryAsync(x => x.UserId, cancellationToken); x => (x.LeftLegRemainder, x.RightLegRemainder),
cancellationToken);
}
else
{
previousWeekCarryovers = new Dictionary<long, (int, int)>();
}
var balancesList = new List<NetworkWeeklyBalance>(); var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.UtcNow; var calculatedAt = DateTime.Now;
// خواندن یکباره Configuration ها (بهینه‌سازی - به جای N query) // استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
var configs = await _context.SystemConfigurations
.Where(x => x.IsActive && (
x.Key == "Club.ActivationFee" ||
x.Key == "Commission.WeeklyPoolContributionPercent" ||
x.Key == "Commission.MaxWeeklyBalancesPerLeg" ||
x.Key == "Commission.MaxNetworkLevel"))
.ToDictionaryAsync(x => x.Key, x => x.Value, cancellationToken);
var activationFee = long.Parse(configs.GetValueOrDefault("Club.ActivationFee", "25000000"));
var poolPercent = decimal.Parse(configs.GetValueOrDefault("Commission.WeeklyPoolContributionPercent", "20")) / 100m;
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل // سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
var maxBalancesPerLeg = int.Parse(configs.GetValueOrDefault("Commission.MaxWeeklyBalancesPerLeg", "300")); var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
// حداکثر عمق شبکه برای شمارش اعضا (15 لول) // حداکثر عمق شبکه برای شمارش اعضا (15 لول)
var maxNetworkLevel = int.Parse(configs.GetValueOrDefault("Commission.MaxNetworkLevel", "15")); var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
foreach (var user in usersInNetwork) foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
{ {
// دریافت باقیمانده هفته قبل // دریافت باقیمانده هفته قبل
var leftCarryover = 0; var leftCarryover = 0;
@@ -77,41 +94,46 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
} }
// محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر) // محاسبه تعداد اعضای جدید در این هفته (تا maxNetworkLevel لول پایین‌تر)
var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekNumber, maxNetworkLevel, cancellationToken); var leftNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Left, request.WeekDefinitionId, maxNetworkLevel, cancellationToken);
var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekNumber, maxNetworkLevel, cancellationToken); var rightNewMembers = await CountNewMembersInLeg(user.Id, NetworkLeg.Right, request.WeekDefinitionId, maxNetworkLevel, cancellationToken);
// محاسبه مجموع هر پا (جدید + باقیمانده) // محاسبه مجموع هر پا (جدید + باقیمانده)
var leftTotal = leftNewMembers + leftCarryover; var leftTotal = leftNewMembers + leftCarryover;
var rightTotal = rightNewMembers + rightCarryover; var rightTotal = rightNewMembers + rightCarryover;
// ✅ اصلاح شده: اعمال سقف روی هر دست جداگانه (نه روی کل) // ✅ مرحله 1: محاسبه تعادل اولیه (قبل از اعمال سقف)
// سقف 300 برای دست چپ + 300 برای دست راست = حداکثر 600 تعادل در هفته // تعادل = کمترین مقدار بین چپ و راست
var cappedLeftTotal = Math.Min(leftTotal, maxBalancesPerLeg); // مثال: چپ=500، راست=600 → تعادل=500
var cappedRightTotal = Math.Min(rightTotal, maxBalancesPerLeg); var totalBalances = Math.Min(leftTotal, rightTotal);
// محاسبه تعادل (کمترین مقدار بعد از اعمال سقف) // ✅ مرحله 2: محاسبه باقیمانده (قبل از سقف)
var totalBalances = Math.Min(cappedLeftTotal, cappedRightTotal); // باقیمانده = اضافه‌ای که یک طرف دارد
// مثال: چپ=500، راست=600، تعادل=500
// → باقی چپ = 500-500 = 0
// → باقی راست = 600-500 = 100 (می‌رود برای هفته بعد)
var leftRemainder = leftTotal - totalBalances;
var rightRemainder = rightTotal - totalBalances;
// محاسبه باقیمانده برای هفته بعد // ✅ مرحله 3: اعمال سقف 300 (برای امتیاز نهایی)
// باقیمانده = مقداری که از سقف هر دست رد شده // از تعادل، فقط 300 از هر طرف حساب می‌شود
// مثال: چپ=350، راست=450، سقف=300 // مثال: تعادل=500 → امتیاز=300
// cappedLeft = MIN(350, 300) = 300 // از چپ: 300 حساب می‌شود، 200 فلش می‌شود
// cappedRight = MIN(450, 300) = 300 // از راست: 300 حساب می‌شود، 200 فلش می‌شود
// totalBalances = MIN(300, 300) = 300 // جمع فلش = 400 (از بین می‌رود)
// leftRemainder = 350 - 300 = 50 (مازاد سقف) var cappedBalances = Math.Min(totalBalances, maxBalancesPerLeg);
// rightRemainder = 450 - 300 = 150 (مازاد سقف)
var leftRemainder = leftTotal - cappedLeftTotal;
var rightRemainder = rightTotal - cappedRightTotal;
// محاسبه سهم استخر (20% از مجموع فعال‌سازی‌های جدید کل شبکه) // ✅ مرحله 4: محاسبه فلش (از هر دو طرف)
// طبق گفته دکتر: کل افراد جدید در شبکه × هزینه فعال‌سازی × 20% var flushedPerSide = totalBalances - cappedBalances; // 500-300=200
var totalNewMembers = leftNewMembers + rightNewMembers; var totalFlushed = flushedPerSide * 2; // 200×2=400 (از بین می‌رود)
var weeklyPoolContribution = (long)(totalNewMembers * activationFee * poolPercent);
// ⚠️ توجه: تعادل زیرمجموعه در این مرحله محاسبه نمیشه
// چون هنوز تمام تعادل‌ها محاسبه نشدن
// بعد از ذخیره همه تعادل‌ها، در یک حلقه دوم محاسبه خواهد شد
var balance = new NetworkWeeklyBalance var balance = new NetworkWeeklyBalance
{ {
UserId = user.Id, UserId = user.Id,
WeekNumber = request.WeekNumber, WeekDefinitionId = request.WeekDefinitionId,
// اطلاعات جدید // اطلاعات جدید
LeftLegNewMembers = leftNewMembers, LeftLegNewMembers = leftNewMembers,
@@ -122,19 +144,26 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
// مجموع // مجموع
LeftLegTotal = leftTotal, LeftLegTotal = leftTotal,
RightLegTotal = rightTotal, RightLegTotal = rightTotal,
TotalBalances = totalBalances, // تعادل واقعی بعد از اعمال سقف روی هر دست TotalBalances = cappedBalances, // امتیاز نهایی بعد از اعمال سقف 300
// باقیمانده برای هفته بعد (مازاد سقف هر دست) // باقیمانده برای هفته بعد (اضافه‌ای که یک طرف دارد)
LeftLegRemainder = leftRemainder, LeftLegRemainder = leftRemainder,
RightLegRemainder = rightRemainder, RightLegRemainder = rightRemainder,
// فلش (از دست رفته)
FlushedPerSide = flushedPerSide,
TotalFlushed = totalFlushed,
// تعادل زیرمجموعه - فعلاً 0 (بعد از ذخیره همه تعادل‌ها محاسبه میشه)
SubordinateBalances = 0,
// فیلدهای قدیمی (deprecated) - برای سازگاری با کدهای قبلی // فیلدهای قدیمی (deprecated) - برای سازگاری با کدهای قبلی
#pragma warning disable CS0618 #pragma warning disable CS0618
LeftLegBalances = leftTotal, LeftLegBalances = leftTotal,
RightLegBalances = rightTotal, RightLegBalances = rightTotal,
#pragma warning restore CS0618 #pragma warning restore CS0618
WeeklyPoolContribution = weeklyPoolContribution, WeeklyPoolContribution = 0, // Pool در مرحله بعد محاسبه میشه
CalculatedAt = calculatedAt, CalculatedAt = calculatedAt,
IsExpired = false IsExpired = false
}; };
@@ -145,40 +174,54 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken); await _context.NetworkWeeklyBalances.AddRangeAsync(balancesList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// ⭐ مرحله 2: محاسبه تعادل زیرمجموعه برای هر کاربر (تا 15 لول)
// حالا که همه تعادل‌ها ذخیره شدن، می‌تونیم تعادل زیرمجموعه رو حساب کنیم
var balancesDictionary = balancesList.ToDictionary(x => x.UserId);
foreach (var balance in balancesList)
{
var subordinateBalances = await CalculateSubordinateBalancesAsync(
balance.UserId,
balancesDictionary,
maxNetworkLevel,
cancellationToken
);
balance.SubordinateBalances = subordinateBalances;
}
// ذخیره تعادل‌های زیرمجموعه
_context.NetworkWeeklyBalances.UpdateRange(balancesList);
await _context.SaveChangesAsync(cancellationToken);
return balancesList.Count; return balancesList.Count;
} }
/// <summary> /// <summary>
/// شماره هفته قبل را محاسبه می‌کند /// شناسه هفته قبل را از WeekDefinitionRepository می‌گیرد
/// </summary> /// </summary>
private string GetPreviousWeekNumber(string currentWeekNumber) private long? GetPreviousWeekDefinitionId(long currentWeekDefinitionId)
{ {
// مثال: "2025-W48" -> "2025-W47" var currentWeekNumber = _weekRepository.GetGregorianWeekNumber(currentWeekDefinitionId);
var parts = currentWeekNumber.Split('-'); if (currentWeekNumber == null) return null;
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
week--; var previousWeekNumber = _weekRepository.GetPreviousWeekNumber(currentWeekNumber);
if (week < 1) if (previousWeekNumber == null) return null;
{
year--;
week = 52; // یا 53 بسته به سال
}
return $"{year}-W{week:D2}"; return _weekRepository.GetWeekDefinitionId(previousWeekNumber);
} }
/// <summary> /// <summary>
/// شمارش اعضای جدیدی که در این هفته به یک پا اضافه شدند /// شمارش اعضای جدیدی که در این هفته به یک پا اضافه شدند
/// تا maxLevel لول پایین‌تر شمارش می‌شود /// تا maxLevel لول پایین‌تر شمارش می‌شود
/// </summary> /// </summary>
private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, string weekNumber, int maxLevel, CancellationToken cancellationToken) private async Task<int> CountNewMembersInLeg(long userId, NetworkLeg leg, long WeekDefinitionId, int maxLevel, CancellationToken cancellationToken)
{ {
// تبدیل WeekNumber به بازه تاریخی // تبدیل WeekNumber به بازه تاریخی
var (startDate, endDate) = GetWeekDateRange(weekNumber); var startDateEndDate =_weekRepository.GetWeekDateRange(WeekDefinitionId);
// شمارش تمام اعضای زیرمجموعه که در این هفته فعال شدند (تا maxLevel لول) // شمارش تمام اعضای زیرمجموعه که در این هفته فعال شدند (تا maxLevel لول)
var count = await CountNewMembersRecursive(userId, leg, startDate, endDate, 0, maxLevel, cancellationToken); var count = await CountNewMembersRecursive(userId, leg, startDateEndDate.Value.startDate, startDateEndDate.Value.endDate, 0, maxLevel, cancellationToken);
return count; return count;
} }
@@ -230,22 +273,89 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
} }
/// <summary> /// <summary>
/// تبدیل شماره هفته به بازه تاریخی /// تبدیل شماره هفته به بازه تاریخی از WeekDefinitionRepository
/// </summary> /// </summary>
private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber) // private (DateTime startDate, DateTime endDate) GetWeekDateRange(string weekNumber)
// {
// var dateRange = _weekRepository.GetWeekDateRange(weekNumber);
// if (dateRange.HasValue)
// {
// return dateRange.Value;
// }
//
// // Fallback: محاسبه دستی اگر در کش نبود
// var parts = weekNumber.Split('-');
// var year = int.Parse(parts[0]);
// var week = int.Parse(parts[1].Replace("W", ""));
//
// var jan1 = new DateTime(year, 1, 1);
// var jan1DayOfWeek = (int)jan1.DayOfWeek;
// var daysToFirstSaturday = jan1DayOfWeek == 6 ? 0 : (6 - jan1DayOfWeek + 7) % 7;
// var firstSaturday = jan1.AddDays(daysToFirstSaturday);
//
// var weekStart = firstSaturday.AddDays((week - 1) * 7);
// var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
//
// return (weekStart, weekEnd);
// }
/// <summary>
/// محاسبه مجموع تعادل‌های زیرمجموعه یک کاربر تا maxLevel لول پایین‌تر
/// </summary>
private async Task<int> CalculateSubordinateBalancesAsync(
long userId,
Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel,
CancellationToken cancellationToken)
{ {
// مثال: "2025-W48" // پیدا کردن همه زیرمجموعه‌ها تا maxLevel لول
var parts = weekNumber.Split('-'); var subordinates = await GetSubordinatesRecursive(userId, 1, maxLevel, cancellationToken);
var year = int.Parse(parts[0]);
var week = int.Parse(parts[1].Replace("W", ""));
// محاسبه اولین روز هفته (شنبه) // جمع تعادل‌های آنها
var jan1 = new DateTime(year, 1, 1); var totalSubordinateBalances = 0;
var daysOffset = DayOfWeek.Saturday - jan1.DayOfWeek; foreach (var subordinateId in subordinates)
var firstSaturday = jan1.AddDays(daysOffset); {
var weekStart = firstSaturday.AddDays((week - 1) * 7); if (allBalances.ContainsKey(subordinateId))
var weekEnd = weekStart.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59); {
totalSubordinateBalances += allBalances[subordinateId].TotalBalances;
}
}
return (weekStart, weekEnd); return totalSubordinateBalances;
}
/// <summary>
/// پیدا کردن بازگشتی زیرمجموعه‌ها تا maxLevel لول
/// </summary>
private async Task<List<long>> GetSubordinatesRecursive(
long userId,
int currentLevel,
int maxLevel,
CancellationToken cancellationToken)
{
// محدودیت عمق
if (currentLevel > maxLevel)
{
return new List<long>();
}
var result = new List<long>();
// پیدا کردن فرزندان مستقیم
var children = await _context.Users
.Where(x => x.NetworkParentId == userId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
result.AddRange(children);
// بازگشت برای هر فرزند
foreach (var childId in children)
{
var grandChildren = await GetSubordinatesRecursive(childId, currentLevel + 1, maxLevel, cancellationToken);
result.AddRange(grandChildren);
}
return result;
} }
} }
@@ -4,11 +4,11 @@ public class CalculateWeeklyBalancesCommandValidator : AbstractValidator<Calcula
{ {
public CalculateWeeklyBalancesCommandValidator() public CalculateWeeklyBalancesCommandValidator()
{ {
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.NotEmpty() .NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد") .WithMessage("شماره هفته نمی‌تواند خالی باشد")
.Matches(@"^\d{4}-W\d{2}$") ;
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد (مثل 2025-W01)");
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -8,7 +8,7 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
/// <summary> /// <summary>
/// شماره هفته (فرمت: YYYY-Www) /// شماره هفته (فرمت: YYYY-Www)
/// </summary> /// </summary>
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// آیا محاسبه مجدد انجام شود؟ /// آیا محاسبه مجدد انجام شود؟
@@ -3,76 +3,305 @@ namespace CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommi
public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<CalculateWeeklyCommissionPoolCommand, long> public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<CalculateWeeklyCommissionPoolCommand, long>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public CalculateWeeklyCommissionPoolCommandHandler(IApplicationDbContext context) public CalculateWeeklyCommissionPoolCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{ {
_context = context; _context = context;
_weekRepository = weekRepository;
} }
public async Task<long> Handle(CalculateWeeklyCommissionPoolCommand request, CancellationToken cancellationToken) public async Task<long> Handle(CalculateWeeklyCommissionPoolCommand request, CancellationToken cancellationToken)
{ {
// بررسی وجود استخر قبلی // تبدیل WeekNumber به WeekDefinitionId
var existingPool = await _context.WeeklyCommissionPools var weekDefinitionId = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken); if (weekDefinitionId == null)
if (existingPool != null && existingPool.IsCalculated && !request.ForceRecalculate)
{ {
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} قبلاً محاسبه شده است"); throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
if (existingPool == null)
{
throw new InvalidOperationException(
$"Pool هفته {request.WeekDefinitionId} وجود ندارد. " +
"Pool باید در هنگام فعالسازی باشگاه مشتریان ایجاد شده باشد"
);
}
if (existingPool.IsCalculated && !request.ForceRecalculate)
{
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekDefinitionId} قبلاً محاسبه شده است");
} }
// بررسی وجود تعادل‌های هفتگی // بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (!weeklyBalances.Any()) if (!weeklyBalances.Any())
{ {
throw new InvalidOperationException($"تعادل‌های هفته {request.WeekNumber} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید"); throw new InvalidOperationException($"تعادل‌های هفته {request.WeekDefinitionId} هنوز محاسبه نشده است. ابتدا CalculateWeeklyBalances را اجرا کنید");
} }
// محاسبه مجموع مشارکت‌ها در استخر // ⭐ Pool از قبل پُر شده (توسط ActivateClubMembership)
var totalPoolAmount = weeklyBalances.Sum(x => x.WeeklyPoolContribution); var totalPoolAmount = existingPool.TotalPoolAmount;
// محاسبه مجموع Balances // محاسبه مجموع تعادل‌های کل شبکه
var totalBalances = weeklyBalances.Sum(x => x.TotalBalances); // نکته: SubordinateBalances اضافه نمی‌کنیم چون وقتی همه TotalBalances رو جمع می‌زنیم،
// خودش شامل بالانس‌های زیرمجموعه‌ها هم هست (تکراری نشه)
var totalBalancesInNetwork = weeklyBalances.Sum(x => x.TotalBalances);
// محاسبه ارزش هر Balance (تقسیم صحیح برای ریال) // محاسبه ارزش هر امتیاز
long valuePerBalance = 0; long valuePerBalance = 0;
if (totalBalances > 0) if (totalBalancesInNetwork > 0)
{ {
valuePerBalance = totalPoolAmount / totalBalances; valuePerBalance = totalPoolAmount / totalBalancesInNetwork;
} }
if (existingPool != null) // به‌روزرسانی Pool
{ existingPool.TotalBalances = totalBalancesInNetwork;
// به‌روزرسانی
existingPool.TotalPoolAmount = totalPoolAmount;
existingPool.TotalBalances = totalBalances;
existingPool.ValuePerBalance = valuePerBalance; existingPool.ValuePerBalance = valuePerBalance;
existingPool.IsCalculated = true; existingPool.IsCalculated = true;
existingPool.CalculatedAt = DateTime.UtcNow; existingPool.CalculatedAt = DateTime.Now;
_context.WeeklyCommissionPools.Update(existingPool); _context.WeeklyCommissionPools.Update(existingPool);
await _context.SaveChangesAsync(cancellationToken);
// حذف پرداخت‌های قبلی در صورت ForceRecalculate
if (request.ForceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
if (oldPayouts.Any())
{
var oldPayoutIds = oldPayouts.Select(p => p.Id).ToList();
// ⭐ ۱. برگرداندن NetworkBalance به حالت قبل (Revert)
await RevertUserWalletsAsync(oldPayoutIds, cancellationToken);
// ⭐ ۲. حذف تاریخچه‌ها (به خاطر FK constraint)
var oldHistories = await _context.CommissionPayoutHistories
.Where(h => oldPayoutIds.Contains(h.UserCommissionPayoutId))
.ToListAsync(cancellationToken);
if (oldHistories.Any())
{
_context.CommissionPayoutHistories.RemoveRange(oldHistories);
} }
else
// ⭐ ۳. حذف پرداخت‌ها
_context.UserCommissionPayouts.RemoveRange(oldPayouts);
await _context.SaveChangesAsync(cancellationToken);
}
}
// ⭐ ثبت پرداخت برای کاربرانی که تعادل دارند
// تعادل شخصی + زیرمجموعه قبلاً در CalculateWeeklyBalances محاسبه شده
var payouts = new List<UserCommissionPayout>();
foreach (var balance in weeklyBalances)
{ {
// ایجاد جدید // فقط تعادل شخصی (SubordinateBalances اضافه نمی‌شه چون در SUM کل شبکه خودش حساب میشه)
var pool = new WeeklyCommissionPool var userBalance = balance.TotalBalances;
// اگر تعادل صفر است، نیازی به ثبت نیست
if (userBalance <= 0)
{ {
WeekNumber = request.WeekNumber, continue;
TotalPoolAmount = totalPoolAmount, }
TotalBalances = totalBalances,
// محاسبه مبلغ کمیسیون
var totalAmount = (long)(userBalance * valuePerBalance);
var payout = new UserCommissionPayout
{
UserId = balance.UserId,
WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = existingPool.Id,
BalancesEarned = userBalance,
ValuePerBalance = valuePerBalance, ValuePerBalance = valuePerBalance,
IsCalculated = true, TotalAmount = totalAmount,
CalculatedAt = DateTime.UtcNow Status = CommissionPayoutStatus.Pending,
PaidAt = null,
WithdrawalMethod = null,
IbanNumber = null,
WithdrawnAt = null
}; };
await _context.WeeklyCommissionPools.AddAsync(pool, cancellationToken); payouts.Add(payout);
existingPool = pool;
} }
if (payouts.Any())
{
await _context.UserCommissionPayouts.AddRangeAsync(payouts, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه برای هر پرداخت
var historyList = new List<CommissionPayoutHistory>();
foreach (var payout in payouts)
{
var history = new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekDefinitionId = request.WeekDefinitionId,
AmountBefore = 0,
AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus),
NewStatus = CommissionPayoutStatus.Pending,
Action = CommissionPayoutAction.Created,
PerformedBy = "System",
Reason = "پردازش خودکار کمیسیون هفتگی"
};
historyList.Add(history);
}
await _context.CommissionPayoutHistories.AddRangeAsync(historyList, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
// ⭐ شارژ کیف پول کاربران (NetworkBalance) و ثبت لاگ
await ChargeUserWalletsAsync(payouts, request.WeekDefinitionId, cancellationToken);
}
return existingPool.Id; return existingPool.Id;
} }
/// <summary>
/// شارژ کیف پول کاربران با مبلغ کمیسیون (به NetworkBalance اضافه می‌شود)
/// و ثبت لاگ تغییرات کیف پول
/// </summary>
private async Task ChargeUserWalletsAsync(
List<UserCommissionPayout> payouts,
long weekDefinitionId,
CancellationToken cancellationToken)
{
var userIds = payouts.Select(p => p.UserId).Distinct().ToList();
// دریافت کیف پول‌های موجود
var existingWallets = await _context.UserWallets
.Where(w => userIds.Contains(w.UserId))
.ToDictionaryAsync(w => w.UserId, cancellationToken);
var newWallets = new List<UserWallet>();
var walletLogs = new List<UserWalletChangeLog>();
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
UserWallet wallet;
// پیدا کردن یا ایجاد کیف پول
if (existingWallets.TryGetValue(payout.UserId, out var existingWallet))
{
wallet = existingWallet;
}
else
{
// ایجاد کیف پول جدید
wallet = new UserWallet
{
UserId = payout.UserId,
Balance = 0,
NetworkBalance = 0,
DiscountBalance = 0
};
newWallets.Add(wallet);
existingWallets[payout.UserId] = wallet; // برای جلوگیری از تکرار
}
}
// ذخیره کیف پول‌های جدید
if (newWallets.Any())
{
await _context.UserWallets.AddRangeAsync(newWallets, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
// شارژ کیف پول‌ها و ثبت لاگ
foreach (var payout in payouts)
{
if (payout.TotalAmount <= 0) continue;
var wallet = existingWallets[payout.UserId];
// ذخیره موجودی قبلی برای لاگ
var previousNetworkBalance = wallet.NetworkBalance;
// شارژ NetworkBalance
wallet.NetworkBalance += payout.TotalAmount;
// ایجاد لاگ تغییر کیف پول
var walletLog = new UserWalletChangeLog
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = 0, // Balance اصلی تغییر نمی‌کند
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = payout.TotalAmount,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = payout.Id // ارجاع به UserCommissionPayout
};
walletLogs.Add(walletLog);
}
// ذخیره تغییرات کیف پول و لاگ‌ها
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// برگرداندن NetworkBalance به حالت قبل از شارژ کمیسیون
/// این متد قبل از محاسبه مجدد (ForceRecalculate) اجرا می‌شود
/// </summary>
private async Task RevertUserWalletsAsync(
List<long> oldPayoutIds,
CancellationToken cancellationToken)
{
// پیدا کردن لاگ‌های کیف پول مرتبط با پرداخت‌های قبلی
var oldWalletLogs = await _context.UserWalletChangeLogs
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
.ToListAsync(cancellationToken);
if (!oldWalletLogs.Any()) return;
// پیدا کردن کیف پول‌های مرتبط
var walletIds = oldWalletLogs.Select(l => l.WalletId).Distinct().ToList();
var wallets = await _context.UserWallets
.Where(w => walletIds.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, cancellationToken);
// برگرداندن NetworkBalance
foreach (var log in oldWalletLogs)
{
if (wallets.TryGetValue(log.WalletId, out var wallet))
{
// کم کردن مبلغ شارژ شده قبلی از NetworkBalance
wallet.NetworkBalance -= log.ChangeNerworkValue;
// اطمینان از منفی نشدن
if (wallet.NetworkBalance < 0)
{
wallet.NetworkBalance = 0;
}
}
}
// حذف لاگ‌های قبلی
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
await _context.SaveChangesAsync(cancellationToken);
}
} }
@@ -4,11 +4,10 @@ public class CalculateWeeklyCommissionPoolCommandValidator : AbstractValidator<C
{ {
public CalculateWeeklyCommissionPoolCommandValidator() public CalculateWeeklyCommissionPoolCommandValidator()
{ {
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.NotEmpty() .NotNull()
.WithMessage("شماره هفته نمی‌تواند خالی باشد") .GreaterThan(0)
.Matches(@"^\d{4}-W\d{2}$") .WithMessage("شماره هفته نمی‌تواند خالی باشد");
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -8,7 +8,7 @@ public record ProcessUserPayoutsCommand : IRequest<int>
/// <summary> /// <summary>
/// شماره هفته /// شماره هفته
/// </summary> /// </summary>
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// آیا پرداخت مجدد انجام شود؟ /// آیا پرداخت مجدد انجام شود؟
@@ -1,33 +1,45 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts; namespace CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int> public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayoutsCommand, int>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekRepository;
public ProcessUserPayoutsCommandHandler(IApplicationDbContext context) public ProcessUserPayoutsCommandHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekRepository)
{ {
_context = context; _context = context;
_weekRepository = weekRepository;
} }
public async Task<int> Handle(ProcessUserPayoutsCommand request, CancellationToken cancellationToken) public async Task<int> Handle(ProcessUserPayoutsCommand request, CancellationToken cancellationToken)
{ {
var regorianWeekNumber = _weekRepository.GetGregorianWeekNumber(request.WeekDefinitionId);
if (regorianWeekNumber == null)
{
throw new InvalidOperationException($"هفته {request.WeekDefinitionId} در سیستم تعریف نشده است");
}
// بررسی وجود استخر // بررسی وجود استخر
var pool = await _context.WeeklyCommissionPools var pool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekNumber == request.WeekNumber, cancellationToken); .FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
if (pool == null || !pool.IsCalculated) if (pool == null || !pool.IsCalculated)
{ {
throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekNumber} هنوز محاسبه نشده است"); throw new InvalidOperationException($"استخر کمیسیون هفته {request.WeekDefinitionId} هنوز محاسبه نشده است");
} }
// بررسی پرداخت قبلی // بررسی پرداخت قبلی
var existingPayouts = await _context.UserCommissionPayouts var existingPayouts = await _context.UserCommissionPayouts
.Where(x => x.WeekNumber == request.WeekNumber) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (existingPayouts.Any() && !request.ForceReprocess) if (existingPayouts.Any() && !request.ForceReprocess)
{ {
throw new InvalidOperationException($"پرداخت‌های هفته {request.WeekNumber} قبلاً انجام شده است"); throw new InvalidOperationException($"پرداخت‌های هفته {request.WeekDefinitionId} قبلاً انجام شده است");
} }
// حذف پرداخت‌های قبلی در صورت ForceReprocess // حذف پرداخت‌های قبلی در صورت ForceReprocess
@@ -37,21 +49,17 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
// ⭐ خواندن MaxNetworkLevel از Config // ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
var maxNetworkLevelConfig = await _context.SystemConfigurations var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
.Where(x => x.Key == "Commission.MaxNetworkLevel" && x.IsActive)
.Select(x => x.Value)
.FirstOrDefaultAsync(cancellationToken);
var maxNetworkLevel = int.Parse(maxNetworkLevelConfig ?? "15");
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه) // دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances var allWeeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToDictionaryAsync(x => x.UserId, cancellationToken); .ToDictionaryAsync(x => x.UserId, cancellationToken);
// دریافت کاربرانی که تعادل > 0 دارند (یا زیرمجموعه‌شان دارد) // دریافت کاربرانی که تعادل > 0 دارند (یا زیرمجموعه‌شان دارد)
var usersWithBalances = await _context.NetworkWeeklyBalances var usersWithBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekNumber == request.WeekNumber && x.TotalBalances > 0) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId && x.TotalBalances > 0)
.Select(x => x.UserId) .Select(x => x.UserId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -82,7 +90,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
// ⭐ محاسبه مجموع تعادل‌های زیرمجموعه تا maxNetworkLevel لول // ⭐ محاسبه مجموع تعادل‌های زیرمجموعه تا maxNetworkLevel لول
var subordinateBalances = await CalculateSubordinateBalancesAsync( var subordinateBalances = await CalculateSubordinateBalancesAsync(
userId, userId,
request.WeekNumber, request.WeekDefinitionId,
allWeeklyBalances, allWeeklyBalances,
maxNetworkLevel, maxNetworkLevel,
cancellationToken cancellationToken
@@ -103,7 +111,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
var payout = new UserCommissionPayout var payout = new UserCommissionPayout
{ {
UserId = userId, UserId = userId,
WeekNumber = request.WeekNumber, WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = pool.Id, WeeklyPoolId = pool.Id,
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
ValuePerBalance = pool.ValuePerBalance, ValuePerBalance = pool.ValuePerBalance,
@@ -129,7 +137,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
{ {
UserCommissionPayoutId = payout.Id, UserCommissionPayoutId = payout.Id,
UserId = payout.UserId, UserId = payout.UserId,
WeekNumber = request.WeekNumber, WeekDefinitionId = request.WeekDefinitionId,
AmountBefore = 0, AmountBefore = 0,
AmountAfter = payout.TotalAmount, AmountAfter = payout.TotalAmount,
OldStatus = default(CommissionPayoutStatus), OldStatus = default(CommissionPayoutStatus),
@@ -180,7 +188,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
/// </summary> /// </summary>
private async Task<int> CalculateSubordinateBalancesAsync( private async Task<int> CalculateSubordinateBalancesAsync(
long userId, long userId,
string weekNumber, long WeekDefinitionId,
Dictionary<long, NetworkWeeklyBalance> allBalances, Dictionary<long, NetworkWeeklyBalance> allBalances,
int maxLevel, int maxLevel,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -4,11 +4,10 @@ public class ProcessUserPayoutsCommandValidator : AbstractValidator<ProcessUserP
{ {
public ProcessUserPayoutsCommandValidator() public ProcessUserPayoutsCommandValidator()
{ {
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.NotEmpty() .NotNull()
.WithMessage("شماره هفته نمی‌تواند خالی باشد") .GreaterThan(0)
.Matches(@"^\d{4}-W\d{2}$") .WithMessage("شماره هفته نمی‌تواند خالی باشد");
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -39,7 +39,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
} }
var oldStatus = payout.Status; var oldStatus = payout.Status;
var now = DateTime.UtcNow; var now = DateTime.Now;
if (request.IsApproved) if (request.IsApproved)
{ {
@@ -73,7 +73,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
UserId = payout.UserId, UserId = payout.UserId,
Iban = payout.IbanNumber ?? throw new InvalidOperationException("شماره شبا یافت نشد"), Iban = payout.IbanNumber ?? throw new InvalidOperationException("شماره شبا یافت نشد"),
AccountHolderName = $"{payout.User.FirstName} {payout.User.LastName}", AccountHolderName = $"{payout.User.FirstName} {payout.User.LastName}",
Description = $"برداشت کمیسیون هفته {payout.WeekNumber}", Description = $"برداشت کمیسیون هفته {payout.WeekDefinitionId}",
InternalRefId = $"PAYOUT-{payout.Id}" InternalRefId = $"PAYOUT-{payout.Id}"
}; };
@@ -120,7 +120,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
{ {
UserCommissionPayoutId = payout.Id, UserCommissionPayoutId = payout.Id,
UserId = payout.UserId, UserId = payout.UserId,
WeekNumber = payout.WeekNumber, WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount, AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount, AmountAfter = payout.TotalAmount,
OldStatus = oldStatus, OldStatus = oldStatus,
@@ -147,7 +147,7 @@ public class ProcessWithdrawalCommandHandler : IRequestHandler<ProcessWithdrawal
{ {
UserCommissionPayoutId = payout.Id, UserCommissionPayoutId = payout.Id,
UserId = payout.UserId, UserId = payout.UserId,
WeekNumber = payout.WeekNumber, WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount, AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount, AmountAfter = payout.TotalAmount,
OldStatus = oldStatus, OldStatus = oldStatus,
@@ -34,9 +34,9 @@ public class RejectWithdrawalCommandHandler : IRequestHandler<RejectWithdrawalCo
// Update status to Cancelled (rejected) // Update status to Cancelled (rejected)
payout.Status = CommissionPayoutStatus.Cancelled; payout.Status = CommissionPayoutStatus.Cancelled;
payout.ProcessedBy = _currentUser.GetPerformedBy(); payout.ProcessedBy = _currentUser.GetPerformedBy();
payout.ProcessedAt = DateTime.UtcNow; payout.ProcessedAt = DateTime.Now;
payout.RejectionReason = request.Reason; payout.RejectionReason = request.Reason;
payout.LastModified = DateTime.UtcNow; payout.LastModified = DateTime.Now;
// TODO: Add PayoutHistory record with rejection reason // TODO: Add PayoutHistory record with rejection reason
// var history = new CommissionPayoutHistory // var history = new CommissionPayoutHistory
@@ -51,7 +51,7 @@ public class RejectWithdrawalCommandHandler : IRequestHandler<RejectWithdrawalCo
// Action = (int)CommissionPayoutAction.Rejected, // Action = (int)CommissionPayoutAction.Rejected,
// PerformedBy = "Admin", // TODO: Get from authenticated user // PerformedBy = "Admin", // TODO: Get from authenticated user
// Reason = request.Reason, // Reason = request.Reason,
// Created = DateTime.UtcNow // Created = DateTime.Now
// }; // };
// _context.CommissionPayoutHistories.Add(history); // _context.CommissionPayoutHistories.Add(history);
@@ -48,7 +48,7 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
{ {
UserCommissionPayoutId = payout.Id, UserCommissionPayoutId = payout.Id,
UserId = payout.UserId, UserId = payout.UserId,
WeekNumber = payout.WeekNumber, WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount, AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount, AmountAfter = payout.TotalAmount,
OldStatus = oldStatus, OldStatus = oldStatus,
@@ -5,7 +5,7 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
/// <summary> /// <summary>
/// شماره هفته (فرمت: "YYYY-Www") /// شماره هفته (فرمت: "YYYY-Www")
/// </summary> /// </summary>
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// اگر true باشد، محاسبات قبلی را حذف و دوباره محاسبه می‌کند /// اگر true باشد، محاسبات قبلی را حذف و دوباره محاسبه می‌کند
@@ -13,17 +13,12 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
public bool ForceRecalculate { get; init; } public bool ForceRecalculate { get; init; }
/// <summary> /// <summary>
/// Skip balance calculation /// Skip balance calculation (Step 1)
/// </summary> /// </summary>
public bool SkipBalances { get; init; } public bool SkipBalances { get; init; }
/// <summary> /// <summary>
/// Skip pool calculation /// Skip pool calculation and payout processing (Step 2)
/// </summary>
public bool SkipPool { get; init; }
/// <summary>
/// Skip payout processing
/// </summary> /// </summary>
public bool SkipPayouts { get; init; } public bool SkipPayouts { get; init; }
} }
@@ -1,7 +1,6 @@
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances; using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyBalances;
using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool; using CMSMicroservice.Application.CommissionCQ.Commands.CalculateWeeklyCommissionPool;
using CMSMicroservice.Application.CommissionCQ.Commands.ProcessUserPayouts;
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation; namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
@@ -9,13 +8,16 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly ICommissionCalculationStrategyFactory _strategyFactory;
public TriggerWeeklyCalculationCommandHandler( public TriggerWeeklyCalculationCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IMediator mediator) IMediator mediator,
ICommissionCalculationStrategyFactory strategyFactory)
{ {
_context = context; _context = context;
_mediator = mediator; _mediator = mediator;
_strategyFactory = strategyFactory;
} }
public async Task<TriggerWeeklyCalculationResponseDto> Handle( public async Task<TriggerWeeklyCalculationResponseDto> Handle(
@@ -23,12 +25,12 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var executionId = Guid.NewGuid().ToString(); var executionId = Guid.NewGuid().ToString();
var startedAt = DateTime.UtcNow; var startedAt = DateTime.Now;
try try
{ {
// Validate week number format // Validate week number format
if (string.IsNullOrWhiteSpace(request.WeekNumber)) if (request.WeekDefinitionId <= 0)
{ {
return new TriggerWeeklyCalculationResponseDto return new TriggerWeeklyCalculationResponseDto
{ {
@@ -41,42 +43,36 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
var steps = new List<string>(); var steps = new List<string>();
// Step 1: Calculate Weekly Balances // ⭐ دریافت استراتژی براساس Config (ORM یا SP)
var strategy = await _strategyFactory.CreateStrategyAsync(cancellationToken);
var strategyName = strategy.GetType().Name.Contains("StoredProcedure") ? "SP" : "ORM";
// Step 1: Calculate Weekly Balances (تا 15 لول)
if (!request.SkipBalances) if (!request.SkipBalances)
{ {
await _mediator.Send(new CalculateWeeklyBalancesCommand var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
{ request.WeekDefinitionId,
WeekNumber = request.WeekNumber, request.ForceRecalculate,
ForceRecalculate = request.ForceRecalculate cancellationToken);
}, cancellationToken);
steps.Add("محاسبه امتیازات هفتگی"); steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
} }
// Step 2: Calculate Weekly Commission Pool // Step 2: Calculate Pool & Process Payouts (محاسبه استخر + پرداخت کاربران)
if (!request.SkipPool)
{
await _mediator.Send(new CalculateWeeklyCommissionPoolCommand
{
WeekNumber = request.WeekNumber
}, cancellationToken);
steps.Add("محاسبه استخر کمیسیون");
}
// Step 3: Process User Payouts
if (!request.SkipPayouts) if (!request.SkipPayouts)
{ {
await _mediator.Send(new ProcessUserPayoutsCommand var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
{ request.WeekDefinitionId,
WeekNumber = request.WeekNumber, request.ForceRecalculate,
ForceReprocess = request.ForceRecalculate cancellationToken);
}, cancellationToken);
steps.Add("پردازش پرداخت‌های کاربران"); steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
} }
return new TriggerWeeklyCalculationResponseDto return new TriggerWeeklyCalculationResponseDto
{ {
Success = true, Success = true,
Message = $"محاسبات هفته {request.WeekNumber} با موفقیت انجام شد. مراحل: {string.Join(", ", steps)}", Message = $"محاسبات هفته {request.WeekDefinitionId} با موفقیت انجام شد [{strategyName}]. مراحل: {string.Join(", ", steps)}",
ExecutionId = executionId, ExecutionId = executionId,
StartedAt = startedAt StartedAt = startedAt
}; };
@@ -0,0 +1,27 @@
using CMSMicroservice.Application.CommissionCQ.Commands.ProcessWithdrawal;
namespace CMSMicroservice.Application.CommissionCQ.Commands.TriggerWeeklyCalculation;
public class TriggerWeeklyCalculationCommandValidator : AbstractValidator<TriggerWeeklyCalculationCommand>
{
public TriggerWeeklyCalculationCommandValidator()
{
RuleFor(x => x.WeekDefinitionId)
.NotNull()
.GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<TriggerWeeklyCalculationCommand>.CreateWithOptions(
(TriggerWeeklyCalculationCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -8,12 +8,12 @@ public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
/// <summary> /// <summary>
/// از هفته (فیلتر اختیاری) /// از هفته (فیلتر اختیاری)
/// </summary> /// </summary>
public string? FromWeek { get; init; } public int? FromWeekOrder { get; init; }
/// <summary> /// <summary>
/// تا هفته (فیلتر اختیاری) /// تا هفته (فیلتر اختیاری)
/// </summary> /// </summary>
public string? ToWeek { get; init; } public int? ToWeekOrder { get; init; }
/// <summary> /// <summary>
/// فقط Pool های محاسبه شده /// فقط Pool های محاسبه شده
@@ -3,25 +3,31 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAllWeeklyPools;
public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQuery, GetAllWeeklyPoolsResponseDto> public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQuery, GetAllWeeklyPoolsResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetAllWeeklyPoolsQueryHandler(IApplicationDbContext context) public GetAllWeeklyPoolsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<GetAllWeeklyPoolsResponseDto> Handle(GetAllWeeklyPoolsQuery request, CancellationToken cancellationToken) public async Task<GetAllWeeklyPoolsResponseDto> Handle(GetAllWeeklyPoolsQuery request, CancellationToken cancellationToken)
{ {
var query = _context.WeeklyCommissionPools.AsNoTracking(); var query = _context.WeeklyCommissionPools
.Include(i=> i.WeekDefinition)
.AsNoTracking();
// Apply filters // Apply filters
if (!string.IsNullOrWhiteSpace(request.FromWeek)) if (request.FromWeekOrder!=null)
{ {
query = query.Where(x => string.Compare(x.WeekNumber, request.FromWeek) >= 0); query = query.Where(x => x.WeekDefinition.WeekOrder>=request.FromWeekOrder );
} }
if (!string.IsNullOrWhiteSpace(request.ToWeek)) if (request.ToWeekOrder!=null)
{ {
query = query.Where(x => string.Compare(x.WeekNumber, request.ToWeek) <= 0); query = query.Where(x =>x.WeekDefinition.WeekOrder<= request.ToWeekOrder);
} }
if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value) if (request.OnlyCalculated.HasValue && request.OnlyCalculated.Value)
@@ -30,7 +36,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
} }
// Order by week number descending (newest first) // Order by week number descending (newest first)
query = query.OrderByDescending(x => x.WeekNumber); query = query.OrderByDescending(x => x.WeekDefinitionId);
// Count total // Count total
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
@@ -42,7 +48,8 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
.Select(x => new WeeklyCommissionPoolDto .Select(x => new WeeklyCommissionPoolDto
{ {
Id = x.Id, Id = x.Id,
WeekNumber = x.WeekNumber, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount, TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances, TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance, ValuePerBalance = x.ValuePerBalance,
@@ -52,6 +59,19 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Populate WeekDisplayName from cache
// foreach (var pool in pools)
// {
// // WeeklyCommissionPoolDto is a record, need to create new instance with display name
// // Since records are immutable, we can't modify them directly
// }
// // Create new list with WeekDisplayName populated
// var poolsWithDisplayName = pools.Select(p => p with
// {
// WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(p.WeekNumber)
// }).ToList();
return new GetAllWeeklyPoolsResponseDto return new GetAllWeeklyPoolsResponseDto
{ {
MetaData = new MetaDataDto MetaData = new MetaDataDto
@@ -9,7 +9,8 @@ public record GetAllWeeklyPoolsResponseDto
public record WeeklyCommissionPoolDto public record WeeklyCommissionPoolDto
{ {
public long Id { get; init; } public long Id { get; init; }
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
public string WeekDisplayName { get; init; } = string.Empty;
public long TotalPoolAmount { get; init; } public long TotalPoolAmount { get; init; }
public int TotalBalances { get; init; } public int TotalBalances { get; init; }
public long ValuePerBalance { get; init; } public long ValuePerBalance { get; init; }
@@ -0,0 +1,13 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
/// <summary>
/// دریافت لیست هفته‌های قابل انتخاب برای محاسبه کمیسیون
/// </summary>
public class GetAvailableWeeksQuery : IRequest<GetAvailableWeeksResponseDto>
{
/// <summary>تعداد هفته‌های آینده برای نمایش (پیش‌فرض: 4)</summary>
public int FutureWeeksCount { get; init; } = 4;
/// <summary>تعداد هفته‌های گذشته برای نمایش (پیش‌فرض: 12)</summary>
public int PastWeeksCount { get; init; } = 12;
}
@@ -0,0 +1,211 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using DateTimeConverterCL;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
public class GetAvailableWeeksQueryHandler : IRequestHandler<GetAvailableWeeksQuery, GetAvailableWeeksResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetAvailableWeeksQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{
_context = context;
_weekDefinitionRepository = weekDefinitionRepository;
}
public async Task<GetAvailableWeeksResponseDto> Handle(
GetAvailableWeeksQuery request,
CancellationToken cancellationToken)
{
var currentWeekDef = _weekDefinitionRepository.GetCurrentWeek();
// var currentWeekNumber = currentWeekDef != null
// ? $"{currentWeekDef.GregorianYear}-W{currentWeekDef.GregorianWeekNumber:D2}"
// : _weekDefinitionRepository.CalculateGregorianWeekNumber(DateTime.Now);
// دریافت هفته‌های محاسبه شده از دیتابیس
var calculatedPools = await _context.WeeklyCommissionPools
.Where(p => p.IsCalculated)
.OrderByDescending(p => p.WeekDefinitionId)
.Take(request.PastWeeksCount)
.ToListAsync(cancellationToken);
// دریافت لاگ‌های اجرا
var executionLogs = await _context.WorkerExecutionLogs
.Where(log => log.Status == WorkerExecutionStatus.Success ||
log.Status == WorkerExecutionStatus.Failed)
.GroupBy(log => log.WeekDefinitionId)
.Select(g => new
{
WeekNumber = g.Key,
LastLog = g.OrderByDescending(l => l.StartedAt).First()
})
.ToDictionaryAsync(x => x.WeekNumber, x => x.LastLog, cancellationToken);
var allWeeks = new List<WeekInfoDto>();
// هفته جاری
var currentDate = DateTime.Now;
var currentWeekInfo = CreateWeekInfo(currentDate, currentWeekDef.Id, calculatedPools, executionLogs);
// هفته‌های گذشته (12 هفته)
var pastWeeks = new List<WeekInfoDto>();
for (int i = 1; i <= request.PastWeeksCount; i++)
{
var pastDate = currentDate.AddDays(-7 * i);
var weekNumber = GetWeekNumberUsingRepository(pastDate);
pastWeeks.Add(CreateWeekInfo(pastDate, currentWeekDef.Id, calculatedPools, executionLogs));
}
// هفته‌های آینده (4 هفته)
var futureWeeks = new List<WeekInfoDto>();
for (int i = 1; i <= request.FutureWeeksCount; i++)
{
var futureDate = currentDate.AddDays(7 * i);
var weekNumber = GetWeekNumberUsingRepository(futureDate);
futureWeeks.Add(CreateWeekInfo(futureDate, currentWeekDef.Id, calculatedPools, executionLogs));
}
// تفکیک به calculated و pending
var calculatedWeeks = pastWeeks.Where(w => w.IsCalculated).ToList();
var pendingWeeks = pastWeeks.Where(w => !w.IsCalculated).ToList();
return new GetAvailableWeeksResponseDto
{
CurrentWeek = currentWeekInfo,
CalculatedWeeks = calculatedWeeks,
PendingWeeks = pendingWeeks,
FutureWeeks = futureWeeks
};
}
private string GetWeekNumberUsingRepository(DateTime date)
{
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
if (weekDef != null)
{
return $"{weekDef.GregorianYear}-W{weekDef.GregorianWeekNumber:D2}";
}
// Fallback to manual calculation if not in cache
return _weekDefinitionRepository.CalculateGregorianWeekNumber(date);
}
private WeekInfoDto CreateWeekInfo(
DateTime date,
long weekDefinitionId,
List<Domain.Entities.Commission.WeeklyCommissionPool> calculatedPools,
Dictionary<long, Domain.Entities.Commission.WorkerExecutionLog> executionLogs)
{
// Get the actual WeekDefinition for this date
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
var actualWeekDefId = weekDef?.Id ?? weekDefinitionId;
var (startDate, endDate) = GetWeekRange(date);
var pool = calculatedPools.FirstOrDefault(p => p.WeekDefinitionId == actualWeekDefId);
var log = executionLogs.GetValueOrDefault(actualWeekDefId);
var isCalculated = pool != null && pool.IsCalculated;
//
// // تبدیل تاریخ به شمسی برای نمایش
// var persianCalendar = new PersianCalendar();
// var startDatePersian = $"{persianCalendar.GetYear(startDate):D4}/{persianCalendar.GetMonth(startDate):D2}/{persianCalendar.GetDayOfMonth(startDate):D2}";
// var endDatePersian = $"{persianCalendar.GetYear(endDate):D4}/{persianCalendar.GetMonth(endDate):D2}/{persianCalendar.GetDayOfMonth(endDate):D2}";
// استفاده از DisplayName از WeekDefinition
var displayName = weekDef?.DisplayName ?? GetDisplayName(startDate);
string displayText;
if (!string.IsNullOrEmpty(displayName))
{
// اگر DisplayName وجود داشت، از آن استفاده کن
displayText = $"{displayName} ({startDate.MiladiToJalali()} - {endDate.MiladiToJalali()})";
}
else
{
// تبدیل weekNumber به شمسی فقط برای نمایش (fallback)
var persianWeekNumber = _weekDefinitionRepository.GetWeekById(actualWeekDefId);
displayText = $"{persianWeekNumber?.PersianWeekNumber} ({startDate.MiladiToJalali()} - {endDate.MiladiToJalali()})";
}
if (isCalculated)
{
displayText += " ✅ محاسبه شده";
}
return new WeekInfoDto
{
WeekDefinitionId = actualWeekDefId,
DisplayName = weekDef?.DisplayName ?? _weekDefinitionRepository.GetWeekById(actualWeekDefId)?.DisplayName ?? "", // میلادی (برای API)
StartDate = startDate,
EndDate = endDate,
IsCalculated = isCalculated,
CalculatedAt = pool?.CalculatedAt,
LastExecutionStatus = log?.Status.ToString(),
TotalPoolAmount = pool?.TotalPoolAmount,
EligibleUsersCount = pool?.UserCommissionPayouts?.Count ?? 0,
DisplayText = displayText // شمسی (برای نمایش)
};
}
private (DateTime startDate, DateTime endDate) GetWeekRange(DateTime date)
{
// First try to get from repository
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
if (weekDef != null)
{
return (weekDef.StartDate, weekDef.EndDate);
}
// Fallback: manual calculation
var dayOfWeek = (int)date.DayOfWeek;
// محاسبه تعداد روزهایی که باید عقب برویم تا به شنبه برسیم
// شنبه = 6, یکشنبه = 0, دوشنبه = 1, ..., جمعه = 5
var daysToSaturday = dayOfWeek == 6 ? 0 : (dayOfWeek + 1) % 7;
var startDate = date.Date.AddDays(-daysToSaturday);
var endDate = startDate.AddDays(6).AddHours(23).AddMinutes(59).AddSeconds(59);
return (startDate, endDate);
}
private string GetDisplayName(DateTime date)
{
var weekDef = _weekDefinitionRepository.GetWeekByDate(date);
return weekDef?.DisplayName ?? "";
}
// private static string ConvertWeekNumberToPersian(string gregorianWeekNumber, DateTime weekStartDate)
// {
// // ورودی: "2025-W48"
// var persianCalendar = new PersianCalendar();
// var persianYear = persianCalendar.GetYear(weekStartDate);
//
// // محاسبه شماره هفته شمسی
// var jan1Persian = persianCalendar.ToDateTime(persianYear, 1, 1, 0, 0, 0, 0);
//
// // پیدا کردن اولین شنبه سال شمسی
// var jan1DayOfWeek = (int)jan1Persian.DayOfWeek;
// var daysToFirstSaturday = jan1DayOfWeek == 6 ? 0 : (6 - jan1DayOfWeek + 7) % 7;
// var firstSaturday = jan1Persian.AddDays(daysToFirstSaturday);
//
// // محاسبه شماره هفته
// int weekNum;
// if (weekStartDate < firstSaturday)
// {
// weekNum = 1;
// }
// else
// {
// var daysSinceFirstSaturday = (weekStartDate - firstSaturday).Days;
// weekNum = (daysSinceFirstSaturday / 7) + 1;
// }
//
// return $"{persianYear}-W{weekNum:D2}";
// }
}
@@ -0,0 +1,49 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetAvailableWeeks;
public class GetAvailableWeeksResponseDto
{
/// <summary>هفته جاری</summary>
public required WeekInfoDto CurrentWeek { get; init; }
/// <summary>هفته‌های محاسبه شده (از جدیدترین به قدیمی‌ترین)</summary>
public required List<WeekInfoDto> CalculatedWeeks { get; init; }
/// <summary>هفته‌های محاسبه نشده (از جدیدترین به قدیمی‌ترین)</summary>
public required List<WeekInfoDto> PendingWeeks { get; init; }
/// <summary>هفته‌های آینده قابل انتخاب</summary>
public required List<WeekInfoDto> FutureWeeks { get; init; }
}
public class WeekInfoDto
{
/// <summary>شناسه یکتای هفته</summary>
public required long WeekDefinitionId { get; init; }
/// <summary>شماره هفته (YYYY-Www)</summary>
public required string DisplayName { get; init; }
/// <summary>تاریخ شروع هفته</summary>
public required DateTime StartDate { get; init; }
/// <summary>تاریخ پایان هفته</summary>
public required DateTime EndDate { get; init; }
/// <summary>آیا محاسبه شده؟</summary>
public bool IsCalculated { get; init; }
/// <summary>تاریخ محاسبه (اگر محاسبه شده باشد)</summary>
public DateTime? CalculatedAt { get; init; }
/// <summary>وضعیت اجرای آخرین محاسبه</summary>
public string? LastExecutionStatus { get; init; }
/// <summary>مبلغ کل استخر کمیسیون (اگر محاسبه شده باشد)</summary>
public long? TotalPoolAmount { get; init; }
/// <summary>تعداد کاربران واجد شرایط</summary>
public int? EligibleUsersCount { get; init; }
/// <summary>نمایش فارسی (برای UI)</summary>
public required string DisplayText { get; init; }
}
@@ -18,7 +18,7 @@ public record GetCommissionPayoutHistoryQuery : IRequest<GetCommissionPayoutHist
/// <summary> /// <summary>
/// شماره هفته (اختیاری) /// شماره هفته (اختیاری)
/// </summary> /// </summary>
public string? WeekNumber { get; init; } public long? WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// مرتب‌سازی /// مرتب‌سازی
@@ -3,15 +3,20 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetCommissionPayoutHi
public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponseDto> public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommissionPayoutHistoryQuery, GetCommissionPayoutHistoryResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetCommissionPayoutHistoryQueryHandler(IApplicationDbContext context) public GetCommissionPayoutHistoryQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<GetCommissionPayoutHistoryResponseDto> Handle(GetCommissionPayoutHistoryQuery request, CancellationToken cancellationToken) public async Task<GetCommissionPayoutHistoryResponseDto> Handle(GetCommissionPayoutHistoryQuery request, CancellationToken cancellationToken)
{ {
var query = _context.CommissionPayoutHistories var query = _context.CommissionPayoutHistories
.Include(i=>i.WeekDefinition)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
@@ -26,12 +31,12 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
query = query.Where(x => x.UserId == request.UserId.Value); query = query.Where(x => x.UserId == request.UserId.Value);
} }
if (!string.IsNullOrEmpty(request.WeekNumber)) if (request.WeekDefinitionId!=null)
{ {
query = query.Where(x => x.WeekNumber == request.WeekNumber); query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
} }
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -42,7 +47,8 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
Id = x.Id, Id = x.Id,
UserCommissionPayoutId = x.UserCommissionPayoutId, UserCommissionPayoutId = x.UserCommissionPayoutId,
UserId = x.UserId, UserId = x.UserId,
WeekNumber = x.WeekNumber, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition!.DisplayName,
AmountBefore = x.AmountBefore, AmountBefore = x.AmountBefore,
AmountAfter = x.AmountAfter, AmountAfter = x.AmountAfter,
OldStatus = x.OldStatus, OldStatus = x.OldStatus,
@@ -54,6 +60,12 @@ public class GetCommissionPayoutHistoryQueryHandler : IRequestHandler<GetCommiss
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var model in models)
// {
// model.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(model.WeekNumber);
// }
return new GetCommissionPayoutHistoryResponseDto return new GetCommissionPayoutHistoryResponseDto
{ {
MetaData = meta, MetaData = meta,
@@ -13,11 +13,10 @@ public class GetCommissionPayoutHistoryQueryValidator : AbstractValidator<GetCom
.GreaterThan(0) .GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست") .WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue); .When(x => x.UserId.HasValue);
RuleFor(x => x.WeekDefinitionId)
RuleFor(x => x.WeekNumber) .NotNull()
.Matches(@"^\d{4}-W\d{2}$") .GreaterThan(0)
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد") .WithMessage("شماره هفته نمی‌تواند خالی باشد");
.When(x => !string.IsNullOrEmpty(x.WeekNumber));
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -11,7 +11,8 @@ public class GetCommissionPayoutHistoryResponseModel
public long Id { get; set; } public long Id { get; set; }
public long UserCommissionPayoutId { get; set; } public long UserCommissionPayoutId { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty; public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long AmountBefore { get; set; } public long AmountBefore { get; set; }
public long AmountAfter { get; set; } public long AmountAfter { get; set; }
public CommissionPayoutStatus? OldStatus { get; set; } public CommissionPayoutStatus? OldStatus { get; set; }
@@ -0,0 +1,25 @@
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; }
}
@@ -0,0 +1,73 @@
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
};
}
}
@@ -0,0 +1,25 @@
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");
});
}
}
@@ -0,0 +1,23 @@
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;
}
@@ -0,0 +1,24 @@
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; }
}
@@ -0,0 +1,47 @@
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);
}
}
@@ -18,7 +18,7 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
/// <summary> /// <summary>
/// شماره هفته (اختیاری) /// شماره هفته (اختیاری)
/// </summary> /// </summary>
public string? WeekNumber { get; init; } public long? WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// مرتب‌سازی /// مرتب‌سازی
@@ -3,22 +3,41 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserCommissionPayo
public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponseDto> public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommissionPayoutsQuery, GetUserCommissionPayoutsResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserCommissionPayoutsQueryHandler(IApplicationDbContext context) public GetUserCommissionPayoutsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
} }
public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken) public async Task<GetUserCommissionPayoutsResponseDto> Handle(GetUserCommissionPayoutsQuery request, CancellationToken cancellationToken)
{ {
var query = _context.UserCommissionPayouts var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
// فیلترها // اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
if (request.UserId.HasValue) long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{ {
query = query.Where(x => x.UserId == request.UserId.Value); if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
}
// فیلترها
if (userId.HasValue && userId.Value > 0)
{
query = query.Where(x => x.UserId == userId.Value);
} }
if (request.Status.HasValue) if (request.Status.HasValue)
@@ -26,12 +45,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
query = query.Where(x => x.Status == request.Status.Value); query = query.Where(x => x.Status == request.Status.Value);
} }
if (!string.IsNullOrEmpty(request.WeekNumber)) if (request.WeekDefinitionId!=null)
{ {
query = query.Where(x => x.WeekNumber == request.WeekNumber); query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
} }
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -41,7 +60,10 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
{ {
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
WeekNumber = x.WeekNumber, FirstName = x.User.FirstName,
LastName = x.User.LastName,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
WeeklyPoolId = x.WeeklyPoolId, WeeklyPoolId = x.WeeklyPoolId,
BalancesEarned = x.BalancesEarned, BalancesEarned = x.BalancesEarned,
ValuePerBalance = x.ValuePerBalance, ValuePerBalance = x.ValuePerBalance,
@@ -55,6 +77,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var model in models)
// {
// model.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(model.WeekNumber);
// }
return new GetUserCommissionPayoutsResponseDto return new GetUserCommissionPayoutsResponseDto
{ {
MetaData = meta, MetaData = meta,
@@ -14,10 +14,10 @@ public class GetUserCommissionPayoutsQueryValidator : AbstractValidator<GetUserC
.WithMessage("وضعیت معتبر نیست") .WithMessage("وضعیت معتبر نیست")
.When(x => x.Status.HasValue); .When(x => x.Status.HasValue);
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.Matches(@"^\d{4}-W\d{2}$") .GreaterThan(0)
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد") .WithMessage("شماره هفته نمی‌تواند صفر باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber)); .When(x => x.WeekDefinitionId.HasValue);
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -10,7 +10,10 @@ public class GetUserCommissionPayoutsResponseModel
{ {
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long WeeklyPoolId { get; set; } public long WeeklyPoolId { get; set; }
public long BalancesEarned { get; set; } public long BalancesEarned { get; set; }
public decimal ValuePerBalance { get; set; } public decimal ValuePerBalance { get; set; }
@@ -11,9 +11,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
public long? UserId { get; init; } public long? UserId { get; init; }
/// <summary> /// <summary>
/// شماره هفته (اختیاری) /// شناسه تعریف هفته (اختیاری) - روش ترجیحی
/// </summary> /// </summary>
public string? WeekNumber { get; init; } public long? WeekDefinitionId { get; init; }
/// <summary> /// <summary>
/// فقط موارد Expired نشده؟ /// فقط موارد Expired نشده؟
@@ -3,35 +3,57 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetUserWeeklyBalances
public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto> public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBalancesQuery, GetUserWeeklyBalancesResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
private readonly ICurrentUserService _currentUser;
public GetUserWeeklyBalancesQueryHandler(IApplicationDbContext context) public GetUserWeeklyBalancesQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository,
ICurrentUserService currentUser)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
_currentUser = currentUser;
} }
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken) public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
{ {
var query = _context.NetworkWeeklyBalances var query = _context.NetworkWeeklyBalances
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
// فیلترها // اگر UserId داده نشده، از CurrentUser بگیر (برای Customer API)
if (request.UserId.HasValue) long? userId = request.UserId;
if (!userId.HasValue || userId.Value == 0)
{ {
query = query.Where(x => x.UserId == request.UserId.Value); if (long.TryParse(_currentUser.UserId, out var currentUserId))
{
userId = currentUserId;
}
} }
if (!string.IsNullOrEmpty(request.WeekNumber)) // فیلترها
if (userId.HasValue && userId.Value > 0)
{ {
query = query.Where(x => x.WeekNumber == request.WeekNumber); query = query.Where(x => x.UserId == userId.Value);
} }
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
if (request.WeekDefinitionId.HasValue)
{
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
if (request.OnlyActive.HasValue && request.OnlyActive.Value) if (request.OnlyActive.HasValue && request.OnlyActive.Value)
{ {
query = query.Where(x => !x.IsExpired); query = query.Where(x => !x.IsExpired);
} }
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekNumber"); // مرتب‌سازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -41,9 +63,15 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
{ {
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
WeekNumber = x.WeekNumber, UserFullName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}".Trim() : "",
LeftLegBalances = x.LeftLegBalances, WeekDefinitionId = x.WeekDefinitionId,
RightLegBalances = x.RightLegBalances, WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
LeftLegNewMembers = x.LeftLegNewMembers,
LeftLegCarryover = x.LeftLegCarryover,
LeftLegTotal = x.LeftLegTotal,
RightLegNewMembers = x.RightLegNewMembers,
RightLegCarryover = x.RightLegCarryover,
RightLegTotal = x.RightLegTotal,
TotalBalances = x.TotalBalances, TotalBalances = x.TotalBalances,
WeeklyPoolContribution = x.WeeklyPoolContribution, WeeklyPoolContribution = x.WeeklyPoolContribution,
CalculatedAt = x.CalculatedAt, CalculatedAt = x.CalculatedAt,
@@ -9,10 +9,10 @@ public class GetUserWeeklyBalancesQueryValidator : AbstractValidator<GetUserWeek
.WithMessage("شناسه کاربر معتبر نیست") .WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue); .When(x => x.UserId.HasValue);
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.Matches(@"^\d{4}-W\d{2}$") .GreaterThan(0)
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد") .WithMessage("شماره هفته نمی‌تواند صفر باشد")
.When(x => !string.IsNullOrEmpty(x.WeekNumber)); .When(x => x.WeekDefinitionId.HasValue);
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -10,9 +10,20 @@ public class GetUserWeeklyBalancesResponseModel
{ {
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string WeekNumber { get; set; } = string.Empty; public string UserFullName { get; set; } = string.Empty;
public int LeftLegBalances { get; set; } public long WeekDefinitionId { get; set; }
public int RightLegBalances { get; set; } public string WeekDisplayName { get; set; } = string.Empty;
// چپ
public int LeftLegNewMembers { get; set; }
public int LeftLegCarryover { get; set; }
public int LeftLegTotal { get; set; }
// راست
public int RightLegNewMembers { get; set; }
public int RightLegCarryover { get; set; }
public int RightLegTotal { get; set; }
public int TotalBalances { get; set; } public int TotalBalances { get; set; }
public long WeeklyPoolContribution { get; set; } public long WeeklyPoolContribution { get; set; }
public DateTime? CalculatedAt { get; set; } public DateTime? CalculatedAt { get; set; }
@@ -0,0 +1,45 @@
using CMSMicroservice.Application.Common.Models;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// Query برای دریافت لیست هفته‌ها از کش (برای dropdown)
/// </summary>
public record GetWeekDefinitionsQuery : IRequest<GetWeekDefinitionsResponseDto>
{
//موقعیت صفحه بندی
public PaginationState? PaginationState { get; init; }
//مرتب سازی بر اساس
public string? SortBy { get; init; }
//فیلتر
public GetWeekDefinitionsFilter? Filter { get; init; }
}
/// <summary>
/// فیلترهای جستجوی هفته‌ها
/// </summary>
public class GetWeekDefinitionsFilter
{
//جستجوی متنی روی DisplayName (مثل "هفته یک")
public string? SearchText { get; set; }
//شماره ترتیب هفته
public int? WeekOrder { get; set; }
//شماره هفته میلادی (2025-W46)
public string? GregorianWeekNumber { get; set; }
//شماره هفته شمسی (1404-W35)
public string? PersianWeekNumber { get; set; }
//سال میلادی
public int? GregorianYear { get; set; }
//سال شمسی
public int? PersianYear { get; set; }
//فقط هفته‌های فعال
public bool? IsActive { get; set; }
}
@@ -0,0 +1,131 @@
using CMSMicroservice.Application.Common.Interfaces;
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// Handler برای دریافت لیست هفته‌ها از کش
/// </summary>
public class GetWeekDefinitionsQueryHandler : IRequestHandler<GetWeekDefinitionsQuery, GetWeekDefinitionsResponseDto>
{
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWeekDefinitionsQueryHandler(IWeekDefinitionRepository weekDefinitionRepository)
{
_weekDefinitionRepository = weekDefinitionRepository;
}
public Task<GetWeekDefinitionsResponseDto> Handle(
GetWeekDefinitionsQuery request,
CancellationToken cancellationToken)
{
// گرفتن همه هفته‌ها از کش
var allWeeks = _weekDefinitionRepository.GetAllWeeks();
// اعمال فیلترها
var filteredWeeks = allWeeks.AsEnumerable();
if (request.Filter != null)
{
var filter = request.Filter;
// جستجوی متنی روی DisplayName
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
filteredWeeks = filteredWeeks.Where(w =>
w.DisplayName.Contains(filter.SearchText, StringComparison.OrdinalIgnoreCase));
}
// فیلتر بر اساس WeekOrder
if (filter.WeekOrder.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.WeekOrder == filter.WeekOrder.Value);
}
// فیلتر بر اساس GregorianWeekNumber
if (!string.IsNullOrWhiteSpace(filter.GregorianWeekNumber))
{
filteredWeeks = filteredWeeks.Where(w =>
w.GregorianWeekNumber == filter.GregorianWeekNumber);
}
// فیلتر بر اساس PersianWeekNumber
if (!string.IsNullOrWhiteSpace(filter.PersianWeekNumber))
{
filteredWeeks = filteredWeeks.Where(w =>
w.PersianWeekNumber == filter.PersianWeekNumber);
}
// فیلتر بر اساس سال میلادی
if (filter.GregorianYear.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.GregorianYear == filter.GregorianYear.Value);
}
// فیلتر بر اساس سال شمسی
if (filter.PersianYear.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.PersianYear == filter.PersianYear.Value);
}
// فیلتر بر اساس فعال بودن
if (filter.IsActive.HasValue)
{
filteredWeeks = filteredWeeks.Where(w => w.IsActive == filter.IsActive.Value);
}
}
// مرتب‌سازی
filteredWeeks = request.SortBy?.ToLower() switch
{
"weekorder_desc" => filteredWeeks.OrderByDescending(w => w.WeekOrder),
"displayname" => filteredWeeks.OrderBy(w => w.DisplayName),
"displayname_desc" => filteredWeeks.OrderByDescending(w => w.DisplayName),
"startdate" => filteredWeeks.OrderBy(w => w.StartDate),
"startdate_desc" => filteredWeeks.OrderByDescending(w => w.StartDate),
_ => filteredWeeks.OrderBy(w => w.WeekOrder) // پیش‌فرض: weekorder
};
// تعداد کل قبل از صفحه‌بندی
var weeksList = filteredWeeks.ToList();
var totalCount = weeksList.Count;
// صفحه‌بندی
if (request.PaginationState != null)
{
var pageNumber = request.PaginationState.PageNumber > 0 ? request.PaginationState.PageNumber : 1;
var pageSize = request.PaginationState.PageSize > 0 ? request.PaginationState.PageSize : 20;
weeksList = weeksList
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
}
// گرفتن هفته جاری برای مشخص کردن IsCurrentWeek
var currentWeek = _weekDefinitionRepository.GetCurrentWeek();
// تبدیل به DTO
var weekDtos = weeksList.Select(w => new WeekDefinitionItemDto
{
Id = w.Id,
WeekOrder = w.WeekOrder,
DisplayName = w.DisplayName,
GregorianWeekNumber = w.GregorianWeekNumber,
PersianWeekNumber = w.PersianWeekNumber,
StartDate = w.StartDate,
EndDate = w.EndDate,
GregorianYear = w.GregorianYear,
PersianYear = w.PersianYear,
IsActive = w.IsActive,
IsCurrentWeek = currentWeek != null && w.WeekOrder == currentWeek.WeekOrder
}).ToList();
var response = new GetWeekDefinitionsResponseDto
{
Data = weekDtos,
TotalCount = totalCount
};
return Task.FromResult(response);
}
}
@@ -0,0 +1,52 @@
namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeekDefinitions;
/// <summary>
/// DTO برای پاسخ لیست هفته‌ها
/// </summary>
public class GetWeekDefinitionsResponseDto
{
//لیست هفته‌ها
public List<WeekDefinitionItemDto> Data { get; set; } = new();
//تعداد کل
public int TotalCount { get; set; }
}
/// <summary>
/// آیتم هفته برای dropdown
/// </summary>
public class WeekDefinitionItemDto
{
//شناسه هفته
public long Id { get; set; }
//شماره ترتیب هفته (1, 2, 3, ...)
public int WeekOrder { get; set; }
//نام نمایشی هفته (هفته یکم، هفته دوم، ...)
public string DisplayName { get; set; } = string.Empty;
//شماره هفته میلادی (2025-W46)
public string GregorianWeekNumber { get; set; } = string.Empty;
//شماره هفته شمسی (1404-W35)
public string PersianWeekNumber { get; set; } = string.Empty;
//تاریخ شروع هفته
public DateTime StartDate { get; set; }
//تاریخ پایان هفته
public DateTime EndDate { get; set; }
//سال میلادی
public int GregorianYear { get; set; }
//سال شمسی
public int PersianYear { get; set; }
//فعال بودن
public bool IsActive { get; set; }
//آیا هفته جاری است؟
public bool IsCurrentWeek { get; set; }
}
@@ -8,5 +8,5 @@ public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
/// <summary> /// <summary>
/// شماره هفته /// شماره هفته
/// </summary> /// </summary>
public string WeekNumber { get; init; } = string.Empty; public long WeekDefinitionId { get; init; }
} }
@@ -3,21 +3,27 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPo
public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyCommissionPoolQuery, WeeklyCommissionPoolDto?> public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyCommissionPoolQuery, WeeklyCommissionPoolDto?>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWeeklyCommissionPoolQueryHandler(IApplicationDbContext context) public GetWeeklyCommissionPoolQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken) public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{ {
var pool = await _context.WeeklyCommissionPools var pool = await _context.WeeklyCommissionPools
.Include(x => x.WeekDefinition)
.AsNoTracking() .AsNoTracking()
.Where(x => x.WeekNumber == request.WeekNumber) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.Select(x => new WeeklyCommissionPoolDto .Select(x => new WeeklyCommissionPoolDto
{ {
Id = x.Id, Id = x.Id,
WeekNumber = x.WeekNumber, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount, TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances, TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance, ValuePerBalance = x.ValuePerBalance,
@@ -27,6 +33,11 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return pool; // if (pool != null)
// {
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
// }
return pool ?? new WeeklyCommissionPoolDto();
} }
} }
@@ -4,11 +4,9 @@ public class GetWeeklyCommissionPoolQueryValidator : AbstractValidator<GetWeekly
{ {
public GetWeeklyCommissionPoolQueryValidator() public GetWeeklyCommissionPoolQueryValidator()
{ {
RuleFor(x => x.WeekNumber) RuleFor(x => x.WeekDefinitionId)
.NotEmpty() .GreaterThan(0)
.WithMessage("شماره هفته نمی‌تواند خالی باشد") .WithMessage("شماره هفته نمی‌تواند صفر باشد");
.Matches(@"^\d{4}-W\d{2}$")
.WithMessage("فرمت شماره هفته باید YYYY-Www باشد");
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) => public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
@@ -6,7 +6,8 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWeeklyCommissionPo
public class WeeklyCommissionPoolDto public class WeeklyCommissionPoolDto
{ {
public long Id { get; set; } public long Id { get; set; }
public string WeekNumber { get; set; } = string.Empty; public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long TotalPoolAmount { get; set; } public long TotalPoolAmount { get; set; }
public long TotalBalances { get; set; } public long TotalBalances { get; set; }
public decimal ValuePerBalance { get; set; } public decimal ValuePerBalance { get; set; }
@@ -19,7 +19,7 @@ public class GetWithdrawalReportsQueryHandler : IRequestHandler<GetWithdrawalRep
public async Task<WithdrawalReportsDto> Handle(GetWithdrawalReportsQuery request, CancellationToken cancellationToken) public async Task<WithdrawalReportsDto> Handle(GetWithdrawalReportsQuery request, CancellationToken cancellationToken)
{ {
// تعیین بازه زمانی پیش‌فرض (30 روز گذشته) // تعیین بازه زمانی پیش‌فرض (30 روز گذشته)
var endDate = request.EndDate ?? DateTime.UtcNow; var endDate = request.EndDate ?? DateTime.Now;
var startDate = request.StartDate ?? endDate.AddDays(-30); var startDate = request.StartDate ?? endDate.AddDays(-30);
// Query پایه // Query پایه
@@ -4,7 +4,7 @@ public class GetWithdrawalRequestsQuery : IRequest<GetWithdrawalRequestsResponse
{ {
public int? Status { get; set; } // CommissionPayoutStatus enum public int? Status { get; set; } // CommissionPayoutStatus enum
public long? UserId { get; set; } public long? UserId { get; set; }
public string? WeekNumber { get; set; } public long? WeekDefinitionId { get; init; }
public string? IbanNumber { get; set; } public string? IbanNumber { get; set; }
public PaginationState? PaginationState { get; set; } public PaginationState? PaginationState { get; set; }
public string? SortBy { get; set; } public string? SortBy { get; set; }
@@ -3,10 +3,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWithdrawalRequests
public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRequestsQuery, GetWithdrawalRequestsResponseDto> public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRequestsQuery, GetWithdrawalRequestsResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWithdrawalRequestsQueryHandler(IApplicationDbContext context) public GetWithdrawalRequestsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<GetWithdrawalRequestsResponseDto> Handle(GetWithdrawalRequestsQuery request, CancellationToken cancellationToken) public async Task<GetWithdrawalRequestsResponseDto> Handle(GetWithdrawalRequestsQuery request, CancellationToken cancellationToken)
@@ -14,6 +18,7 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
var query = _context.UserCommissionPayouts var query = _context.UserCommissionPayouts
.AsNoTracking() .AsNoTracking()
.Include(x => x.User) .Include(x => x.User)
.Include(x => x.WeekDefinition)
.Where(x => x.WithdrawalMethod != null) // Only requests with withdrawal method .Where(x => x.WithdrawalMethod != null) // Only requests with withdrawal method
.AsQueryable(); .AsQueryable();
@@ -28,9 +33,9 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
query = query.Where(x => x.UserId == request.UserId.Value); query = query.Where(x => x.UserId == request.UserId.Value);
} }
if (!string.IsNullOrEmpty(request.WeekNumber)) if (request.WeekDefinitionId!= null && request.WeekDefinitionId >0)
{ {
query = query.Where(x => x.WeekNumber == request.WeekNumber); query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
} }
if (!string.IsNullOrWhiteSpace(request.IbanNumber)) if (!string.IsNullOrWhiteSpace(request.IbanNumber))
@@ -38,7 +43,7 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
query = query.Where(x => x.IbanNumber != null && x.IbanNumber.Contains(request.IbanNumber)); query = query.Where(x => x.IbanNumber != null && x.IbanNumber.Contains(request.IbanNumber));
} }
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created"); query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -51,7 +56,8 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
UserName = x.User != null ? (x.User.FirstName + " " + x.User.LastName).Trim() : x.User?.Mobile ?? "N/A", UserName = x.User != null ? (x.User.FirstName + " " + x.User.LastName).Trim() : x.User?.Mobile ?? "N/A",
WeekNumber = x.WeekNumber, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName =x.WeekDefinition.DisplayName,
Amount = x.TotalAmount, Amount = x.TotalAmount,
Status = (int)x.Status, Status = (int)x.Status,
WithdrawalMethod = x.WithdrawalMethod.HasValue ? (int)x.WithdrawalMethod.Value : 0, WithdrawalMethod = x.WithdrawalMethod.HasValue ? (int)x.WithdrawalMethod.Value : 0,
@@ -11,7 +11,8 @@ public class WithdrawalRequestModel
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string UserName { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty; public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public long Amount { get; set; } public long Amount { get; set; }
public int Status { get; set; } // CommissionPayoutStatus enum public int Status { get; set; } // CommissionPayoutStatus enum
public int? WithdrawalMethod { get; set; } public int? WithdrawalMethod { get; set; }
@@ -4,7 +4,7 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLog
public record GetWorkerExecutionLogsQuery : IRequest<GetWorkerExecutionLogsResponseDto> public record GetWorkerExecutionLogsQuery : IRequest<GetWorkerExecutionLogsResponseDto>
{ {
public string? WeekNumber { get; init; } public long? WeekDefinitionId { get; init; }
public string? ExecutionId { get; init; } public string? ExecutionId { get; init; }
public bool? SuccessOnly { get; init; } public bool? SuccessOnly { get; init; }
public bool? FailedOnly { get; init; } public bool? FailedOnly { get; init; }
@@ -6,10 +6,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerExecutionLog
public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecutionLogsQuery, GetWorkerExecutionLogsResponseDto> public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecutionLogsQuery, GetWorkerExecutionLogsResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWorkerExecutionLogsQueryHandler(IApplicationDbContext context) public GetWorkerExecutionLogsQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<GetWorkerExecutionLogsResponseDto> Handle( public async Task<GetWorkerExecutionLogsResponseDto> Handle(
@@ -17,12 +21,14 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Query from database // Query from database
var query = _context.WorkerExecutionLogs.AsQueryable(); var query = _context.WorkerExecutionLogs
.Include(i=> i.WeekDefinition)
.AsQueryable();
// Apply filters // Apply filters
if (!string.IsNullOrEmpty(request.WeekNumber)) if (request.WeekDefinitionId!=null && request.WeekDefinitionId > 0)
{ {
query = query.Where(x => x.WeekNumber == request.WeekNumber); query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
} }
if (request.SuccessOnly == true) if (request.SuccessOnly == true)
@@ -49,7 +55,8 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
.Select(x => new WorkerExecutionLogModel .Select(x => new WorkerExecutionLogModel
{ {
ExecutionId = x.ExecutionId.ToString(), ExecutionId = x.ExecutionId.ToString(),
WeekNumber = x.WeekNumber, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition.DisplayName,
Step = "Full", // We only have full execution now Step = "Full", // We only have full execution now
Success = x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success || Success = x.Status == Domain.Entities.Commission.WorkerExecutionStatus.Success ||
x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings, x.Status == Domain.Entities.Commission.WorkerExecutionStatus.SuccessWithWarnings,
@@ -62,6 +69,12 @@ public class GetWorkerExecutionLogsQueryHandler : IRequestHandler<GetWorkerExecu
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// // Populate WeekDisplayName from cache
// foreach (var log in logs)
// {
// log.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(log.WeekNumber);
// }
return new GetWorkerExecutionLogsResponseDto return new GetWorkerExecutionLogsResponseDto
{ {
MetaData = new MetaData MetaData = new MetaData
@@ -11,7 +11,8 @@ public class GetWorkerExecutionLogsResponseDto
public class WorkerExecutionLogModel public class WorkerExecutionLogModel
{ {
public string ExecutionId { get; set; } = string.Empty; public string ExecutionId { get; set; } = string.Empty;
public string WeekNumber { get; set; } = string.Empty; public long WeekDefinitionId { get; set; }
public string WeekDisplayName { get; set; } = string.Empty;
public string Step { get; set; } = string.Empty; // "Balances" | "Pool" | "Payouts" | "Full" public string Step { get; set; } = string.Empty; // "Balances" | "Pool" | "Payouts" | "Full"
public bool Success { get; set; } public bool Success { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
@@ -5,10 +5,14 @@ namespace CMSMicroservice.Application.CommissionCQ.Queries.GetWorkerStatus;
public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery, GetWorkerStatusResponseDto> public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery, GetWorkerStatusResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IWeekDefinitionRepository _weekDefinitionRepository;
public GetWorkerStatusQueryHandler(IApplicationDbContext context) public GetWorkerStatusQueryHandler(
IApplicationDbContext context,
IWeekDefinitionRepository weekDefinitionRepository)
{ {
_context = context; _context = context;
_weekDefinitionRepository = weekDefinitionRepository;
} }
public async Task<GetWorkerStatusResponseDto> Handle( public async Task<GetWorkerStatusResponseDto> Handle(
@@ -20,15 +24,21 @@ public class GetWorkerStatusQueryHandler : IRequestHandler<GetWorkerStatusQuery,
await Task.CompletedTask; await Task.CompletedTask;
var currentWeek = _weekDefinitionRepository.GetCurrentWeek();
var currentWeekNumber = currentWeek != null
? $"{currentWeek.GregorianYear}-W{currentWeek.GregorianWeekNumber:D2}"
: null;
return new GetWorkerStatusResponseDto return new GetWorkerStatusResponseDto
{ {
IsRunning = false, IsRunning = false,
IsEnabled = true, IsEnabled = true,
CurrentExecutionId = null, CurrentExecutionId = null,
CurrentWeekNumber = null, CurrentWeekNumber = currentWeekNumber,
CurrentWeekDisplayName = currentWeek?.DisplayName,
CurrentStep = "Idle", CurrentStep = "Idle",
LastRunAt = DateTime.UtcNow.AddHours(-24), LastRunAt = DateTime.Now.AddHours(-24),
NextScheduledRun = DateTime.UtcNow.AddDays(7), NextScheduledRun = DateTime.Now.AddDays(7),
TotalExecutions = 48, TotalExecutions = 48,
SuccessfulExecutions = 47, SuccessfulExecutions = 47,
FailedExecutions = 1 FailedExecutions = 1
@@ -6,6 +6,7 @@ public class GetWorkerStatusResponseDto
public bool IsEnabled { get; set; } public bool IsEnabled { get; set; }
public string? CurrentExecutionId { get; set; } public string? CurrentExecutionId { get; set; }
public string? CurrentWeekNumber { get; set; } public string? CurrentWeekNumber { get; set; }
public string? CurrentWeekDisplayName { get; set; }
public string? CurrentStep { get; set; } // "Balances" | "Pool" | "Payouts" | "Idle" public string? CurrentStep { get; set; } // "Balances" | "Pool" | "Payouts" | "Idle"
public DateTime? LastRunAt { get; set; } public DateTime? LastRunAt { get; set; }
public DateTime? NextScheduledRun { get; set; } public DateTime? NextScheduledRun { get; set; }
@@ -0,0 +1,17 @@
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);
}
@@ -0,0 +1,127 @@
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);
}
}
@@ -0,0 +1,15 @@
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; }
}
@@ -1,6 +1,8 @@
using CMSMicroservice.Domain.Entities.Payment; using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order; using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace CMSMicroservice.Application.Common.Interfaces; namespace CMSMicroservice.Application.Common.Interfaces;
@@ -29,8 +31,6 @@ public interface IApplicationDbContext
DbSet<UserPackagePurchase> UserPackagePurchases { get; } DbSet<UserPackagePurchase> UserPackagePurchases { get; }
DbSet<UserWallet> UserWallets { get; } DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; } DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<SystemConfiguration> SystemConfigurations { get; }
DbSet<SystemConfigurationHistory> SystemConfigurationHistories { get; }
DbSet<ManualPayment> ManualPayments { get; } DbSet<ManualPayment> ManualPayments { get; }
DbSet<PublicMessage> PublicMessages { get; } DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; } DbSet<ClubMembership> ClubMemberships { get; }
@@ -39,19 +39,37 @@ public interface IApplicationDbContext
DbSet<UserClubFeature> UserClubFeatures { get; } DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; } DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
DbSet<NetworkMembershipHistory> NetworkMembershipHistories { get; } DbSet<NetworkMembershipHistory> NetworkMembershipHistories { get; }
DbSet<WeekDefinition> WeekDefinitions { get; }
DbSet<WeeklyCommissionPool> WeeklyCommissionPools { get; } DbSet<WeeklyCommissionPool> WeeklyCommissionPools { get; }
DbSet<UserCommissionPayout> UserCommissionPayouts { get; } DbSet<UserCommissionPayout> UserCommissionPayouts { get; }
DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; } DbSet<CommissionPayoutHistory> CommissionPayoutHistories { get; }
DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; } DbSet<WorkerExecutionLog> WorkerExecutionLogs { get; }
DbSet<DayaLoanContract> DayaLoanContracts { get; } DbSet<DayaLoanContract> DayaLoanContracts { get; }
DbSet<AppVersion> AppVersions { get; }
// ============= Discount Shop ============= // ============= Discount Shop =============
DbSet<DiscountProduct> DiscountProducts { get; } DbSet<DiscountProduct> DiscountProducts { get; }
DbSet<DiscountCategory> DiscountCategories { get; } DbSet<DiscountCategory> DiscountCategories { get; }
DbSet<DiscountProductCategory> DiscountProductCategories { get; } DbSet<DiscountProductCategory> DiscountProductCategories { get; }
DbSet<DiscountProductImage> DiscountProductImages { get; }
DbSet<DiscountShoppingCart> DiscountShoppingCarts { get; } DbSet<DiscountShoppingCart> DiscountShoppingCarts { get; }
DbSet<DiscountOrder> DiscountOrders { get; } DbSet<DiscountOrder> DiscountOrders { get; }
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; } DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
// ============= Geography =============
DbSet<Country> Countries { get; }
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>
DatabaseFacade Database { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,64 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// سرویس ارتباط با API چتیکا برای ساخت حساب کاربری
/// </summary>
public interface IChatikaApiService
{
/// <summary>
/// ایجاد حساب کاربری در چتیکا
/// </summary>
/// <param name="mobileNumber">شماره موبایل کاربر</param>
/// <param name="fullName">نام کامل کاربر</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>نتیجه ایجاد حساب</returns>
Task<ChatikaAccountResult> CreateAccountAsync(
string mobileNumber,
string fullName,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نتیجه ایجاد حساب در چتیکا
/// </summary>
public class ChatikaAccountResult
{
/// <summary>
/// موفقیت عملیات
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// پیام خطا در صورت عدم موفقیت
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// شناسه کاربر در چتیکا
/// </summary>
public string? ChatikaUserId { get; set; }
/// <summary>
/// URL دسترسی به چتیکا
/// </summary>
public string? AccessUrl { get; set; }
/// <summary>
/// ایجاد نتیجه موفق
/// </summary>
public static ChatikaAccountResult Success(string? chatikaUserId = null, string? accessUrl = null) => new()
{
IsSuccess = true,
ChatikaUserId = chatikaUserId,
AccessUrl = accessUrl
};
/// <summary>
/// ایجاد نتیجه ناموفق
/// </summary>
public static ChatikaAccountResult Failure(string errorMessage) => new()
{
IsSuccess = false,
ErrorMessage = errorMessage
};
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// استراتژی محاسبه کمیسیون هفتگی
/// دو پیاده‌سازی دارد: ORM و Stored Procedure
/// </summary>
public interface ICommissionCalculationStrategy
{
/// <summary>
/// محاسبه تعادل‌های هفتگی (مرحله 1)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>تعداد رکوردهای محاسبه شده</returns>
Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
/// <summary>
/// محاسبه استخر کمیسیون و پرداخت‌ها (مرحله 2)
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>شناسه Pool ایجاد شده</returns>
Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
CancellationToken cancellationToken = default);
}
/// <summary>
/// نوع استراتژی محاسبه کمیسیون
/// </summary>
public enum CommissionCalculationStrategyType
{
/// <summary>
/// استفاده از Entity Framework (ORM) - پیش‌فرض
/// </summary>
Orm = 0,
/// <summary>
/// استفاده از Stored Procedure - بهینه‌تر برای داده‌های زیاد
/// </summary>
StoredProcedure = 1
}
@@ -0,0 +1,20 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Factory برای ایجاد Strategy مناسب براساس Config
/// </summary>
public interface ICommissionCalculationStrategyFactory
{
/// <summary>
/// ایجاد Strategy براساس تنظیمات سیستم
/// </summary>
/// <returns>استراتژی مناسب (ORM یا SP)</returns>
Task<ICommissionCalculationStrategy> CreateStrategyAsync(CancellationToken cancellationToken = default);
/// <summary>
/// ایجاد Strategy مشخص
/// </summary>
/// <param name="strategyType">نوع استراتژی</param>
/// <returns>استراتژی درخواستی</returns>
ICommissionCalculationStrategy CreateStrategy(CommissionCalculationStrategyType strategyType);
}
@@ -0,0 +1,37 @@
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);
}

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