From 1b792f02ae25eb546f4c71dc133a440fd23fd64c Mon Sep 17 00:00:00 2001 From: konturai-ops Date: Mon, 10 Aug 2026 15:26:55 +0000 Subject: [PATCH] sync: migrate erp-mvp to Gitea (2026-08-10) --- ._. | Bin 0 -> 163 bytes ._.env.example | Bin 0 -> 163 bytes ._.gitignore | Bin 0 -> 163 bytes ._README.md | Bin 0 -> 163 bytes ._backend | Bin 0 -> 163 bytes ._docker-compose.server.yml | Bin 0 -> 163 bytes ._docker-compose.yml | Bin 0 -> 163 bytes ._frontend | Bin 0 -> 163 bytes .env.example | 7 + .gitignore | 17 + README.md | 923 +++++++ backend/._.dockerignore | Bin 0 -> 163 bytes backend/._Dockerfile | Bin 0 -> 163 bytes backend/._build.gradle | Bin 0 -> 163 bytes backend/._settings.gradle | Bin 0 -> 163 bytes backend/._src | Bin 0 -> 163 bytes backend/.dockerignore | 5 + backend/Dockerfile | 18 + backend/build.gradle | 46 + backend/settings.gradle | 16 + backend/src/._main | Bin 0 -> 163 bytes backend/src/main/._java | Bin 0 -> 163 bytes backend/src/main/._resources | Bin 0 -> 163 bytes backend/src/main/java/._com | Bin 0 -> 163 bytes backend/src/main/java/com/._example | Bin 0 -> 163 bytes backend/src/main/java/com/example/._erpmvp | Bin 0 -> 163 bytes .../example/erpmvp/._ErpMvpApplication.java | Bin 0 -> 163 bytes .../src/main/java/com/example/erpmvp/._common | Bin 0 -> 163 bytes .../src/main/java/com/example/erpmvp/._config | Bin 0 -> 163 bytes .../src/main/java/com/example/erpmvp/._health | Bin 0 -> 163 bytes .../main/java/com/example/erpmvp/._modules | Bin 0 -> 163 bytes .../com/example/erpmvp/ErpMvpApplication.java | 14 + .../main/java/com/example/erpmvp/common/._api | Bin 0 -> 163 bytes .../java/com/example/erpmvp/common/._audit | Bin 0 -> 163 bytes .../java/com/example/erpmvp/common/._error | Bin 0 -> 163 bytes .../com/example/erpmvp/common/._pagination | Bin 0 -> 163 bytes .../example/erpmvp/common/api/._ApiError.java | Bin 0 -> 163 bytes .../erpmvp/common/api/._ApiResponse.java | Bin 0 -> 163 bytes .../common/api/._FieldValidationError.java | Bin 0 -> 163 bytes .../example/erpmvp/common/api/ApiError.java | 19 + .../erpmvp/common/api/ApiResponse.java | 20 + .../common/api/FieldValidationError.java | 7 + .../erpmvp/common/audit/._BaseEntity.java | Bin 0 -> 163 bytes .../erpmvp/common/audit/BaseEntity.java | 57 + .../common/error/._BadRequestException.java | Bin 0 -> 163 bytes .../common/error/._BusinessException.java | Bin 0 -> 163 bytes .../common/error/._ConflictException.java | Bin 0 -> 163 bytes .../common/error/._DebugErrorController.java | Bin 0 -> 163 bytes .../error/._GlobalExceptionHandler.java | Bin 0 -> 163 bytes .../common/error/._NotFoundException.java | Bin 0 -> 163 bytes .../common/error/._UnauthorizedException.java | Bin 0 -> 163 bytes .../common/error/BadRequestException.java | 13 + .../common/error/BusinessException.java | 16 + .../common/error/ConflictException.java | 13 + .../common/error/DebugErrorController.java | 26 + .../common/error/GlobalExceptionHandler.java | 119 + .../common/error/NotFoundException.java | 13 + .../common/error/UnauthorizedException.java | 13 + .../common/pagination/._PageRequestDto.java | Bin 0 -> 163 bytes .../common/pagination/._PageResponseDto.java | Bin 0 -> 163 bytes .../common/pagination/PageRequestDto.java | 38 + .../common/pagination/PageResponseDto.java | 29 + .../example/erpmvp/config/._CorsConfig.java | Bin 0 -> 163 bytes .../erpmvp/config/._OpenApiConfig.java | Bin 0 -> 163 bytes .../java/com/example/erpmvp/config/._demo | Bin 0 -> 163 bytes .../com/example/erpmvp/config/CorsConfig.java | 34 + .../example/erpmvp/config/OpenApiConfig.java | 29 + .../config/demo/._DemoDataProperties.java | Bin 0 -> 163 bytes .../erpmvp/config/demo/._DemoDataSeeder.java | Bin 0 -> 163 bytes .../config/demo/DemoDataProperties.java | 19 + .../erpmvp/config/demo/DemoDataSeeder.java | 644 +++++ .../erpmvp/health/._HealthController.java | Bin 0 -> 163 bytes .../erpmvp/health/HealthController.java | 40 + .../java/com/example/erpmvp/modules/._auth | Bin 0 -> 163 bytes .../java/com/example/erpmvp/modules/._catalog | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/._dashboard | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/._documents | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/._procurement | Bin 0 -> 163 bytes .../java/com/example/erpmvp/modules/._sales | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/._warehouse | Bin 0 -> 163 bytes .../example/erpmvp/modules/auth/._controller | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/auth/._domain | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/auth/._dto | Bin 0 -> 163 bytes .../example/erpmvp/modules/auth/._repository | Bin 0 -> 163 bytes .../example/erpmvp/modules/auth/._security | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/auth/._service | Bin 0 -> 163 bytes .../auth/controller/._AuthController.java | Bin 0 -> 163 bytes .../auth/controller/AuthController.java | 54 + .../erpmvp/modules/auth/domain/._Role.java | Bin 0 -> 163 bytes .../erpmvp/modules/auth/domain/._User.java | Bin 0 -> 163 bytes .../erpmvp/modules/auth/domain/Role.java | 9 + .../erpmvp/modules/auth/domain/User.java | 68 + .../modules/auth/dto/._AuthUserResponse.java | Bin 0 -> 163 bytes .../modules/auth/dto/._LoginRequest.java | Bin 0 -> 163 bytes .../modules/auth/dto/._LoginResponse.java | Bin 0 -> 163 bytes .../modules/auth/dto/._LogoutResponse.java | Bin 0 -> 163 bytes .../erpmvp/modules/auth/dto/._MeResponse.java | Bin 0 -> 163 bytes .../auth/dto/._ProtectedTestResponse.java | Bin 0 -> 163 bytes .../modules/auth/dto/AuthUserResponse.java | 19 + .../erpmvp/modules/auth/dto/LoginRequest.java | 11 + .../modules/auth/dto/LoginResponse.java | 10 + .../modules/auth/dto/LogoutResponse.java | 5 + .../erpmvp/modules/auth/dto/MeResponse.java | 26 + .../auth/dto/ProtectedTestResponse.java | 11 + .../auth/repository/._UserRepository.java | Bin 0 -> 163 bytes .../auth/repository/UserRepository.java | 14 + .../auth/security/._AuthUserDetails.java | Bin 0 -> 163 bytes .../security/._AuthUserDetailsService.java | Bin 0 -> 163 bytes .../security/._JwtAccessDeniedHandler.java | Bin 0 -> 163 bytes .../._JwtAuthenticationEntryPoint.java | Bin 0 -> 163 bytes .../security/._JwtAuthenticationFilter.java | Bin 0 -> 163 bytes .../auth/security/._SecurityConfig.java | Bin 0 -> 163 bytes .../auth/security/AuthUserDetails.java | 44 + .../auth/security/AuthUserDetailsService.java | 29 + .../auth/security/JwtAccessDeniedHandler.java | 39 + .../security/JwtAuthenticationEntryPoint.java | 39 + .../security/JwtAuthenticationFilter.java | 67 + .../modules/auth/security/SecurityConfig.java | 56 + .../auth/service/._AuthDataInitializer.java | Bin 0 -> 163 bytes .../modules/auth/service/._AuthService.java | Bin 0 -> 163 bytes .../modules/auth/service/._JwtService.java | Bin 0 -> 163 bytes .../auth/service/AuthDataInitializer.java | 55 + .../modules/auth/service/AuthService.java | 47 + .../modules/auth/service/JwtService.java | 87 + .../erpmvp/modules/catalog/._controller | Bin 0 -> 163 bytes .../example/erpmvp/modules/catalog/._domain | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/catalog/._dto | Bin 0 -> 163 bytes .../erpmvp/modules/catalog/._repository | Bin 0 -> 163 bytes .../example/erpmvp/modules/catalog/._service | Bin 0 -> 163 bytes .../controller/._CustomerController.java | Bin 0 -> 163 bytes .../controller/._ProductController.java | Bin 0 -> 163 bytes .../controller/._SupplierController.java | Bin 0 -> 163 bytes .../controller/._WarehouseController.java | Bin 0 -> 163 bytes .../controller/CustomerController.java | 80 + .../catalog/controller/ProductController.java | 80 + .../controller/SupplierController.java | 80 + .../controller/WarehouseController.java | 80 + .../modules/catalog/domain/._Customer.java | Bin 0 -> 163 bytes .../modules/catalog/domain/._Product.java | Bin 0 -> 163 bytes .../modules/catalog/domain/._Supplier.java | Bin 0 -> 163 bytes .../modules/catalog/domain/._Warehouse.java | Bin 0 -> 163 bytes .../modules/catalog/domain/Customer.java | 101 + .../modules/catalog/domain/Product.java | 98 + .../modules/catalog/domain/Supplier.java | 101 + .../modules/catalog/domain/Warehouse.java | 71 + .../catalog/dto/._CreateCustomerRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._CreateProductRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._CreateSupplierRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._CreateWarehouseRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._CustomerResponse.java | Bin 0 -> 163 bytes .../catalog/dto/._ProductResponse.java | Bin 0 -> 163 bytes .../catalog/dto/._SupplierResponse.java | Bin 0 -> 163 bytes .../catalog/dto/._UpdateCustomerRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._UpdateProductRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._UpdateSupplierRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._UpdateWarehouseRequest.java | Bin 0 -> 163 bytes .../catalog/dto/._WarehouseResponse.java | Bin 0 -> 163 bytes .../catalog/dto/CreateCustomerRequest.java | 16 + .../catalog/dto/CreateProductRequest.java | 15 + .../catalog/dto/CreateSupplierRequest.java | 16 + .../catalog/dto/CreateWarehouseRequest.java | 12 + .../modules/catalog/dto/CustomerResponse.java | 36 + .../modules/catalog/dto/ProductResponse.java | 36 + .../modules/catalog/dto/SupplierResponse.java | 36 + .../catalog/dto/UpdateCustomerRequest.java | 17 + .../catalog/dto/UpdateProductRequest.java | 15 + .../catalog/dto/UpdateSupplierRequest.java | 17 + .../catalog/dto/UpdateWarehouseRequest.java | 12 + .../catalog/dto/WarehouseResponse.java | 30 + .../repository/._CustomerRepository.java | Bin 0 -> 163 bytes .../repository/._ProductRepository.java | Bin 0 -> 163 bytes .../repository/._SupplierRepository.java | Bin 0 -> 163 bytes .../repository/._WarehouseRepository.java | Bin 0 -> 163 bytes .../repository/CustomerRepository.java | 35 + .../catalog/repository/ProductRepository.java | 35 + .../repository/SupplierRepository.java | 35 + .../repository/WarehouseRepository.java | 35 + .../catalog/service/._CustomerService.java | Bin 0 -> 163 bytes .../catalog/service/._ProductService.java | Bin 0 -> 163 bytes .../catalog/service/._SupplierService.java | Bin 0 -> 163 bytes .../catalog/service/._WarehouseService.java | Bin 0 -> 163 bytes .../catalog/service/CustomerService.java | 83 + .../catalog/service/ProductService.java | 89 + .../catalog/service/SupplierService.java | 83 + .../catalog/service/WarehouseService.java | 75 + .../erpmvp/modules/dashboard/._controller | Bin 0 -> 163 bytes .../example/erpmvp/modules/dashboard/._dto | Bin 0 -> 163 bytes .../erpmvp/modules/dashboard/._service | Bin 0 -> 163 bytes .../controller/._DashboardController.java | Bin 0 -> 163 bytes .../controller/DashboardController.java | 95 + .../dashboard/dto/._DailyAmountMetric.java | Bin 0 -> 163 bytes .../dto/._DashboardProcurementResponse.java | Bin 0 -> 163 bytes .../dto/._DashboardSalesResponse.java | Bin 0 -> 163 bytes .../dto/._DashboardSummaryResponse.java | Bin 0 -> 163 bytes .../dto/._DashboardWarehouseResponse.java | Bin 0 -> 163 bytes .../dashboard/dto/._LowStockItemResponse.java | Bin 0 -> 163 bytes .../dashboard/dto/._MovementTypeMetric.java | Bin 0 -> 163 bytes .../dto/._RecentActivityResponse.java | Bin 0 -> 163 bytes .../dashboard/dto/._StatusAmountMetric.java | Bin 0 -> 163 bytes .../dashboard/dto/._TopPartyMetric.java | Bin 0 -> 163 bytes .../dashboard/dto/._TypeCountMetric.java | Bin 0 -> 163 bytes .../dashboard/dto/._WarehouseStockMetric.java | Bin 0 -> 163 bytes .../dashboard/dto/DailyAmountMetric.java | 11 + .../dto/DashboardProcurementResponse.java | 14 + .../dashboard/dto/DashboardSalesResponse.java | 14 + .../dto/DashboardSummaryResponse.java | 31 + .../dto/DashboardWarehouseResponse.java | 18 + .../dashboard/dto/LowStockItemResponse.java | 17 + .../dashboard/dto/MovementTypeMetric.java | 10 + .../dashboard/dto/RecentActivityResponse.java | 14 + .../dashboard/dto/StatusAmountMetric.java | 10 + .../modules/dashboard/dto/TopPartyMetric.java | 12 + .../dashboard/dto/TypeCountMetric.java | 7 + .../dashboard/dto/WarehouseStockMetric.java | 13 + .../dashboard/service/._DashboardService.java | Bin 0 -> 163 bytes .../dashboard/service/DashboardService.java | 557 ++++ .../erpmvp/modules/documents/._controller | Bin 0 -> 163 bytes .../example/erpmvp/modules/documents/._domain | Bin 0 -> 163 bytes .../example/erpmvp/modules/documents/._dto | Bin 0 -> 163 bytes .../erpmvp/modules/documents/._repository | Bin 0 -> 163 bytes .../erpmvp/modules/documents/._service | Bin 0 -> 163 bytes .../controller/._DocumentController.java | Bin 0 -> 163 bytes .../controller/DocumentController.java | 117 + .../modules/documents/domain/._Document.java | Bin 0 -> 163 bytes .../domain/._DocumentSourceType.java | Bin 0 -> 163 bytes .../documents/domain/._DocumentStatus.java | Bin 0 -> 163 bytes .../documents/domain/._DocumentType.java | Bin 0 -> 163 bytes .../modules/documents/domain/Document.java | 140 + .../documents/domain/DocumentSourceType.java | 5 + .../documents/domain/DocumentStatus.java | 5 + .../documents/domain/DocumentType.java | 7 + .../documents/dto/._DocumentDownload.java | Bin 0 -> 163 bytes .../documents/dto/._DocumentResponse.java | Bin 0 -> 163 bytes ..._GenerateCustomerOrderDocumentRequest.java | Bin 0 -> 163 bytes .../documents/dto/DocumentDownload.java | 8 + .../documents/dto/DocumentResponse.java | 44 + .../GenerateCustomerOrderDocumentRequest.java | 9 + .../repository/._DocumentRepository.java | Bin 0 -> 163 bytes .../repository/DocumentRepository.java | 28 + .../service/._CustomerOrderPdfGenerator.java | Bin 0 -> 163 bytes .../documents/service/._DocumentService.java | Bin 0 -> 163 bytes .../service/CustomerOrderPdfGenerator.java | 166 ++ .../documents/service/DocumentService.java | 242 ++ .../erpmvp/modules/procurement/._controller | Bin 0 -> 163 bytes .../erpmvp/modules/procurement/._domain | Bin 0 -> 163 bytes .../example/erpmvp/modules/procurement/._dto | Bin 0 -> 163 bytes .../erpmvp/modules/procurement/._repository | Bin 0 -> 163 bytes .../erpmvp/modules/procurement/._service | Bin 0 -> 163 bytes .../controller/._PurchaseOrderController.java | Bin 0 -> 163 bytes .../controller/PurchaseOrderController.java | 121 + .../procurement/domain/._PurchaseOrder.java | Bin 0 -> 163 bytes .../domain/._PurchaseOrderItem.java | Bin 0 -> 163 bytes .../domain/._PurchaseOrderStatus.java | Bin 0 -> 163 bytes .../domain/._PurchaseOrderStatusHistory.java | Bin 0 -> 163 bytes .../procurement/domain/PurchaseOrder.java | 185 ++ .../procurement/domain/PurchaseOrderItem.java | 70 + .../domain/PurchaseOrderStatus.java | 10 + .../domain/PurchaseOrderStatusHistory.java | 70 + .../._ChangePurchaseOrderStatusRequest.java | Bin 0 -> 163 bytes .../dto/._CreatePurchaseOrderItemRequest.java | Bin 0 -> 163 bytes .../dto/._CreatePurchaseOrderRequest.java | Bin 0 -> 163 bytes .../dto/._PurchaseOrderItemResponse.java | Bin 0 -> 163 bytes .../dto/._PurchaseOrderProductResponse.java | Bin 0 -> 163 bytes .../dto/._PurchaseOrderResponse.java | Bin 0 -> 163 bytes .../._PurchaseOrderStatusHistoryResponse.java | Bin 0 -> 163 bytes .../dto/._PurchaseOrderSupplierResponse.java | Bin 0 -> 163 bytes .../dto/._PurchaseOrderWarehouseResponse.java | Bin 0 -> 163 bytes .../dto/._UpdatePurchaseOrderRequest.java | Bin 0 -> 163 bytes .../dto/ChangePurchaseOrderStatusRequest.java | 11 + .../dto/CreatePurchaseOrderItemRequest.java | 16 + .../dto/CreatePurchaseOrderRequest.java | 18 + .../dto/PurchaseOrderItemResponse.java | 26 + .../dto/PurchaseOrderProductResponse.java | 23 + .../dto/PurchaseOrderResponse.java | 51 + .../PurchaseOrderStatusHistoryResponse.java | 29 + .../dto/PurchaseOrderSupplierResponse.java | 21 + .../dto/PurchaseOrderWarehouseResponse.java | 24 + .../dto/UpdatePurchaseOrderRequest.java | 18 + .../._PurchaseOrderItemRepository.java | Bin 0 -> 163 bytes .../repository/._PurchaseOrderRepository.java | Bin 0 -> 163 bytes ..._PurchaseOrderStatusHistoryRepository.java | Bin 0 -> 163 bytes .../PurchaseOrderItemRepository.java | 11 + .../repository/PurchaseOrderRepository.java | 42 + .../PurchaseOrderStatusHistoryRepository.java | 13 + .../service/._PurchaseOrderService.java | Bin 0 -> 163 bytes .../service/PurchaseOrderService.java | 311 +++ .../example/erpmvp/modules/sales/._controller | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/sales/._domain | Bin 0 -> 163 bytes .../com/example/erpmvp/modules/sales/._dto | Bin 0 -> 163 bytes .../example/erpmvp/modules/sales/._repository | Bin 0 -> 163 bytes .../example/erpmvp/modules/sales/._service | Bin 0 -> 163 bytes .../controller/._CustomerOrderController.java | Bin 0 -> 163 bytes .../controller/CustomerOrderController.java | 122 + .../modules/sales/domain/._CustomerOrder.java | Bin 0 -> 163 bytes .../sales/domain/._CustomerOrderItem.java | Bin 0 -> 163 bytes .../sales/domain/._CustomerOrderStatus.java | Bin 0 -> 163 bytes .../domain/._CustomerOrderStatusHistory.java | Bin 0 -> 163 bytes .../modules/sales/domain/CustomerOrder.java | 203 ++ .../sales/domain/CustomerOrderItem.java | 70 + .../sales/domain/CustomerOrderStatus.java | 11 + .../domain/CustomerOrderStatusHistory.java | 70 + .../._ChangeCustomerOrderStatusRequest.java | Bin 0 -> 163 bytes .../dto/._CreateCustomerOrderItemRequest.java | Bin 0 -> 163 bytes .../dto/._CreateCustomerOrderRequest.java | Bin 0 -> 163 bytes .../dto/._CustomerOrderCustomerResponse.java | Bin 0 -> 163 bytes .../dto/._CustomerOrderItemResponse.java | Bin 0 -> 163 bytes .../dto/._CustomerOrderProductResponse.java | Bin 0 -> 163 bytes .../sales/dto/._CustomerOrderResponse.java | Bin 0 -> 163 bytes .../._CustomerOrderStatusHistoryResponse.java | Bin 0 -> 163 bytes .../dto/._CustomerOrderWarehouseResponse.java | Bin 0 -> 163 bytes .../dto/._UpdateCustomerOrderRequest.java | Bin 0 -> 163 bytes .../dto/ChangeCustomerOrderStatusRequest.java | 11 + .../dto/CreateCustomerOrderItemRequest.java | 16 + .../sales/dto/CreateCustomerOrderRequest.java | 19 + .../dto/CustomerOrderCustomerResponse.java | 21 + .../sales/dto/CustomerOrderItemResponse.java | 26 + .../dto/CustomerOrderProductResponse.java | 23 + .../sales/dto/CustomerOrderResponse.java | 56 + .../CustomerOrderStatusHistoryResponse.java | 29 + .../dto/CustomerOrderWarehouseResponse.java | 25 + .../sales/dto/UpdateCustomerOrderRequest.java | 19 + .../._CustomerOrderItemRepository.java | Bin 0 -> 163 bytes .../repository/._CustomerOrderRepository.java | Bin 0 -> 163 bytes ..._CustomerOrderStatusHistoryRepository.java | Bin 0 -> 163 bytes .../CustomerOrderItemRepository.java | 11 + .../repository/CustomerOrderRepository.java | 44 + .../CustomerOrderStatusHistoryRepository.java | 14 + .../sales/service/._CustomerOrderService.java | Bin 0 -> 163 bytes .../sales/service/CustomerOrderService.java | 326 +++ .../erpmvp/modules/warehouse/._controller | Bin 0 -> 163 bytes .../example/erpmvp/modules/warehouse/._domain | Bin 0 -> 163 bytes .../example/erpmvp/modules/warehouse/._dto | Bin 0 -> 163 bytes .../erpmvp/modules/warehouse/._repository | Bin 0 -> 163 bytes .../erpmvp/modules/warehouse/._service | Bin 0 -> 163 bytes .../._WarehouseStockController.java | Bin 0 -> 163 bytes .../controller/WarehouseStockController.java | 93 + .../warehouse/domain/._StockBalance.java | Bin 0 -> 163 bytes .../warehouse/domain/._StockMovement.java | Bin 0 -> 163 bytes .../domain/._StockMovementSourceType.java | Bin 0 -> 163 bytes .../warehouse/domain/._StockMovementType.java | Bin 0 -> 163 bytes .../warehouse/domain/StockBalance.java | 77 + .../warehouse/domain/StockMovement.java | 135 + .../domain/StockMovementSourceType.java | 7 + .../warehouse/domain/StockMovementType.java | 8 + .../dto/._ManualStockAdjustmentRequest.java | Bin 0 -> 163 bytes .../warehouse/dto/._StockBalanceResponse.java | Bin 0 -> 163 bytes .../dto/._StockMovementResponse.java | Bin 0 -> 163 bytes .../warehouse/dto/._StockProductResponse.java | Bin 0 -> 163 bytes .../dto/._StockWarehouseResponse.java | Bin 0 -> 163 bytes .../dto/ManualStockAdjustmentRequest.java | 18 + .../warehouse/dto/StockBalanceResponse.java | 28 + .../warehouse/dto/StockMovementResponse.java | 44 + .../warehouse/dto/StockProductResponse.java | 22 + .../warehouse/dto/StockWarehouseResponse.java | 20 + .../repository/._StockBalanceRepository.java | Bin 0 -> 163 bytes .../repository/._StockMovementRepository.java | Bin 0 -> 163 bytes .../repository/StockBalanceRepository.java | 56 + .../repository/StockMovementRepository.java | 51 + .../service/._WarehouseStockService.java | Bin 0 -> 163 bytes .../service/WarehouseStockService.java | 326 +++ backend/src/main/resources/._application.yml | Bin 0 -> 163 bytes backend/src/main/resources/._db | Bin 0 -> 163 bytes backend/src/main/resources/application.yml | 36 + backend/src/main/resources/db/._migration | Bin 0 -> 163 bytes .../resources/db/migration/._V1__init.sql | Bin 0 -> 163 bytes .../db/migration/._V2__system_init.sql | Bin 0 -> 163 bytes .../db/migration/._V3__auth_init.sql | Bin 0 -> 163 bytes .../db/migration/._V4__catalog_init.sql | Bin 0 -> 163 bytes .../db/migration/._V5__procurement_init.sql | Bin 0 -> 163 bytes .../db/migration/._V6__sales_init.sql | Bin 0 -> 163 bytes .../migration/._V7__warehouse_stock_init.sql | Bin 0 -> 163 bytes .../db/migration/._V8__documents_init.sql | Bin 0 -> 163 bytes .../main/resources/db/migration/V1__init.sql | 2 + .../db/migration/V2__system_init.sql | 8 + .../resources/db/migration/V3__auth_init.sql | 13 + .../db/migration/V4__catalog_init.sql | 55 + .../db/migration/V5__procurement_init.sql | 54 + .../resources/db/migration/V6__sales_init.sql | 59 + .../db/migration/V7__warehouse_stock_init.sql | 53 + .../db/migration/V8__documents_init.sql | 26 + docker-compose.server.yml | 66 + docker-compose.yml | 50 + frontend/.dockerignore | 5 + frontend/Dockerfile | 12 + frontend/Dockerfile.server | 19 + frontend/index.html | 12 + frontend/nginx.server.conf | 17 + frontend/package-lock.json | 2443 +++++++++++++++++ frontend/package.json | 27 + frontend/postcss.config.js | 7 + frontend/src/App.tsx | 42 + frontend/src/api/catalogApi.ts | 140 + frontend/src/api/client.ts | 34 + frontend/src/api/dashboardApi.ts | 171 ++ frontend/src/api/documentsApi.ts | 106 + frontend/src/api/errors.ts | 50 + frontend/src/api/procurementApi.ts | 143 + frontend/src/api/salesApi.ts | 147 + frontend/src/api/types.ts | 21 + frontend/src/api/warehouseApi.ts | 118 + frontend/src/auth/AuthContext.tsx | 141 + frontend/src/auth/ProtectedRoute.tsx | 23 + .../src/components/CustomerOrderDocuments.tsx | 164 ++ frontend/src/components/CustomerOrderForm.tsx | 323 +++ frontend/src/components/DataTable.tsx | 66 + frontend/src/components/Layout.tsx | 90 + frontend/src/components/PurchaseOrderForm.tsx | 325 +++ frontend/src/components/Sidebar.tsx | 99 + frontend/src/components/StatusBadge.tsx | 24 + .../src/components/StockAdjustmentForm.tsx | 225 ++ frontend/src/components/dashboard/KpiCard.tsx | 17 + .../components/dashboard/LowStockTable.tsx | 52 + .../components/dashboard/RecentActivities.tsx | 45 + .../dashboard/StatusSummaryTable.tsx | 54 + frontend/src/components/ui/Badge.tsx | 34 + frontend/src/components/ui/Button.tsx | 45 + frontend/src/components/ui/Card.tsx | 24 + frontend/src/components/ui/ConfirmDialog.tsx | 46 + frontend/src/components/ui/EmptyState.tsx | 17 + frontend/src/components/ui/ErrorState.tsx | 11 + frontend/src/components/ui/FormField.tsx | 22 + frontend/src/components/ui/Input.tsx | 13 + frontend/src/components/ui/LoadingState.tsx | 11 + frontend/src/components/ui/PageHeader.tsx | 19 + frontend/src/components/ui/Pagination.tsx | 37 + frontend/src/components/ui/Select.tsx | 13 + frontend/src/components/ui/Textarea.tsx | 13 + frontend/src/index.css | 8 + frontend/src/main.tsx | 17 + frontend/src/pages/CatalogPage.tsx | 314 +++ .../src/pages/CustomerOrderDetailsPage.tsx | 281 ++ frontend/src/pages/CustomerOrdersPage.tsx | 168 ++ frontend/src/pages/CustomersPage.tsx | 60 + frontend/src/pages/DashboardPage.tsx | 191 ++ frontend/src/pages/DocumentsPage.tsx | 172 ++ frontend/src/pages/LoginPage.tsx | 132 + frontend/src/pages/ProductsPage.tsx | 70 + .../src/pages/PurchaseOrderDetailsPage.tsx | 279 ++ frontend/src/pages/PurchaseOrdersPage.tsx | 173 ++ frontend/src/pages/StockBalancesPage.tsx | 197 ++ frontend/src/pages/StockMovementsPage.tsx | 221 ++ frontend/src/pages/SuppliersPage.tsx | 60 + frontend/src/pages/WarehousesPage.tsx | 53 + frontend/src/utils/formatters.ts | 113 + frontend/src/vite-env.d.ts | 2 + frontend/tailwind.config.js | 9 + frontend/tsconfig.json | 20 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 11 + 449 files changed, 17419 insertions(+) create mode 100755 ._. create mode 100644 ._.env.example create mode 100644 ._.gitignore create mode 100644 ._README.md create mode 100755 ._backend create mode 100644 ._docker-compose.server.yml create mode 100644 ._docker-compose.yml create mode 100755 ._frontend create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/._.dockerignore create mode 100644 backend/._Dockerfile create mode 100644 backend/._build.gradle create mode 100644 backend/._settings.gradle create mode 100755 backend/._src create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/build.gradle create mode 100644 backend/settings.gradle create mode 100755 backend/src/._main create mode 100755 backend/src/main/._java create mode 100755 backend/src/main/._resources create mode 100755 backend/src/main/java/._com create mode 100755 backend/src/main/java/com/._example create mode 100755 backend/src/main/java/com/example/._erpmvp create mode 100644 backend/src/main/java/com/example/erpmvp/._ErpMvpApplication.java create mode 100755 backend/src/main/java/com/example/erpmvp/._common create mode 100755 backend/src/main/java/com/example/erpmvp/._config create mode 100755 backend/src/main/java/com/example/erpmvp/._health create mode 100755 backend/src/main/java/com/example/erpmvp/._modules create mode 100644 backend/src/main/java/com/example/erpmvp/ErpMvpApplication.java create mode 100755 backend/src/main/java/com/example/erpmvp/common/._api create mode 100755 backend/src/main/java/com/example/erpmvp/common/._audit create mode 100755 backend/src/main/java/com/example/erpmvp/common/._error create mode 100755 backend/src/main/java/com/example/erpmvp/common/._pagination create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/._ApiError.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/._ApiResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/._FieldValidationError.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/ApiError.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/audit/BaseEntity.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._BadRequestException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._BusinessException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._ConflictException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._DebugErrorController.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._NotFoundException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/._UnauthorizedException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/BadRequestException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/BusinessException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/ConflictException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/DebugErrorController.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/pagination/._PageResponseDto.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/pagination/PageRequestDto.java create mode 100644 backend/src/main/java/com/example/erpmvp/common/pagination/PageResponseDto.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/._OpenApiConfig.java create mode 100755 backend/src/main/java/com/example/erpmvp/config/._demo create mode 100644 backend/src/main/java/com/example/erpmvp/config/CorsConfig.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataSeeder.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/demo/DemoDataProperties.java create mode 100644 backend/src/main/java/com/example/erpmvp/config/demo/DemoDataSeeder.java create mode 100644 backend/src/main/java/com/example/erpmvp/health/._HealthController.java create mode 100644 backend/src/main/java/com/example/erpmvp/health/HealthController.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._auth create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._catalog create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._dashboard create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._documents create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._procurement create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._sales create mode 100755 backend/src/main/java/com/example/erpmvp/modules/._warehouse create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._security create mode 100755 backend/src/main/java/com/example/erpmvp/modules/auth/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/controller/._AuthController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/domain/._User.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/domain/Role.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/domain/User.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._AuthUserResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LogoutResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._MeResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/._ProtectedTestResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/AuthUserResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/LogoutResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/MeResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/dto/ProtectedTestResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/repository/._UserRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/repository/UserRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetailsService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAccessDeniedHandler.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationEntryPoint.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/._SecurityConfig.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetails.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/._JwtService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthDataInitializer.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/catalog/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/catalog/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/catalog/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/catalog/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/catalog/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._CustomerController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._ProductController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._SupplierController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._WarehouseController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/CustomerController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Product.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Supplier.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Warehouse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Customer.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Product.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Supplier.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Warehouse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateCustomerRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateProductRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateSupplierRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateWarehouseRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CustomerResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._ProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._SupplierResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateCustomerRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateProductRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateSupplierRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateWarehouseRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._WarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateCustomerRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateProductRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateSupplierRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateWarehouseRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CustomerResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/ProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/SupplierResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateCustomerRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateProductRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateSupplierRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateWarehouseRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/dto/WarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._CustomerRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._ProductRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._SupplierRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._WarehouseRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/CustomerRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/._ProductService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/._SupplierService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/._WarehouseService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/CustomerService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/dashboard/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/dashboard/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/._DashboardController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/DashboardController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardProcurementResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSalesResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSummaryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._LowStockItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._MovementTypeMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._RecentActivityResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._StatusAmountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TopPartyMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TypeCountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._WarehouseStockMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DailyAmountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardProcurementResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/dashboard/service/DashboardService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/documents/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/documents/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/documents/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/documents/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/documents/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/controller/._DocumentController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/controller/DocumentController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentSourceType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/Document.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentSourceType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentDownload.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/._GenerateCustomerOrderDocumentRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentDownload.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/dto/GenerateCustomerOrderDocumentRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/repository/._DocumentRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/repository/DocumentRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/service/._DocumentService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/service/CustomerOrderPdfGenerator.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/procurement/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/procurement/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/procurement/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/procurement/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/procurement/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/controller/._PurchaseOrderController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/controller/PurchaseOrderController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderItem.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatusHistory.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrder.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderItemRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderStatusHistoryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderSupplierResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._UpdatePurchaseOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/ChangePurchaseOrderStatusRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderItemRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderStatusHistoryRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderItemRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/procurement/service/PurchaseOrderService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/sales/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/sales/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/sales/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/sales/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/sales/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/controller/._CustomerOrderController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/controller/CustomerOrderController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderItem.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatusHistory.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrder.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderItemRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderCustomerResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderStatusHistoryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/._UpdateCustomerOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/ChangeCustomerOrderStatusRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderItemRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderStatusHistoryRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderItemRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/sales/service/CustomerOrderService.java create mode 100755 backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller create mode 100755 backend/src/main/java/com/example/erpmvp/modules/warehouse/._domain create mode 100755 backend/src/main/java/com/example/erpmvp/modules/warehouse/._dto create mode 100755 backend/src/main/java/com/example/erpmvp/modules/warehouse/._repository create mode 100755 backend/src/main/java/com/example/erpmvp/modules/warehouse/._service create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/._WarehouseStockController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/WarehouseStockController.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovement.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementSourceType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockBalance.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovement.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementSourceType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementType.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._ManualStockAdjustmentRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockBalanceResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockMovementResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/ManualStockAdjustmentRequest.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockBalanceResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockMovementResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockProductResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockWarehouseResponse.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockBalanceRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockMovementRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockBalanceRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java create mode 100644 backend/src/main/java/com/example/erpmvp/modules/warehouse/service/WarehouseStockService.java create mode 100644 backend/src/main/resources/._application.yml create mode 100755 backend/src/main/resources/._db create mode 100644 backend/src/main/resources/application.yml create mode 100755 backend/src/main/resources/db/._migration create mode 100644 backend/src/main/resources/db/migration/._V1__init.sql create mode 100644 backend/src/main/resources/db/migration/._V2__system_init.sql create mode 100644 backend/src/main/resources/db/migration/._V3__auth_init.sql create mode 100644 backend/src/main/resources/db/migration/._V4__catalog_init.sql create mode 100644 backend/src/main/resources/db/migration/._V5__procurement_init.sql create mode 100644 backend/src/main/resources/db/migration/._V6__sales_init.sql create mode 100644 backend/src/main/resources/db/migration/._V7__warehouse_stock_init.sql create mode 100644 backend/src/main/resources/db/migration/._V8__documents_init.sql create mode 100644 backend/src/main/resources/db/migration/V1__init.sql create mode 100644 backend/src/main/resources/db/migration/V2__system_init.sql create mode 100644 backend/src/main/resources/db/migration/V3__auth_init.sql create mode 100644 backend/src/main/resources/db/migration/V4__catalog_init.sql create mode 100644 backend/src/main/resources/db/migration/V5__procurement_init.sql create mode 100644 backend/src/main/resources/db/migration/V6__sales_init.sql create mode 100644 backend/src/main/resources/db/migration/V7__warehouse_stock_init.sql create mode 100644 backend/src/main/resources/db/migration/V8__documents_init.sql create mode 100644 docker-compose.server.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/Dockerfile.server create mode 100644 frontend/index.html create mode 100644 frontend/nginx.server.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/catalogApi.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/dashboardApi.ts create mode 100644 frontend/src/api/documentsApi.ts create mode 100644 frontend/src/api/errors.ts create mode 100644 frontend/src/api/procurementApi.ts create mode 100644 frontend/src/api/salesApi.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/api/warehouseApi.ts create mode 100644 frontend/src/auth/AuthContext.tsx create mode 100644 frontend/src/auth/ProtectedRoute.tsx create mode 100644 frontend/src/components/CustomerOrderDocuments.tsx create mode 100644 frontend/src/components/CustomerOrderForm.tsx create mode 100644 frontend/src/components/DataTable.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/PurchaseOrderForm.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/StockAdjustmentForm.tsx create mode 100644 frontend/src/components/dashboard/KpiCard.tsx create mode 100644 frontend/src/components/dashboard/LowStockTable.tsx create mode 100644 frontend/src/components/dashboard/RecentActivities.tsx create mode 100644 frontend/src/components/dashboard/StatusSummaryTable.tsx create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/ConfirmDialog.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/ErrorState.tsx create mode 100644 frontend/src/components/ui/FormField.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/LoadingState.tsx create mode 100644 frontend/src/components/ui/PageHeader.tsx create mode 100644 frontend/src/components/ui/Pagination.tsx create mode 100644 frontend/src/components/ui/Select.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/CatalogPage.tsx create mode 100644 frontend/src/pages/CustomerOrderDetailsPage.tsx create mode 100644 frontend/src/pages/CustomerOrdersPage.tsx create mode 100644 frontend/src/pages/CustomersPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/DocumentsPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/ProductsPage.tsx create mode 100644 frontend/src/pages/PurchaseOrderDetailsPage.tsx create mode 100644 frontend/src/pages/PurchaseOrdersPage.tsx create mode 100644 frontend/src/pages/StockBalancesPage.tsx create mode 100644 frontend/src/pages/StockMovementsPage.tsx create mode 100644 frontend/src/pages/SuppliersPage.tsx create mode 100644 frontend/src/pages/WarehousesPage.tsx create mode 100644 frontend/src/utils/formatters.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/._. b/._. new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K This MVP demonstrates the core ERP operating loop: catalog data, procurement, sales orders, warehouse stock movements, document generation and management dashboard in one integrated system. + +5-7 minute flow: + +1. Login as Admin: `admin@erp.local` / `admin12345`. +2. Open Dashboard and show KPI cards for catalog, sales, procurement, warehouse, documents and low stock. +3. Open Catalog -> Products, Suppliers, Customers, Warehouses and show the seeded master data. +4. Open Purchase Orders and open the received PO. +5. Explain that moving PO to `RECEIVED` created inbound stock movements. +6. Open Stock Balances and show updated warehouse quantities. +7. Open Customer Orders and open the shipped order. +8. Explain that moving SO to `SHIPPED` created outbound stock movements and checked available stock. +9. Open Documents and show generated invoice, contract and delivery note placeholders. +10. Download one PDF from Documents or Customer Order details. +11. Return to Dashboard and show recent activities, low stock and updated analytics. + +## Manual QA Checklist + +Backend: + +- [ ] `/api/health` returns `UP`. +- [ ] Swagger opens at http://localhost:8080/swagger-ui/index.html. +- [ ] Login works. +- [ ] Catalog CRUD works. +- [ ] Purchase order flow works. +- [ ] Customer order flow works. +- [ ] Stock balances update correctly. +- [ ] Documents generate and download. +- [ ] Dashboard loads. + +Frontend: + +- [ ] Login works. +- [ ] Sidebar navigation works. +- [ ] Dashboard loads. +- [ ] Forms save data. +- [ ] Status transitions work. +- [ ] Errors display properly. +- [ ] Download PDF works. +- [ ] Logout works. + +## Troubleshooting + +PostgreSQL port already in use: + +- Stop the local PostgreSQL process or change the `5432:5432` mapping in `docker-compose.yml`. + +Backend cannot connect to database: + +- Check `docker compose logs -f backend` and confirm `SPRING_DATASOURCE_URL`, `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD` match. + +Flyway migration failed: + +- For dev data loss is acceptable, reset with `docker compose down -v` and then `docker compose up --build`. + +Frontend cannot call backend due to CORS: + +- Confirm `CORS_ALLOWED_ORIGINS` includes `http://localhost:5173`. + +JWT expired or unauthorized: + +- Logout, login again, and check that requests include `Authorization: Bearer `. + +Demo data not visible: + +- Ensure `DEMO_DATA_ENABLED=true`, then reset the dev database if it was initialized before enabling demo data. + +PDF download blocked by browser: + +- Try downloading from the Documents page again and make sure pop-up/download blocking is not preventing the file save. + +Database credentials changed but old volume remains: + +- Run `docker compose down -v` so PostgreSQL can initialize with the new `.env` values. + +# MVP Scope + +Included: + +- Auth and roles. +- Catalog. +- Procurement. +- Sales. +- Warehouse stock. +- Documents. +- Dashboard. +- Demo data. + +Not included in MVP v1: + +- Real 1C integration. +- Real WMS/TMS integration. +- Payment module. +- Email/WhatsApp sending. +- Electronic signature. +- Advanced approval workflow. +- Advanced BI/Power BI. +- Multi-company accounting. diff --git a/backend/._.dockerignore b/backend/._.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K details +) { + + public static ApiError of(String code, String message) { + return new ApiError(code, message, List.of()); + } + + public static ApiError of(String code, String message, List details) { + return new ApiError(code, message, details == null ? List.of() : details); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java b/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java new file mode 100644 index 0000000..39550e6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java @@ -0,0 +1,20 @@ +package com.example.erpmvp.common.api; + +import java.time.Instant; + +public record ApiResponse( + boolean success, + T data, + ApiError error, + Instant timestamp +) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null, Instant.now()); + } + + public static ApiResponse error(ApiError error) { + return new ApiResponse<>(false, null, error, Instant.now()); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java b/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java new file mode 100644 index 0000000..a35bc61 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.common.api; + +public record FieldValidationError( + String field, + String message +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java b/backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K throw new ConstraintViolationException("Debug validation error", Set.of()); + case "not-found" -> throw new NotFoundException("DEBUG_NOT_FOUND", "Debug resource was not found"); + case "business" -> throw new BusinessException("DEBUG_BUSINESS_ERROR", "Debug business rule failed"); + case "generic" -> throw new RuntimeException("Debug generic error"); + default -> throw new BadRequestException("UNSUPPORTED_DEBUG_ERROR_TYPE", "Unsupported debug error type: " + type); + } + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java b/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..863af01 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java @@ -0,0 +1,119 @@ +package com.example.erpmvp.common.error; + +import java.util.List; + +import com.example.erpmvp.common.api.ApiError; +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.api.FieldValidationError; +import jakarta.validation.ConstraintViolationException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { + List details = exception.getBindingResult() + .getFieldErrors() + .stream() + .map(this::toFieldValidationError) + .toList(); + + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + "VALIDATION_ERROR", + "Validation failed", + details + ); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException exception) { + List details = exception.getConstraintViolations() + .stream() + .map(violation -> new FieldValidationError( + violation.getPropertyPath().toString(), + violation.getMessage() + )) + .toList(); + + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + "VALIDATION_ERROR", + exception.getMessage(), + details + ); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFound(NotFoundException exception) { + return buildErrorResponse(HttpStatus.NOT_FOUND, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> handleUnauthorized(UnauthorizedException exception) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity> handleConflict(ConflictException exception) { + return buildErrorResponse(HttpStatus.CONFLICT, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity> handleBadRequest(BadRequestException exception) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException exception) { + return buildErrorResponse(HttpStatus.UNPROCESSABLE_ENTITY, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException exception) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, "BAD_REQUEST", exception.getMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound(NoResourceFoundException exception) { + return buildErrorResponse(HttpStatus.NOT_FOUND, "NOT_FOUND", "Resource not found"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception exception) { + return buildErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + "INTERNAL_SERVER_ERROR", + "Unexpected internal server error" + ); + } + + private FieldValidationError toFieldValidationError(FieldError fieldError) { + return new FieldValidationError( + fieldError.getField(), + fieldError.getDefaultMessage() + ); + } + + private ResponseEntity> buildErrorResponse(HttpStatus status, String code, String message) { + return buildErrorResponse(status, code, message, List.of()); + } + + private ResponseEntity> buildErrorResponse( + HttpStatus status, + String code, + String message, + List details + ) { + ApiError error = ApiError.of(code, message, details); + return ResponseEntity.status(status).body(ApiResponse.error(error)); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java b/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java new file mode 100644 index 0000000..bd623a3 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.common.error; + +public class NotFoundException extends BusinessException { + + public NotFoundException(String message) { + super("NOT_FOUND", message); + } + + public NotFoundException(String errorCode, String message) { + super(errorCode, message); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java b/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java new file mode 100644 index 0000000..812bcff --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.common.error; + +public class UnauthorizedException extends BusinessException { + + public UnauthorizedException(String message) { + super("UNAUTHORIZED", message); + } + + public UnauthorizedException(String errorCode, String message) { + super(errorCode, message); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java b/backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrevious +) { + + public static PageResponseDto from(Page page) { + return new PageResponseDto<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java b/backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K !origin.isBlank()) + .toArray(String[]::new); + + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins(origins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("Content-Disposition"); + } + }; + } +} diff --git a/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java b/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java new file mode 100644 index 0000000..575fb9b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI erpMvpOpenApi() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes("bearer-jwt", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .addSecurityItem(new SecurityRequirement().addList("bearer-jwt")) + .info(new Info() + .title("ERP MVP API") + .version("v1") + .description("API documentation for ERP MVP backend")); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java b/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K userRepository.save(new User( + normalizedEmail, + fullName, + passwordEncoder.encode(password), + role + ))); + } + + private DemoCatalog seedCatalog() { + Product apple = upsertProduct("SKU-FRESH-001", "Apple Golden", "Fresh Fruits", "kg"); + Product banana = upsertProduct("SKU-FRESH-002", "Banana Premium", "Fresh Fruits", "kg"); + Product tomato = upsertProduct("SKU-FRESH-003", "Tomato Local", "Vegetables", "kg"); + Product cucumber = upsertProduct("SKU-FRESH-004", "Cucumber Fresh", "Vegetables", "kg"); + Product rice = upsertProduct("SKU-DRY-001", "Rice 5kg", "Grocery", "pcs"); + Product sugar = upsertProduct("SKU-DRY-002", "Sugar 1kg", "Grocery", "pcs"); + Product juice = upsertProduct("SKU-DRINK-001", "Apple Juice 1L", "Drinks", "pcs"); + Product water = upsertProduct("SKU-DRINK-002", "Water 0.5L", "Drinks", "pcs"); + + Supplier freshImport = upsertSupplier("Fresh Import Kazakhstan", "111111111111"); + Supplier almatyAgro = upsertSupplier("Almaty Agro Supply", "222222222222"); + Supplier groceryTrade = upsertSupplier("Grocery Trade LLP", "333333333333"); + + Customer miniMarket = upsertCustomer("Mini Market Alatau", "444444444444"); + Customer greenStore = upsertCustomer("Green Store B2B", "555555555555"); + Customer cityFood = upsertCustomer("City Food Retail", "666666666666"); + + Warehouse mainWarehouse = upsertWarehouse("WH-ALM-01", "Almaty Main Warehouse"); + Warehouse freshWarehouse = upsertWarehouse("WH-ALM-02", "Almaty Fresh Warehouse"); + + return new DemoCatalog( + apple, + banana, + tomato, + cucumber, + rice, + sugar, + juice, + water, + freshImport, + almatyAgro, + groceryTrade, + miniMarket, + greenStore, + cityFood, + mainWarehouse, + freshWarehouse + ); + } + + private Product upsertProduct(String sku, String name, String category, String unit) { + String normalizedSku = Product.normalizeCode(sku); + Product product = productRepository.findBySku(normalizedSku) + .orElseGet(() -> new Product(normalizedSku, name, category, unit, null, "Demo product")); + product.update(name, category, unit, product.getBarcode(), "Demo product", true); + return productRepository.save(product); + } + + private Supplier upsertSupplier(String companyName, String bin) { + Supplier supplier = supplierRepository.findByBin(bin) + .or(() -> supplierRepository.findByCompanyName(companyName)) + .orElseGet(() -> new Supplier( + companyName, + bin, + "Demo contact", + "+7 700 000 0000", + null, + "Almaty, Kazakhstan" + )); + supplier.update( + companyName, + bin, + supplier.getContactName() == null ? "Demo contact" : supplier.getContactName(), + supplier.getPhone() == null ? "+7 700 000 0000" : supplier.getPhone(), + supplier.getEmail(), + supplier.getAddress() == null ? "Almaty, Kazakhstan" : supplier.getAddress(), + true + ); + return supplierRepository.save(supplier); + } + + private Customer upsertCustomer(String companyName, String bin) { + Customer customer = customerRepository.findByBin(bin) + .or(() -> customerRepository.findByCompanyName(companyName)) + .orElseGet(() -> new Customer( + companyName, + bin, + "Demo contact", + "+7 701 000 0000", + null, + "Almaty, Kazakhstan" + )); + customer.update( + companyName, + bin, + customer.getContactName() == null ? "Demo contact" : customer.getContactName(), + customer.getPhone() == null ? "+7 701 000 0000" : customer.getPhone(), + customer.getEmail(), + customer.getAddress() == null ? "Almaty, Kazakhstan" : customer.getAddress(), + true + ); + return customerRepository.save(customer); + } + + private Warehouse upsertWarehouse(String code, String name) { + String normalizedCode = Warehouse.normalizeCode(code); + Warehouse warehouse = warehouseRepository.findByCode(normalizedCode) + .orElseGet(() -> new Warehouse(normalizedCode, name, "Almaty, Kazakhstan")); + warehouse.update(name, warehouse.getAddress() == null ? "Almaty, Kazakhstan" : warehouse.getAddress(), true); + return warehouseRepository.save(warehouse); + } + + private void seedProcurement(DemoCatalog catalog, User admin) { + seedPurchaseOrder( + PO_1_MARKER, + catalog.freshImport(), + catalog.freshWarehouse(), + LocalDate.now().plusDays(5), + List.of( + poItem(catalog.apple(), "120", "450"), + poItem(catalog.banana(), "90", "520") + ), + PurchaseOrderStatus.RECEIVED, + admin + ); + seedPurchaseOrder( + PO_2_MARKER, + catalog.groceryTrade(), + catalog.mainWarehouse(), + LocalDate.now().plusDays(10), + List.of( + poItem(catalog.rice(), "40", "1800"), + poItem(catalog.sugar(), "100", "390") + ), + PurchaseOrderStatus.ORDERED, + admin + ); + seedPurchaseOrder( + PO_3_MARKER, + catalog.almatyAgro(), + catalog.freshWarehouse(), + LocalDate.now().plusDays(7), + List.of( + poItem(catalog.tomato(), "70", "380"), + poItem(catalog.cucumber(), "60", "350") + ), + PurchaseOrderStatus.DRAFT, + admin + ); + } + + private UUID seedPurchaseOrder( + String marker, + Supplier supplier, + Warehouse warehouse, + LocalDate expectedDeliveryDate, + List items, + PurchaseOrderStatus targetStatus, + User admin + ) { + PurchaseOrder existing = purchaseOrderRepository.findFirstByNotesContaining(marker).orElse(null); + UUID purchaseOrderId = existing == null + ? purchaseOrderService.create(new CreatePurchaseOrderRequest( + supplier.getId(), + warehouse.getId(), + expectedDeliveryDate, + marker + " demo purchase order", + items + ), admin).id() + : existing.getId(); + + advancePurchaseOrder(purchaseOrderId, targetStatus, admin, marker); + return purchaseOrderId; + } + + private CreatePurchaseOrderItemRequest poItem(Product product, String quantity, String unitPrice) { + return new CreatePurchaseOrderItemRequest( + product.getId(), + new BigDecimal(quantity), + new BigDecimal(unitPrice) + ); + } + + private void advancePurchaseOrder(UUID purchaseOrderId, PurchaseOrderStatus targetStatus, User admin, String marker) { + PurchaseOrderStatus current = purchaseOrderRepository.findById(purchaseOrderId) + .orElseThrow() + .getStatus(); + + if (current == targetStatus) { + return; + } + + if (targetStatus == PurchaseOrderStatus.CANCELLED) { + purchaseOrderService.changeStatus( + purchaseOrderId, + new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.CANCELLED, marker + " cancelled"), + admin + ); + return; + } + + if (current == PurchaseOrderStatus.DRAFT && targetAtLeast(targetStatus, PurchaseOrderStatus.APPROVED)) { + purchaseOrderService.changeStatus( + purchaseOrderId, + new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.APPROVED, marker + " approved"), + admin + ); + current = PurchaseOrderStatus.APPROVED; + } + + if (current == PurchaseOrderStatus.APPROVED && targetAtLeast(targetStatus, PurchaseOrderStatus.ORDERED)) { + purchaseOrderService.changeStatus( + purchaseOrderId, + new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.ORDERED, marker + " ordered"), + admin + ); + current = PurchaseOrderStatus.ORDERED; + } + + if (current == PurchaseOrderStatus.ORDERED && targetStatus == PurchaseOrderStatus.RECEIVED) { + purchaseOrderService.changeStatus( + purchaseOrderId, + new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.RECEIVED, marker + " received"), + admin + ); + } + } + + private boolean targetAtLeast(PurchaseOrderStatus target, PurchaseOrderStatus checkpoint) { + return purchaseStatusRank(target) >= purchaseStatusRank(checkpoint); + } + + private int purchaseStatusRank(PurchaseOrderStatus status) { + return switch (status) { + case DRAFT -> 0; + case APPROVED -> 1; + case ORDERED -> 2; + case RECEIVED -> 3; + case CANCELLED -> -1; + }; + } + + private DemoSales seedSales(DemoCatalog catalog, User admin) { + UUID shippedOrderId = seedCustomerOrder( + SO_1_MARKER, + catalog.miniMarket(), + catalog.freshWarehouse(), + LocalDate.now().plusDays(3), + List.of( + soItem(catalog.apple(), "20", "650"), + soItem(catalog.banana(), "15", "720") + ), + CustomerOrderStatus.SHIPPED, + admin + ); + UUID confirmedOrderId = seedCustomerOrder( + SO_2_MARKER, + catalog.greenStore(), + catalog.mainWarehouse(), + LocalDate.now().plusDays(8), + List.of( + soItem(catalog.rice(), "10", "2400"), + soItem(catalog.sugar(), "25", "550") + ), + CustomerOrderStatus.CONFIRMED, + admin + ); + seedCustomerOrder( + SO_3_MARKER, + catalog.cityFood(), + catalog.freshWarehouse(), + LocalDate.now().plusDays(4), + List.of(soItem(catalog.tomato(), "10", "590")), + CustomerOrderStatus.NEW, + admin + ); + seedCustomerOrder( + SO_4_MARKER, + catalog.miniMarket(), + catalog.freshWarehouse(), + LocalDate.now().plusDays(6), + List.of(soItem(catalog.cucumber(), "8", "540")), + CustomerOrderStatus.CANCELLED, + admin + ); + + return new DemoSales(shippedOrderId, confirmedOrderId); + } + + private UUID seedCustomerOrder( + String marker, + Customer customer, + Warehouse warehouse, + LocalDate requestedDeliveryDate, + List items, + CustomerOrderStatus targetStatus, + User admin + ) { + CustomerOrder existing = customerOrderRepository.findFirstByNotesContaining(marker).orElse(null); + UUID customerOrderId = existing == null + ? customerOrderService.create(new CreateCustomerOrderRequest( + customer.getId(), + warehouse.getId(), + requestedDeliveryDate, + marker + " demo customer order", + items + ), admin).id() + : existing.getId(); + + advanceCustomerOrder(customerOrderId, targetStatus, admin, marker); + return customerOrderId; + } + + private CreateCustomerOrderItemRequest soItem(Product product, String quantity, String unitPrice) { + return new CreateCustomerOrderItemRequest( + product.getId(), + new BigDecimal(quantity), + new BigDecimal(unitPrice) + ); + } + + private void advanceCustomerOrder(UUID customerOrderId, CustomerOrderStatus targetStatus, User admin, String marker) { + CustomerOrderStatus current = customerOrderRepository.findById(customerOrderId) + .orElseThrow() + .getStatus(); + + if (current == targetStatus) { + return; + } + + if (targetStatus == CustomerOrderStatus.CANCELLED) { + if (current != CustomerOrderStatus.SHIPPED && current != CustomerOrderStatus.CLOSED) { + customerOrderService.changeStatus( + customerOrderId, + new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CANCELLED, marker + " cancelled"), + admin + ); + } + return; + } + + if (current == CustomerOrderStatus.NEW && targetAtLeast(targetStatus, CustomerOrderStatus.CONFIRMED)) { + customerOrderService.changeStatus( + customerOrderId, + new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CONFIRMED, marker + " confirmed"), + admin + ); + current = CustomerOrderStatus.CONFIRMED; + } + + if (current == CustomerOrderStatus.CONFIRMED && targetAtLeast(targetStatus, CustomerOrderStatus.IN_PROGRESS)) { + customerOrderService.changeStatus( + customerOrderId, + new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.IN_PROGRESS, marker + " in progress"), + admin + ); + current = CustomerOrderStatus.IN_PROGRESS; + } + + if (current == CustomerOrderStatus.IN_PROGRESS && targetAtLeast(targetStatus, CustomerOrderStatus.SHIPPED)) { + customerOrderService.changeStatus( + customerOrderId, + new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.SHIPPED, marker + " shipped"), + admin + ); + current = CustomerOrderStatus.SHIPPED; + } + + if (current == CustomerOrderStatus.SHIPPED && targetStatus == CustomerOrderStatus.CLOSED) { + customerOrderService.changeStatus( + customerOrderId, + new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CLOSED, marker + " closed"), + admin + ); + } + } + + private boolean targetAtLeast(CustomerOrderStatus target, CustomerOrderStatus checkpoint) { + return customerStatusRank(target) >= customerStatusRank(checkpoint); + } + + private int customerStatusRank(CustomerOrderStatus status) { + return switch (status) { + case NEW -> 0; + case CONFIRMED -> 1; + case IN_PROGRESS -> 2; + case SHIPPED -> 3; + case CLOSED -> 4; + case CANCELLED -> -1; + }; + } + + private void seedManualStockAdjustments(DemoCatalog catalog, User warehouseUser) { + seedManualAdjustment( + catalog.mainWarehouse(), + catalog.water(), + StockMovementType.ADJUSTMENT_IN, + "8", + "DEMO opening low stock", + warehouseUser + ); + seedManualAdjustment( + catalog.mainWarehouse(), + catalog.juice(), + StockMovementType.ADJUSTMENT_IN, + "5", + "DEMO opening low stock", + warehouseUser + ); + } + + private void seedManualAdjustment( + Warehouse warehouse, + Product product, + StockMovementType type, + String quantity, + String comment, + User warehouseUser + ) { + if (stockMovementRepository.existsBySourceTypeAndWarehouse_IdAndProduct_IdAndComment( + StockMovementSourceType.MANUAL_ADJUSTMENT, + warehouse.getId(), + product.getId(), + comment + )) { + return; + } + + warehouseStockService.manualAdjustment(new ManualStockAdjustmentRequest( + warehouse.getId(), + product.getId(), + type, + new BigDecimal(quantity), + comment + ), warehouseUser); + } + + private void seedDocuments(DemoSales sales, User admin) { + CustomerOrder shippedOrder = customerOrderRepository.findById(sales.shippedOrderId()).orElse(null); + if (shippedOrder != null + && (shippedOrder.getStatus() == CustomerOrderStatus.SHIPPED + || shippedOrder.getStatus() == CustomerOrderStatus.CLOSED)) { + generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.INVOICE, admin); + generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.CONTRACT, admin); + generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.DELIVERY_NOTE, admin); + } + + CustomerOrder confirmedOrder = customerOrderRepository.findById(sales.confirmedOrderId()).orElse(null); + if (confirmedOrder != null && confirmedOrder.getStatus() != CustomerOrderStatus.CANCELLED) { + generateCustomerOrderDocument(confirmedOrder.getId(), DocumentType.INVOICE, admin); + generateCustomerOrderDocument(confirmedOrder.getId(), DocumentType.CONTRACT, admin); + } + } + + private void generateCustomerOrderDocument(UUID customerOrderId, DocumentType documentType, User admin) { + documentService.generateForCustomerOrder(customerOrderId, documentType, admin); + } + + private record DemoUsers(User admin, User manager, User warehouse, User finance) { + } + + private record DemoCatalog( + Product apple, + Product banana, + Product tomato, + Product cucumber, + Product rice, + Product sugar, + Product juice, + Product water, + Supplier freshImport, + Supplier almatyAgro, + Supplier groceryTrade, + Customer miniMarket, + Customer greenStore, + Customer cityFood, + Warehouse mainWarehouse, + Warehouse freshWarehouse + ) { + } + + private record DemoSales(UUID shippedOrderId, UUID confirmedOrderId) { + } +} diff --git a/backend/src/main/java/com/example/erpmvp/health/._HealthController.java b/backend/src/main/java/com/example/erpmvp/health/._HealthController.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K health() { + String databaseStatus = checkDatabase(); + String serviceStatus = "UP".equals(databaseStatus) ? "UP" : "DOWN"; + + return ApiResponse.success(new HealthResponse(serviceStatus, "erp-backend", "v1", databaseStatus)); + } + + private String checkDatabase() { + try { + Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class); + return Integer.valueOf(1).equals(result) ? "UP" : "DOWN"; + } catch (Exception exception) { + return "DOWN"; + } + } + + public record HealthResponse(String status, String service, String version, String database) { + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/._auth b/backend/src/main/java/com/example/erpmvp/modules/._auth new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.success(authService.login(request)); + } + + @GetMapping("/me") + public ApiResponse me(@AuthenticationPrincipal AuthUserDetails principal) { + return ApiResponse.success(MeResponse.from(principal.getUser())); + } + + @PostMapping("/logout") + public ApiResponse logout() { + return ApiResponse.success(new LogoutResponse("Logged out successfully")); + } + + @GetMapping("/protected-test") + public ApiResponse protectedTest(@AuthenticationPrincipal AuthUserDetails principal) { + return ApiResponse.success(new ProtectedTestResponse( + "You are authenticated", + principal.getUser().getEmail(), + principal.getUser().getRole() + )); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K { + + Optional findByEmail(String email); +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPasswordHash(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isEnabled() { + return user.isActive(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java new file mode 100644 index 0000000..f2a8f4c --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.auth.security; + +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.auth.repository.UserRepository; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class AuthUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public AuthUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) { + String email = User.normalizeEmail(username); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + return new AuthUserDetails(user); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..0433cf1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.common.api.ApiError; +import com.example.erpmvp.common.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public JwtAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + ApiResponse body = ApiResponse.error(ApiError.of("ACCESS_DENIED", "Access is denied")); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8661a3a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.common.api.ApiError; +import com.example.erpmvp.common.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + ApiResponse body = ApiResponse.error(ApiError.of("UNAUTHORIZED", "Authentication is required")); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a5be57 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.modules.auth.service.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final AuthUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, AuthUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring(BEARER_PREFIX.length()); + + try { + if (SecurityContextHolder.getContext().getAuthentication() == null && jwtService.validateToken(token)) { + String email = jwtService.extractEmail(token); + AuthUserDetails userDetails = (AuthUserDetails) userDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (UsernameNotFoundException exception) { + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java new file mode 100644 index 0000000..dc33a5f --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java @@ -0,0 +1,56 @@ +package com.example.erpmvp.modules.auth.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter, + JwtAuthenticationEntryPoint authenticationEntryPoint, + JwtAccessDeniedHandler accessDeniedHandler + ) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/login", + "/api/health", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs", + "/v3/api-docs/**", + "/api/debug/error", + "/api/debug/error/**" + ).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K 0) { + return; + } + + User admin = new User( + adminEmail, + "System Administrator", + passwordEncoder.encode(adminPassword), + Role.ADMIN + ); + + userRepository.save(admin); + log.info("Seeded default admin user: {}", admin.getEmail()); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java new file mode 100644 index 0000000..2bf8d0d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java @@ -0,0 +1,47 @@ +package com.example.erpmvp.modules.auth.service; + +import com.example.erpmvp.common.error.UnauthorizedException; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.auth.dto.AuthUserResponse; +import com.example.erpmvp.modules.auth.dto.LoginRequest; +import com.example.erpmvp.modules.auth.dto.LoginResponse; +import com.example.erpmvp.modules.auth.repository.UserRepository; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + } + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest request) { + String email = User.normalizeEmail(request.email()); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UnauthorizedException("INVALID_CREDENTIALS", "Invalid email or password")); + + if (!user.isActive() || !passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new UnauthorizedException("INVALID_CREDENTIALS", "Invalid email or password"); + } + + String token = jwtService.generateToken(user); + + return new LoginResponse( + token, + "Bearer", + jwtService.getExpirationMinutes(), + AuthUserResponse.from(user) + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java new file mode 100644 index 0000000..d60b578 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java @@ -0,0 +1,87 @@ +package com.example.erpmvp.modules.auth.service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import javax.crypto.SecretKey; + +import com.example.erpmvp.modules.auth.domain.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + private final String secret; + private final long expirationMinutes; + + public JwtService( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-minutes}") long expirationMinutes + ) { + this.secret = secret; + this.expirationMinutes = expirationMinutes; + } + + @PostConstruct + void validateConfig() { + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("JWT secret must contain at least 32 bytes"); + } + + if (expirationMinutes <= 0) { + throw new IllegalStateException("JWT expiration must be greater than zero"); + } + } + + public String generateToken(User user) { + Instant now = Instant.now(); + Instant expiresAt = now.plus(Duration.ofMinutes(expirationMinutes)); + + return Jwts.builder() + .subject(user.getEmail()) + .claim("role", user.getRole().name()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(signingKey()) + .compact(); + } + + public boolean validateToken(String token) { + try { + Claims claims = parseClaims(token); + return claims.getExpiration().after(new Date()); + } catch (JwtException | IllegalArgumentException exception) { + return false; + } + } + + public String extractEmail(String token) { + return parseClaims(token).getSubject(); + } + + public long getExpirationMinutes() { + return expirationMinutes; + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey signingKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._controller b/backend/src/main/java/com/example/erpmvp/modules/catalog/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(customerService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get customer by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(customerService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create customer") + public ApiResponse create(@Valid @RequestBody CreateCustomerRequest request) { + return ApiResponse.success(customerService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update customer") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateCustomerRequest request) { + return ApiResponse.success(customerService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate customer") + public ApiResponse delete(@PathVariable UUID id) { + customerService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java new file mode 100644 index 0000000..dbbdc86 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateProductRequest; +import com.example.erpmvp.modules.catalog.dto.ProductResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateProductRequest; +import com.example.erpmvp.modules.catalog.service.ProductService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/products") +@Tag(name = "Catalog Products", description = "Product and SKU directory") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @GetMapping + @Operation(summary = "List products") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(productService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get product by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(productService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create product") + public ApiResponse create(@Valid @RequestBody CreateProductRequest request) { + return ApiResponse.success(productService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update product") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateProductRequest request) { + return ApiResponse.success(productService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate product") + public ApiResponse delete(@PathVariable UUID id) { + productService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java new file mode 100644 index 0000000..8c5ee2e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateSupplierRequest; +import com.example.erpmvp.modules.catalog.dto.SupplierResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateSupplierRequest; +import com.example.erpmvp.modules.catalog.service.SupplierService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/suppliers") +@Tag(name = "Catalog Suppliers", description = "Supplier directory") +public class SupplierController { + + private final SupplierService supplierService; + + public SupplierController(SupplierService supplierService) { + this.supplierService = supplierService; + } + + @GetMapping + @Operation(summary = "List suppliers") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(supplierService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get supplier by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(supplierService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create supplier") + public ApiResponse create(@Valid @RequestBody CreateSupplierRequest request) { + return ApiResponse.success(supplierService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update supplier") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateSupplierRequest request) { + return ApiResponse.success(supplierService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate supplier") + public ApiResponse delete(@PathVariable UUID id) { + supplierService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java new file mode 100644 index 0000000..390bfd5 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.UpdateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.WarehouseResponse; +import com.example.erpmvp.modules.catalog.service.WarehouseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/warehouses") +@Tag(name = "Catalog Warehouses", description = "Warehouse directory") +public class WarehouseController { + + private final WarehouseService warehouseService; + + public WarehouseController(WarehouseService warehouseService) { + this.warehouseService = warehouseService; + } + + @GetMapping + @Operation(summary = "List warehouses") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get warehouse by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(warehouseService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create warehouse") + public ApiResponse create(@Valid @RequestBody CreateWarehouseRequest request) { + return ApiResponse.success(warehouseService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update warehouse") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateWarehouseRequest request) { + return ApiResponse.success(warehouseService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate warehouse") + public ApiResponse delete(@PathVariable UUID id) { + warehouseService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K { + + Optional findByBin(String bin); + + Optional findByCompanyName(String companyName); + + @Query(""" + select c from Customer c + where (:active is null or c.active = :active) + and ( + :search is null + or :search = '' + or lower(c.companyName) like lower(concat('%', :search, '%')) + or lower(coalesce(c.bin, '')) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java new file mode 100644 index 0000000..ee0f9c1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductRepository extends JpaRepository { + + boolean existsBySku(String sku); + + Optional findBySku(String sku); + + @Query(""" + select p from Product p + where (:active is null or p.active = :active) + and ( + :search is null + or :search = '' + or lower(p.sku) like lower(concat('%', :search, '%')) + or lower(p.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java new file mode 100644 index 0000000..2599a43 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Supplier; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SupplierRepository extends JpaRepository { + + Optional findByBin(String bin); + + Optional findByCompanyName(String companyName); + + @Query(""" + select s from Supplier s + where (:active is null or s.active = :active) + and ( + :search is null + or :search = '' + or lower(s.companyName) like lower(concat('%', :search, '%')) + or lower(coalesce(s.bin, '')) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java new file mode 100644 index 0000000..47b7eb4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface WarehouseRepository extends JpaRepository { + + boolean existsByCode(String code); + + Optional findByCode(String code); + + @Query(""" + select w from Warehouse w + where (:active is null or w.active = :active) + and ( + :search is null + or :search = '' + or lower(w.code) like lower(concat('%', :search, '%')) + or lower(w.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(customerRepository.search(normalizeSearch(search), active, pageable).map(CustomerResponse::from)); + } + + @Transactional(readOnly = true) + public CustomerResponse getById(UUID id) { + return CustomerResponse.from(findCustomer(id)); + } + + @Transactional + public CustomerResponse create(CreateCustomerRequest request) { + Customer customer = new Customer( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address() + ); + + return CustomerResponse.from(customerRepository.save(customer)); + } + + @Transactional + public CustomerResponse update(UUID id, UpdateCustomerRequest request) { + Customer customer = findCustomer(id); + customer.update( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address(), + request.active() + ); + + return CustomerResponse.from(customer); + } + + @Transactional + public void delete(UUID id) { + Customer customer = findCustomer(id); + customer.deactivate(); + } + + private Customer findCustomer(UUID id) { + return customerRepository.findById(id) + .orElseThrow(() -> new NotFoundException("CUSTOMER_NOT_FOUND", "Customer not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java new file mode 100644 index 0000000..cfb8a8b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java @@ -0,0 +1,89 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.ConflictException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Product; +import com.example.erpmvp.modules.catalog.dto.CreateProductRequest; +import com.example.erpmvp.modules.catalog.dto.ProductResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateProductRequest; +import com.example.erpmvp.modules.catalog.repository.ProductRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProductService { + + private final ProductRepository productRepository; + + public ProductService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(productRepository.search(normalizeSearch(search), active, pageable).map(ProductResponse::from)); + } + + @Transactional(readOnly = true) + public ProductResponse getById(UUID id) { + return ProductResponse.from(findProduct(id)); + } + + @Transactional + public ProductResponse create(CreateProductRequest request) { + String sku = Product.normalizeCode(request.sku()); + + if (productRepository.existsBySku(sku)) { + throw new ConflictException("PRODUCT_SKU_ALREADY_EXISTS", "Product SKU already exists"); + } + + Product product = new Product( + request.sku(), + request.name(), + request.category(), + request.unit(), + request.barcode(), + request.description() + ); + + return ProductResponse.from(productRepository.save(product)); + } + + @Transactional + public ProductResponse update(UUID id, UpdateProductRequest request) { + Product product = findProduct(id); + product.update( + request.name(), + request.category(), + request.unit(), + request.barcode(), + request.description(), + request.active() + ); + + return ProductResponse.from(product); + } + + @Transactional + public void delete(UUID id) { + Product product = findProduct(id); + product.deactivate(); + } + + private Product findProduct(UUID id) { + return productRepository.findById(id) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java new file mode 100644 index 0000000..18924e6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java @@ -0,0 +1,83 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Supplier; +import com.example.erpmvp.modules.catalog.dto.CreateSupplierRequest; +import com.example.erpmvp.modules.catalog.dto.SupplierResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateSupplierRequest; +import com.example.erpmvp.modules.catalog.repository.SupplierRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SupplierService { + + private final SupplierRepository supplierRepository; + + public SupplierService(SupplierRepository supplierRepository) { + this.supplierRepository = supplierRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(supplierRepository.search(normalizeSearch(search), active, pageable).map(SupplierResponse::from)); + } + + @Transactional(readOnly = true) + public SupplierResponse getById(UUID id) { + return SupplierResponse.from(findSupplier(id)); + } + + @Transactional + public SupplierResponse create(CreateSupplierRequest request) { + Supplier supplier = new Supplier( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address() + ); + + return SupplierResponse.from(supplierRepository.save(supplier)); + } + + @Transactional + public SupplierResponse update(UUID id, UpdateSupplierRequest request) { + Supplier supplier = findSupplier(id); + supplier.update( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address(), + request.active() + ); + + return SupplierResponse.from(supplier); + } + + @Transactional + public void delete(UUID id) { + Supplier supplier = findSupplier(id); + supplier.deactivate(); + } + + private Supplier findSupplier(UUID id) { + return supplierRepository.findById(id) + .orElseThrow(() -> new NotFoundException("SUPPLIER_NOT_FOUND", "Supplier not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java new file mode 100644 index 0000000..da9de91 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java @@ -0,0 +1,75 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.ConflictException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import com.example.erpmvp.modules.catalog.dto.CreateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.UpdateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.WarehouseResponse; +import com.example.erpmvp.modules.catalog.repository.WarehouseRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WarehouseService { + + private final WarehouseRepository warehouseRepository; + + public WarehouseService(WarehouseRepository warehouseRepository) { + this.warehouseRepository = warehouseRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(warehouseRepository.search(normalizeSearch(search), active, pageable).map(WarehouseResponse::from)); + } + + @Transactional(readOnly = true) + public WarehouseResponse getById(UUID id) { + return WarehouseResponse.from(findWarehouse(id)); + } + + @Transactional + public WarehouseResponse create(CreateWarehouseRequest request) { + String code = Warehouse.normalizeCode(request.code()); + + if (warehouseRepository.existsByCode(code)) { + throw new ConflictException("WAREHOUSE_CODE_ALREADY_EXISTS", "Warehouse code already exists"); + } + + Warehouse warehouse = new Warehouse(request.code(), request.name(), request.address()); + + return WarehouseResponse.from(warehouseRepository.save(warehouse)); + } + + @Transactional + public WarehouseResponse update(UUID id, UpdateWarehouseRequest request) { + Warehouse warehouse = findWarehouse(id); + warehouse.update(request.name(), request.address(), request.active()); + + return WarehouseResponse.from(warehouse); + } + + @Transactional + public void delete(UUID id) { + Warehouse warehouse = findWarehouse(id); + warehouse.deactivate(); + } + + private Warehouse findWarehouse(UUID id) { + return warehouseRepository.findById(id) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K summary() { + return ApiResponse.success(dashboardService.summary()); + } + + @GetMapping("/sales") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get sales analytics") + public ApiResponse sales( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate + ) { + return ApiResponse.success(dashboardService.sales(fromDate, toDate)); + } + + @GetMapping("/procurement") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get procurement analytics") + public ApiResponse procurement( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate + ) { + return ApiResponse.success(dashboardService.procurement(fromDate, toDate)); + } + + @GetMapping("/warehouse") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get warehouse analytics") + public ApiResponse warehouse() { + return ApiResponse.success(dashboardService.warehouse()); + } + + @GetMapping("/recent-activities") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get recent ERP activities") + public ApiResponse> recentActivities( + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit + ) { + return ApiResponse.success(dashboardService.recentActivities(limit)); + } + + @GetMapping("/low-stock") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get low stock items") + public ApiResponse> lowStock( + @RequestParam(defaultValue = "10") @DecimalMin("0.0") BigDecimal threshold, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(dashboardService.lowStock(threshold, page, size)); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K purchaseOrdersByStatus, + List dailyProcurement, + List topSuppliers +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java new file mode 100644 index 0000000..d10870e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardSalesResponse( + long totalOrders, + BigDecimal totalAmount, + BigDecimal averageOrderAmount, + List ordersByStatus, + List dailySales, + List topCustomers +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java new file mode 100644 index 0000000..cfa05b6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java @@ -0,0 +1,31 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardSummaryResponse( + long productsCount, + long activeProductsCount, + long suppliersCount, + long customersCount, + long warehousesCount, + long customerOrdersCount, + long activeCustomerOrdersCount, + BigDecimal totalSalesAmount, + List salesOrdersByStatus, + long purchaseOrdersCount, + long activePurchaseOrdersCount, + BigDecimal totalProcurementAmount, + List purchaseOrdersByStatus, + long stockItemsCount, + BigDecimal totalQuantityOnHand, + long lowStockItemsCount, + long stockMovementsCount, + long documentsCount, + List documentsByType, + List recentCustomerOrders, + List recentPurchaseOrders, + List recentStockMovements, + List recentDocuments +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java new file mode 100644 index 0000000..cbba7f0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardWarehouseResponse( + long stockItemsCount, + BigDecimal totalQuantityOnHand, + long lowStockItemsCount, + long movementsCount, + BigDecimal inboundQuantity, + BigDecimal outboundQuantity, + BigDecimal adjustmentInQuantity, + BigDecimal adjustmentOutQuantity, + List stockByWarehouse, + List movementsByType +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java new file mode 100644 index 0000000..a2a4eba --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java @@ -0,0 +1,17 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record LowStockItemResponse( + UUID warehouseId, + String warehouseCode, + String warehouseName, + UUID productId, + String productSku, + String productName, + String unit, + BigDecimal quantityOnHand, + BigDecimal threshold +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java new file mode 100644 index 0000000..4c358d1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; + +public record MovementTypeMetric( + String movementType, + long count, + BigDecimal totalQuantity +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java new file mode 100644 index 0000000..7ba9573 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.time.Instant; +import java.util.UUID; + +public record RecentActivityResponse( + UUID id, + String type, + String title, + String description, + Instant createdAt, + String link +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java new file mode 100644 index 0000000..30ffdb1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; + +public record StatusAmountMetric( + String status, + long count, + BigDecimal totalAmount +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java new file mode 100644 index 0000000..6a30871 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java @@ -0,0 +1,12 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record TopPartyMetric( + UUID partyId, + String companyName, + long ordersCount, + BigDecimal totalAmount +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java new file mode 100644 index 0000000..a3e1e15 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.modules.dashboard.dto; + +public record TypeCountMetric( + String type, + long count +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java new file mode 100644 index 0000000..1a9d5f9 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record WarehouseStockMetric( + UUID warehouseId, + String warehouseCode, + String warehouseName, + long productsCount, + BigDecimal totalQuantityOnHand +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K movementsByType = movementsByType(); + + return new DashboardWarehouseResponse( + count("warehouse_stock_balances"), + sum("warehouse_stock_balances", "quantity_on_hand"), + countLowStock(DEFAULT_LOW_STOCK_THRESHOLD), + count("warehouse_stock_movements"), + movementQuantity(movementsByType, "INBOUND"), + movementQuantity(movementsByType, "OUTBOUND"), + movementQuantity(movementsByType, "ADJUSTMENT_IN"), + movementQuantity(movementsByType, "ADJUSTMENT_OUT"), + stockByWarehouse(), + movementsByType + ); + } + + @Transactional(readOnly = true) + public List recentActivities(int limit) { + validateLimit(limit); + + return jdbcTemplate.query(""" + select id, type, title, description, activity_at, link + from ( + select id, + 'CUSTOMER_ORDER' as type, + 'Customer order ' || order_number || ' created' as title, + 'Status ' || status as description, + created_at as activity_at, + '/sales/customer-orders/' || id::text as link + from sales_customer_orders + union all + select id, + 'PURCHASE_ORDER' as type, + 'Purchase order ' || po_number || ' status ' || status as title, + 'Supplier id ' || supplier_id::text as description, + updated_at as activity_at, + '/procurement/purchase-orders/' || id::text as link + from procurement_purchase_orders + union all + select id, + 'STOCK_MOVEMENT' as type, + 'Stock movement ' || movement_number || ' ' || movement_type as title, + 'Quantity ' || quantity as description, + created_at as activity_at, + '/warehouse/stock-movements' as link + from warehouse_stock_movements + union all + select id, + 'DOCUMENT' as type, + 'Document ' || document_number || ' generated' as title, + document_type || ' for ' || source_type as description, + generated_at as activity_at, + '/documents' as link + from erp_documents + ) activities + order by activity_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + rs.getString("type"), + rs.getString("title"), + rs.getString("description"), + instant(rs, "activity_at"), + rs.getString("link") + ), limit); + } + + @Transactional(readOnly = true) + public PageResponseDto lowStock(BigDecimal threshold, int page, int size) { + validateThreshold(threshold); + int offset = page * size; + long totalElements = jdbcTemplate.queryForObject(""" + select count(*) + from warehouse_stock_balances sb + where sb.quantity_on_hand <= ? + """, Long.class, threshold); + + List items = jdbcTemplate.query(""" + select w.id as warehouse_id, + w.code as warehouse_code, + w.name as warehouse_name, + p.id as product_id, + p.sku as product_sku, + p.name as product_name, + p.unit as unit, + sb.quantity_on_hand + from warehouse_stock_balances sb + join catalog_warehouses w on w.id = sb.warehouse_id + join catalog_products p on p.id = sb.product_id + where sb.quantity_on_hand <= ? + order by sb.quantity_on_hand asc, w.code asc, p.sku asc + limit ? offset ? + """, (rs, rowNum) -> new LowStockItemResponse( + uuid(rs, "warehouse_id"), + rs.getString("warehouse_code"), + rs.getString("warehouse_name"), + uuid(rs, "product_id"), + rs.getString("product_sku"), + rs.getString("product_name"), + rs.getString("unit"), + decimal(rs, "quantity_on_hand"), + threshold + ), threshold, size, offset); + + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return new PageResponseDto<>( + items, + page, + size, + totalElements, + totalPages, + page + 1 < totalPages, + page > 0 + ); + } + + private List salesOrdersByStatus(Instant fromInstant, Instant toExclusiveInstant) { + return statusAmounts( + "sales_customer_orders", + "status", + "total_amount", + "created_at", + fromInstant, + toExclusiveInstant + ); + } + + private List purchaseOrdersByStatus(Instant fromInstant, Instant toExclusiveInstant) { + return statusAmounts( + "procurement_purchase_orders", + "status", + "total_amount", + "created_at", + fromInstant, + toExclusiveInstant + ); + } + + private List statusAmounts( + String table, + String statusColumn, + String amountColumn, + String dateColumn, + Instant fromInstant, + Instant toExclusiveInstant + ) { + String whereClause = fromInstant == null ? "" : " where " + dateColumn + " >= ? and " + dateColumn + " < ?"; + Object[] args = fromInstant == null ? new Object[]{} : new Object[]{timestamp(fromInstant), timestamp(toExclusiveInstant)}; + + return jdbcTemplate.query(""" + select %s as status, count(*) as records_count, coalesce(sum(%s), 0) as total_amount + from %s + %s + group by %s + order by %s + """.formatted(statusColumn, amountColumn, table, whereClause, statusColumn, statusColumn), + (rs, rowNum) -> new StatusAmountMetric( + rs.getString("status"), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ), + args + ); + } + + private List documentsByType() { + return jdbcTemplate.query(""" + select document_type as type, count(*) as records_count + from erp_documents + group by document_type + order by document_type + """, (rs, rowNum) -> new TypeCountMetric( + rs.getString("type"), + rs.getLong("records_count") + )); + } + + private List dailySales(DateRange range) { + return dailyAmounts("sales_customer_orders", "created_at", "total_amount", range); + } + + private List dailyProcurement(DateRange range) { + return dailyAmounts("procurement_purchase_orders", "created_at", "total_amount", range); + } + + private List dailyAmounts(String table, String dateColumn, String amountColumn, DateRange range) { + return jdbcTemplate.query(""" + select cast(%s as date) as activity_date, + count(*) as records_count, + coalesce(sum(%s), 0) as total_amount + from %s + where %s >= ? and %s < ? + group by cast(%s as date) + order by activity_date + """.formatted(dateColumn, amountColumn, table, dateColumn, dateColumn, dateColumn), + (rs, rowNum) -> new DailyAmountMetric( + rs.getDate("activity_date").toLocalDate(), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ), + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private List topCustomers(DateRange range) { + return jdbcTemplate.query(""" + select c.id as party_id, + c.company_name, + count(co.id) as records_count, + coalesce(sum(co.total_amount), 0) as total_amount + from sales_customer_orders co + join catalog_customers c on c.id = co.customer_id + where co.created_at >= ? and co.created_at < ? + group by c.id, c.company_name + order by total_amount desc, records_count desc + limit 5 + """, (rs, rowNum) -> topParty(rs), timestamp(range.fromInstant()), timestamp(range.toExclusiveInstant())); + } + + private List topSuppliers(DateRange range) { + return jdbcTemplate.query(""" + select s.id as party_id, + s.company_name, + count(po.id) as records_count, + coalesce(sum(po.total_amount), 0) as total_amount + from procurement_purchase_orders po + join catalog_suppliers s on s.id = po.supplier_id + where po.created_at >= ? and po.created_at < ? + group by s.id, s.company_name + order by total_amount desc, records_count desc + limit 5 + """, (rs, rowNum) -> topParty(rs), timestamp(range.fromInstant()), timestamp(range.toExclusiveInstant())); + } + + private List stockByWarehouse() { + return jdbcTemplate.query(""" + select w.id as warehouse_id, + w.code as warehouse_code, + w.name as warehouse_name, + count(sb.product_id) as products_count, + coalesce(sum(sb.quantity_on_hand), 0) as total_quantity + from catalog_warehouses w + left join warehouse_stock_balances sb on sb.warehouse_id = w.id + group by w.id, w.code, w.name + order by w.code + """, (rs, rowNum) -> new WarehouseStockMetric( + uuid(rs, "warehouse_id"), + rs.getString("warehouse_code"), + rs.getString("warehouse_name"), + rs.getLong("products_count"), + decimal(rs, "total_quantity") + )); + } + + private List movementsByType() { + return jdbcTemplate.query(""" + select movement_type, + count(*) as records_count, + coalesce(sum(quantity), 0) as total_quantity + from warehouse_stock_movements + group by movement_type + order by movement_type + """, (rs, rowNum) -> new MovementTypeMetric( + rs.getString("movement_type"), + rs.getLong("records_count"), + decimal(rs, "total_quantity") + )); + } + + private List recentCustomerOrders(int limit) { + return jdbcTemplate.query(""" + select id, order_number, status, created_at + from sales_customer_orders + order by created_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "CUSTOMER_ORDER", + "Customer order " + rs.getString("order_number") + " created", + "Status " + rs.getString("status"), + instant(rs, "created_at"), + "/sales/customer-orders/" + rs.getString("id") + ), limit); + } + + private List recentPurchaseOrders(int limit) { + return jdbcTemplate.query(""" + select id, po_number, status, updated_at + from procurement_purchase_orders + order by updated_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "PURCHASE_ORDER", + "Purchase order " + rs.getString("po_number") + " status " + rs.getString("status"), + "Purchase order updated", + instant(rs, "updated_at"), + "/procurement/purchase-orders/" + rs.getString("id") + ), limit); + } + + private List recentStockMovements(int limit) { + return jdbcTemplate.query(""" + select id, movement_number, movement_type, quantity, created_at + from warehouse_stock_movements + order by created_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "STOCK_MOVEMENT", + "Stock movement " + rs.getString("movement_number") + " " + rs.getString("movement_type"), + "Quantity " + rs.getBigDecimal("quantity"), + instant(rs, "created_at"), + "/warehouse/stock-movements" + ), limit); + } + + private List recentDocuments(int limit) { + return jdbcTemplate.query(""" + select id, document_number, document_type, generated_at + from erp_documents + order by generated_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "DOCUMENT", + "Document " + rs.getString("document_number") + " generated", + rs.getString("document_type"), + instant(rs, "generated_at"), + "/documents" + ), limit); + } + + private long count(String table) { + return jdbcTemplate.queryForObject("select count(*) from " + table, Long.class); + } + + private long countWhere(String table, String whereClause) { + return jdbcTemplate.queryForObject("select count(*) from " + table + " where " + whereClause, Long.class); + } + + private long countBetween(String table, String dateColumn, DateRange range) { + return jdbcTemplate.queryForObject( + "select count(*) from " + table + " where " + dateColumn + " >= ? and " + dateColumn + " < ?", + Long.class, + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private BigDecimal sum(String table, String column) { + return jdbcTemplate.queryForObject("select coalesce(sum(" + column + "), 0) from " + table, BigDecimal.class); + } + + private BigDecimal sumBetween(String table, String amountColumn, String dateColumn, DateRange range) { + return jdbcTemplate.queryForObject( + "select coalesce(sum(" + amountColumn + "), 0) from " + table + " where " + dateColumn + " >= ? and " + dateColumn + " < ?", + BigDecimal.class, + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private long countLowStock(BigDecimal threshold) { + return jdbcTemplate.queryForObject( + "select count(*) from warehouse_stock_balances where quantity_on_hand <= ?", + Long.class, + threshold + ); + } + + private BigDecimal movementQuantity(List metrics, String movementType) { + return metrics.stream() + .filter(metric -> movementType.equals(metric.movementType())) + .map(MovementTypeMetric::totalQuantity) + .findFirst() + .orElse(BigDecimal.ZERO); + } + + private TopPartyMetric topParty(java.sql.ResultSet rs) throws java.sql.SQLException { + return new TopPartyMetric( + uuid(rs, "party_id"), + rs.getString("company_name"), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ); + } + + private DateRange resolveDateRange(LocalDate fromDate, LocalDate toDate) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate resolvedFromDate = fromDate == null ? today.minusDays(29) : fromDate; + LocalDate resolvedToDate = toDate == null ? today : toDate; + validateDateRange(resolvedFromDate, resolvedToDate); + return new DateRange( + resolvedFromDate, + resolvedToDate, + resolvedFromDate.atStartOfDay(ZoneOffset.UTC).toInstant(), + resolvedToDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() + ); + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private void validateLimit(int limit) { + if (limit < 1 || limit > 100) { + throw new BadRequestException("INVALID_LIMIT", "limit must be between 1 and 100"); + } + } + + private void validateThreshold(BigDecimal threshold) { + if (threshold.signum() < 0) { + throw new BadRequestException("INVALID_THRESHOLD", "threshold must be greater than or equal to 0"); + } + } + + private BigDecimal average(BigDecimal totalAmount, long count) { + if (count == 0) { + return BigDecimal.ZERO; + } + + return totalAmount.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP); + } + + private Timestamp timestamp(Instant instant) { + return Timestamp.from(instant); + } + + private Instant instant(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + return rs.getTimestamp(column).toInstant(); + } + + private UUID uuid(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + return rs.getObject(column, UUID.class); + } + + private BigDecimal decimal(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + BigDecimal value = rs.getBigDecimal(column); + return value == null ? BigDecimal.ZERO : value; + } + + private record DateRange( + LocalDate fromDate, + LocalDate toDate, + Instant fromInstant, + Instant toExclusiveInstant + ) { + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._controller b/backend/src/main/java/com/example/erpmvp/modules/documents/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K> list( + @RequestParam(required = false) DocumentType documentType, + @RequestParam(required = false) DocumentSourceType sourceType, + @RequestParam(required = false) UUID sourceId, + @RequestParam(required = false) DocumentStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(documentService.list(documentType, sourceType, sourceId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get document metadata by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(documentService.getById(id)); + } + + @GetMapping("/customer-orders/{customerOrderId}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List documents generated for customer order") + public ApiResponse> getCustomerOrderDocuments(@PathVariable UUID customerOrderId) { + return ApiResponse.success(documentService.getDocumentsForCustomerOrder(customerOrderId)); + } + + @PostMapping("/customer-orders/{customerOrderId}/generate") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Generate or refresh customer order PDF document") + public ApiResponse generateForCustomerOrder( + @PathVariable UUID customerOrderId, + @Valid @RequestBody GenerateCustomerOrderDocumentRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(documentService.generateForCustomerOrder( + customerOrderId, + request.documentType(), + currentUser.getUser() + )); + } + + @GetMapping("/{id}/download") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation( + summary = "Download generated PDF", + responses = @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "PDF file", + content = @Content(mediaType = "application/pdf") + ) + ) + public ResponseEntity download(@PathVariable UUID id) { + DocumentDownload download = documentService.download(id); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(download.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName()) + .build() + .toString()) + .body(download.data()); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K, JpaSpecificationExecutor { + + boolean existsByDocumentNumber(String documentNumber); + + long countByDocumentNumberStartingWith(String documentNumberPrefix); + + List findBySourceTypeAndSourceIdOrderByGeneratedAtDesc(DocumentSourceType sourceType, UUID sourceId); + + Optional findBySourceTypeAndSourceIdAndDocumentType( + DocumentSourceType sourceType, + UUID sourceId, + DocumentType documentType + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K lines = buildLines(documentType, documentNumber, customerOrder, generatedAt); + writeLines(document, titleFor(documentType), lines); + document.save(outputStream); + return outputStream.toByteArray(); + } catch (IOException exception) { + throw new BusinessException("PDF_GENERATION_FAILED", "Failed to generate PDF document"); + } + } + + private List buildLines( + DocumentType documentType, + String documentNumber, + CustomerOrder customerOrder, + Instant generatedAt + ) { + List lines = new ArrayList<>(); + lines.add("Document number: " + documentNumber); + lines.add("Document type: " + documentType); + lines.add("Generated at: " + generatedAt); + lines.add(""); + lines.add("Customer order number: " + customerOrder.getOrderNumber()); + lines.add("Customer company name: " + customerOrder.getCustomer().getCompanyName()); + lines.add("Customer BIN: " + blankIfNull(customerOrder.getCustomer().getBin())); + lines.add("Order status: " + customerOrder.getStatus()); + lines.add("Total amount: " + customerOrder.getTotalAmount()); + lines.add("Requested delivery date: " + blankIfNull(customerOrder.getRequestedDeliveryDate())); + lines.add(""); + lines.add("Items:"); + lines.add(String.format("%-14s %-28s %10s %12s %12s", "SKU", "Product name", "Quantity", "Unit price", "Line total")); + lines.add("--------------------------------------------------------------------------------"); + + for (CustomerOrderItem item : customerOrder.getItems()) { + lines.add(String.format( + "%-14s %-28s %10s %12s %12s", + truncate(item.getProduct().getSku(), 14), + truncate(item.getProduct().getName(), 28), + formatDecimal(item.getQuantity()), + formatDecimal(item.getUnitPrice()), + formatDecimal(item.getLineTotal()) + )); + } + + lines.add(""); + lines.add("This document is a placeholder generated by ERP MVP."); + return lines; + } + + private void writeLines(PDDocument document, String title, List lines) throws IOException { + PDPage page = addPage(document); + PDPageContentStream stream = new PDPageContentStream(document, page); + float y = page.getMediaBox().getHeight() - MARGIN; + + stream.beginText(); + stream.setFont(titleFont, 16); + stream.newLineAtOffset(MARGIN, y); + stream.showText(title); + stream.endText(); + y -= LINE_HEIGHT * 2; + + stream.setFont(textFont, FONT_SIZE); + + for (String line : lines) { + if (y <= MARGIN) { + stream.close(); + page = addPage(document); + stream = new PDPageContentStream(document, page); + y = page.getMediaBox().getHeight() - MARGIN; + } + + stream.beginText(); + stream.setFont(line.startsWith("SKU") || line.startsWith("-") ? tableFont : textFont, FONT_SIZE); + stream.newLineAtOffset(MARGIN, y); + stream.showText(sanitize(line)); + stream.endText(); + y -= LINE_HEIGHT; + } + + stream.close(); + } + + private PDPage addPage(PDDocument document) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + return page; + } + + private String titleFor(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "INVOICE PLACEHOLDER"; + case CONTRACT -> "CONTRACT PLACEHOLDER"; + case DELIVERY_NOTE -> "DELIVERY NOTE PLACEHOLDER"; + }; + } + + private String filePrefixFor(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "invoice"; + case CONTRACT -> "contract"; + case DELIVERY_NOTE -> "delivery-note"; + }; + } + + public String fileNameFor(DocumentType documentType, String documentNumber) { + return filePrefixFor(documentType) + "-" + documentNumber + ".pdf"; + } + + private String blankIfNull(Object value) { + return value == null ? "" : value.toString(); + } + + private String formatDecimal(BigDecimal value) { + return value == null ? "" : value.toPlainString(); + } + + private String truncate(String value, int length) { + if (value == null) { + return ""; + } + + return value.length() <= length ? value : value.substring(0, length - 1); + } + + private String sanitize(String value) { + return value.replaceAll("[^\\x20-\\x7E]", " "); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java new file mode 100644 index 0000000..677e4b1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java @@ -0,0 +1,242 @@ +package com.example.erpmvp.modules.documents.service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.domain.Role; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.documents.domain.Document; +import com.example.erpmvp.modules.documents.domain.DocumentSourceType; +import com.example.erpmvp.modules.documents.domain.DocumentStatus; +import com.example.erpmvp.modules.documents.domain.DocumentType; +import com.example.erpmvp.modules.documents.dto.DocumentDownload; +import com.example.erpmvp.modules.documents.dto.DocumentResponse; +import com.example.erpmvp.modules.documents.repository.DocumentRepository; +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.repository.CustomerOrderRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentService { + + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final DateTimeFormatter DOCUMENT_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + private final DocumentRepository documentRepository; + private final CustomerOrderRepository customerOrderRepository; + private final CustomerOrderPdfGenerator pdfGenerator; + + public DocumentService( + DocumentRepository documentRepository, + CustomerOrderRepository customerOrderRepository, + CustomerOrderPdfGenerator pdfGenerator + ) { + this.documentRepository = documentRepository; + this.customerOrderRepository = customerOrderRepository; + this.pdfGenerator = pdfGenerator; + } + + @Transactional(readOnly = true) + public PageResponseDto list( + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + DocumentStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "generatedAt")); + return PageResponseDto.from(documentRepository.findAll(documentFilter( + documentType, + sourceType, + sourceId, + status, + toStartInstant(fromDate), + toExclusiveEndInstant(toDate) + ), pageable).map(DocumentResponse::from)); + } + + @Transactional(readOnly = true) + public DocumentResponse getById(UUID id) { + return DocumentResponse.from(findDocument(id)); + } + + @Transactional(readOnly = true) + public List getDocumentsForCustomerOrder(UUID customerOrderId) { + if (!customerOrderRepository.existsById(customerOrderId)) { + throw new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found"); + } + + return documentRepository.findBySourceTypeAndSourceIdOrderByGeneratedAtDesc( + DocumentSourceType.CUSTOMER_ORDER, + customerOrderId + ) + .stream() + .map(DocumentResponse::from) + .toList(); + } + + @Transactional + public DocumentResponse generateForCustomerOrder(UUID customerOrderId, DocumentType documentType, User currentUser) { + ensureRoleCanGenerate(documentType, currentUser.getRole()); + + CustomerOrder customerOrder = customerOrderRepository.findById(customerOrderId) + .orElseThrow(() -> new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found")); + + ensureCanGenerateForOrder(customerOrder, documentType); + + Instant generatedAt = Instant.now(); + Document document = documentRepository.findBySourceTypeAndSourceIdAndDocumentType( + DocumentSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + documentType + ) + .orElse(null); + String documentNumber = document == null ? generateDocumentNumber(documentType) : document.getDocumentNumber(); + byte[] pdfData = pdfGenerator.generate(documentType, documentNumber, customerOrder, generatedAt); + String fileName = pdfGenerator.fileNameFor(documentType, documentNumber); + + if (document == null) { + document = new Document( + documentNumber, + documentType, + DocumentSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + fileName, + PDF_CONTENT_TYPE, + pdfData, + currentUser.getId(), + generatedAt + ); + } else { + document.updateGeneratedContent(fileName, PDF_CONTENT_TYPE, pdfData, currentUser.getId(), generatedAt); + } + + return DocumentResponse.from(documentRepository.save(document)); + } + + @Transactional(readOnly = true) + public DocumentDownload download(UUID id) { + Document document = findDocument(id); + return new DocumentDownload(document.getFileName(), document.getContentType(), document.getPdfData()); + } + + private Document findDocument(UUID id) { + return documentRepository.findById(id) + .orElseThrow(() -> new NotFoundException("DOCUMENT_NOT_FOUND", "Document not found")); + } + + private Specification documentFilter( + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + DocumentStatus status, + Instant fromDateTime, + Instant toDateTime + ) { + return (root, query, criteriaBuilder) -> { + List predicates = new ArrayList<>(); + + if (documentType != null) { + predicates.add(criteriaBuilder.equal(root.get("documentType"), documentType)); + } + if (sourceType != null) { + predicates.add(criteriaBuilder.equal(root.get("sourceType"), sourceType)); + } + if (sourceId != null) { + predicates.add(criteriaBuilder.equal(root.get("sourceId"), sourceId)); + } + if (status != null) { + predicates.add(criteriaBuilder.equal(root.get("status"), status)); + } + if (fromDateTime != null) { + predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get("generatedAt"), fromDateTime)); + } + if (toDateTime != null) { + predicates.add(criteriaBuilder.lessThan(root.get("generatedAt"), toDateTime)); + } + + return criteriaBuilder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private void ensureCanGenerateForOrder(CustomerOrder customerOrder, DocumentType documentType) { + if (customerOrder.getStatus() == CustomerOrderStatus.CANCELLED) { + throw new BusinessException("DOCUMENT_GENERATION_NOT_ALLOWED", "Cannot generate documents for cancelled order"); + } + + if (documentType == DocumentType.DELIVERY_NOTE + && customerOrder.getStatus() != CustomerOrderStatus.SHIPPED + && customerOrder.getStatus() != CustomerOrderStatus.CLOSED) { + throw new BusinessException( + "DOCUMENT_GENERATION_NOT_ALLOWED", + "Delivery note can be generated only for SHIPPED or CLOSED orders" + ); + } + } + + private void ensureRoleCanGenerate(DocumentType documentType, Role role) { + boolean allowed = switch (documentType) { + case INVOICE, CONTRACT -> role == Role.ADMIN || role == Role.MANAGER || role == Role.FINANCE; + case DELIVERY_NOTE -> role == Role.ADMIN || role == Role.MANAGER || role == Role.WAREHOUSE; + }; + + if (!allowed) { + throw new BusinessException("DOCUMENT_GENERATION_FORBIDDEN", "Current role cannot generate this document type"); + } + } + + private String generateDocumentNumber(DocumentType documentType) { + String prefix = documentPrefix(documentType) + "-" + LocalDate.now().format(DOCUMENT_DATE_FORMAT) + "-"; + long nextNumber = documentRepository.countByDocumentNumberStartingWith(prefix) + 1; + + String documentNumber; + do { + documentNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (documentRepository.existsByDocumentNumber(documentNumber)); + + return documentNumber; + } + + private String documentPrefix(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "INV"; + case CONTRACT -> "CTR"; + case DELIVERY_NOTE -> "DN"; + }; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private Instant toStartInstant(LocalDate date) { + return date == null ? null : date.atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private Instant toExclusiveEndInstant(LocalDate date) { + return date == null ? null : date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._controller b/backend/src/main/java/com/example/erpmvp/modules/procurement/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K> list( + @RequestParam(required = false) String search, + @RequestParam(required = false) UUID supplierId, + @RequestParam(required = false) PurchaseOrderStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(purchaseOrderService.list(search, supplierId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get purchase order by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(purchaseOrderService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create purchase order") + public ApiResponse create( + @Valid @RequestBody CreatePurchaseOrderRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(purchaseOrderService.create(request, currentUser.getUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update draft purchase order") + public ApiResponse update( + @PathVariable UUID id, + @Valid @RequestBody UpdatePurchaseOrderRequest request + ) { + return ApiResponse.success(purchaseOrderService.update(id, request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE')") + @Operation(summary = "Change purchase order status") + public ApiResponse changeStatus( + @PathVariable UUID id, + @Valid @RequestBody ChangePurchaseOrderStatusRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(purchaseOrderService.changeStatus(id, request, currentUser.getUser())); + } + + @GetMapping("/{id}/status-history") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get purchase order status history") + public ApiResponse> getStatusHistory(@PathVariable UUID id) { + return ApiResponse.success(purchaseOrderService.getStatusHistory(id)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Cancel purchase order") + public ApiResponse delete( + @PathVariable UUID id, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + purchaseOrderService.delete(id, currentUser.getUser()); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K items = new ArrayList<>(); + + protected PurchaseOrder() { + } + + public PurchaseOrder( + String poNumber, + Supplier supplier, + Warehouse warehouse, + LocalDate expectedDeliveryDate, + String notes, + UUID createdByUserId + ) { + this.poNumber = poNumber; + this.supplier = supplier; + this.warehouse = warehouse; + this.expectedDeliveryDate = expectedDeliveryDate; + this.notes = trim(notes); + this.createdByUserId = createdByUserId; + this.status = PurchaseOrderStatus.DRAFT; + this.totalAmount = BigDecimal.ZERO; + } + + public void updateDraft(Supplier supplier, Warehouse warehouse, LocalDate expectedDeliveryDate, String notes) { + this.supplier = supplier; + this.warehouse = warehouse; + this.expectedDeliveryDate = expectedDeliveryDate; + this.notes = trim(notes); + } + + public void replaceItems(List newItems) { + items.clear(); + newItems.forEach(this::addItem); + recalculateTotal(); + } + + public void addItem(PurchaseOrderItem item) { + item.setPurchaseOrder(this); + items.add(item); + } + + public void recalculateTotal() { + totalAmount = items.stream() + .map(PurchaseOrderItem::getLineTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public void changeStatus(PurchaseOrderStatus newStatus, UUID changedByUserId) { + status = newStatus; + Instant now = Instant.now(); + + if (newStatus == PurchaseOrderStatus.APPROVED) { + approvedByUserId = changedByUserId; + } else if (newStatus == PurchaseOrderStatus.ORDERED) { + orderedAt = now; + } else if (newStatus == PurchaseOrderStatus.RECEIVED) { + receivedAt = now; + } else if (newStatus == PurchaseOrderStatus.CANCELLED) { + cancelledAt = now; + } + } + + public String getPoNumber() { + return poNumber; + } + + public Supplier getSupplier() { + return supplier; + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public PurchaseOrderStatus getStatus() { + return status; + } + + public LocalDate getExpectedDeliveryDate() { + return expectedDeliveryDate; + } + + public String getNotes() { + return notes; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public UUID getCreatedByUserId() { + return createdByUserId; + } + + public UUID getApprovedByUserId() { + return approvedByUserId; + } + + public Instant getOrderedAt() { + return orderedAt; + } + + public Instant getReceivedAt() { + return receivedAt; + } + + public Instant getCancelledAt() { + return cancelledAt; + } + + public List getItems() { + return items; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java new file mode 100644 index 0000000..e8f33ec --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.procurement.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "procurement_purchase_order_items") +public class PurchaseOrderItem extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "purchase_order_id", nullable = false) + private PurchaseOrder purchaseOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, precision = 19, scale = 3) + private BigDecimal quantity; + + @Column(name = "unit_price", nullable = false, precision = 19, scale = 2) + private BigDecimal unitPrice; + + @Column(name = "line_total", nullable = false, precision = 19, scale = 2) + private BigDecimal lineTotal; + + protected PurchaseOrderItem() { + } + + public PurchaseOrderItem(Product product, BigDecimal quantity, BigDecimal unitPrice) { + this.product = product; + this.quantity = quantity; + this.unitPrice = unitPrice; + recalculateLineTotal(); + } + + public void setPurchaseOrder(PurchaseOrder purchaseOrder) { + this.purchaseOrder = purchaseOrder; + } + + public Product getProduct() { + return product; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public BigDecimal getLineTotal() { + return lineTotal; + } + + private void recalculateLineTotal() { + lineTotal = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java new file mode 100644 index 0000000..5ae9ccf --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.procurement.domain; + +public enum PurchaseOrderStatus { + DRAFT, + APPROVED, + ORDERED, + RECEIVED, + CANCELLED +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java new file mode 100644 index 0000000..f59e74a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.procurement.domain; + +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "procurement_purchase_order_status_history") +public class PurchaseOrderStatusHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "purchase_order_id", nullable = false) + private PurchaseOrder purchaseOrder; + + @Enumerated(EnumType.STRING) + @Column(name = "old_status", length = 50) + private PurchaseOrderStatus oldStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false, length = 50) + private PurchaseOrderStatus newStatus; + + @Column(name = "changed_by_user_id") + private UUID changedByUserId; + + @Column(columnDefinition = "TEXT") + private String comment; + + protected PurchaseOrderStatusHistory() { + } + + public PurchaseOrderStatusHistory( + PurchaseOrder purchaseOrder, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + this.purchaseOrder = purchaseOrder; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedByUserId = changedByUserId; + this.comment = comment == null ? null : comment.trim(); + } + + public PurchaseOrderStatus getOldStatus() { + return oldStatus; + } + + public PurchaseOrderStatus getNewStatus() { + return newStatus; + } + + public UUID getChangedByUserId() { + return changedByUserId; + } + + public String getComment() { + return comment; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K items +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java new file mode 100644 index 0000000..09658ca --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java @@ -0,0 +1,26 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderItem; + +public record PurchaseOrderItemResponse( + UUID id, + PurchaseOrderProductResponse product, + BigDecimal quantity, + BigDecimal unitPrice, + BigDecimal lineTotal +) { + + public static PurchaseOrderItemResponse from(PurchaseOrderItem item) { + return new PurchaseOrderItemResponse( + item.getId(), + PurchaseOrderProductResponse.from(item.getProduct()), + item.getQuantity(), + item.getUnitPrice(), + item.getLineTotal() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java new file mode 100644 index 0000000..edf8258 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java @@ -0,0 +1,23 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record PurchaseOrderProductResponse( + UUID id, + String sku, + String name, + String unit +) { + + public static PurchaseOrderProductResponse from(Product product) { + return new PurchaseOrderProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getUnit() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java new file mode 100644 index 0000000..c11b228 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java @@ -0,0 +1,51 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; + +public record PurchaseOrderResponse( + UUID id, + String poNumber, + PurchaseOrderSupplierResponse supplier, + PurchaseOrderWarehouseResponse warehouse, + PurchaseOrderStatus status, + LocalDate expectedDeliveryDate, + String notes, + BigDecimal totalAmount, + UUID createdByUserId, + UUID approvedByUserId, + Instant orderedAt, + Instant receivedAt, + Instant cancelledAt, + Instant createdAt, + Instant updatedAt, + List items +) { + + public static PurchaseOrderResponse from(PurchaseOrder purchaseOrder) { + return new PurchaseOrderResponse( + purchaseOrder.getId(), + purchaseOrder.getPoNumber(), + PurchaseOrderSupplierResponse.from(purchaseOrder.getSupplier()), + PurchaseOrderWarehouseResponse.from(purchaseOrder.getWarehouse()), + purchaseOrder.getStatus(), + purchaseOrder.getExpectedDeliveryDate(), + purchaseOrder.getNotes(), + purchaseOrder.getTotalAmount(), + purchaseOrder.getCreatedByUserId(), + purchaseOrder.getApprovedByUserId(), + purchaseOrder.getOrderedAt(), + purchaseOrder.getReceivedAt(), + purchaseOrder.getCancelledAt(), + purchaseOrder.getCreatedAt(), + purchaseOrder.getUpdatedAt(), + purchaseOrder.getItems().stream().map(PurchaseOrderItemResponse::from).toList() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java new file mode 100644 index 0000000..5f4b7da --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatusHistory; + +public record PurchaseOrderStatusHistoryResponse( + UUID id, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment, + Instant createdAt +) { + + public static PurchaseOrderStatusHistoryResponse from(PurchaseOrderStatusHistory history) { + return new PurchaseOrderStatusHistoryResponse( + history.getId(), + history.getOldStatus(), + history.getNewStatus(), + history.getChangedByUserId(), + history.getComment(), + history.getCreatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java new file mode 100644 index 0000000..d444d22 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java @@ -0,0 +1,21 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Supplier; + +public record PurchaseOrderSupplierResponse( + UUID id, + String companyName, + String bin +) { + + public static PurchaseOrderSupplierResponse from(Supplier supplier) { + return new PurchaseOrderSupplierResponse( + supplier.getId(), + supplier.getCompanyName(), + supplier.getBin() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java new file mode 100644 index 0000000..0dd1ad0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java @@ -0,0 +1,24 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record PurchaseOrderWarehouseResponse( + UUID id, + String code, + String name +) { + + public static PurchaseOrderWarehouseResponse from(Warehouse warehouse) { + if (warehouse == null) { + return null; + } + + return new PurchaseOrderWarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java new file mode 100644 index 0000000..768d04c --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record UpdatePurchaseOrderRequest( + @NotNull UUID supplierId, + UUID warehouseId, + LocalDate expectedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java new file mode 100644 index 0000000..b12ea95 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java @@ -0,0 +1,42 @@ +package com.example.erpmvp.modules.procurement.repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PurchaseOrderRepository extends JpaRepository { + + boolean existsByPoNumber(String poNumber); + + long countByPoNumberStartingWith(String poNumberPrefix); + + Optional findFirstByNotesContaining(String marker); + + @EntityGraph(attributePaths = {"supplier", "warehouse"}) + @Query(""" + select po from PurchaseOrder po + where (:search is null or :search = '' or lower(po.poNumber) like lower(concat('%', :search, '%'))) + and (:supplierId is null or po.supplier.id = :supplierId) + and (:status is null or po.status = :status) + and (:fromDate is null or po.expectedDeliveryDate >= :fromDate) + and (:toDate is null or po.expectedDeliveryDate <= :toDate) + """) + Page search( + @Param("search") String search, + @Param("supplierId") UUID supplierId, + @Param("status") PurchaseOrderStatus status, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java new file mode 100644 index 0000000..1f82825 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.modules.procurement.repository; + +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatusHistory; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PurchaseOrderStatusHistoryRepository extends JpaRepository { + + List findByPurchaseOrder_IdOrderByCreatedAtAsc(UUID purchaseOrderId); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K list( + String search, + UUID supplierId, + PurchaseOrderStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(purchaseOrderRepository.search( + normalizeSearch(search), + supplierId, + status, + fromDate, + toDate, + pageable + ).map(PurchaseOrderResponse::from)); + } + + @Transactional(readOnly = true) + public PurchaseOrderResponse getById(UUID id) { + return PurchaseOrderResponse.from(findPurchaseOrder(id)); + } + + @Transactional + public PurchaseOrderResponse create(CreatePurchaseOrderRequest request, User currentUser) { + Supplier supplier = findActiveSupplier(request.supplierId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + PurchaseOrder purchaseOrder = new PurchaseOrder( + generatePoNumber(), + supplier, + warehouse, + request.expectedDeliveryDate(), + request.notes(), + currentUser.getId() + ); + purchaseOrder.replaceItems(buildItems(request.items())); + + PurchaseOrder saved = purchaseOrderRepository.save(purchaseOrder); + addHistory(saved, null, PurchaseOrderStatus.DRAFT, currentUser.getId(), "Created"); + + return PurchaseOrderResponse.from(saved); + } + + @Transactional + public PurchaseOrderResponse update(UUID id, UpdatePurchaseOrderRequest request) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + ensureDraft(purchaseOrder); + + Supplier supplier = findActiveSupplier(request.supplierId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + purchaseOrder.updateDraft(supplier, warehouse, request.expectedDeliveryDate(), request.notes()); + purchaseOrder.replaceItems(buildItems(request.items())); + + return PurchaseOrderResponse.from(purchaseOrder); + } + + @Transactional + public PurchaseOrderResponse changeStatus(UUID id, ChangePurchaseOrderStatusRequest request, User currentUser) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + PurchaseOrderStatus oldStatus = purchaseOrder.getStatus(); + PurchaseOrderStatus newStatus = request.status(); + + ensureStatusChangeAllowedForRole(newStatus, currentUser.getRole()); + ensureTransitionAllowed(oldStatus, newStatus); + + if (newStatus == PurchaseOrderStatus.RECEIVED) { + ensureReceivingWarehouseSelected(purchaseOrder); + warehouseStockService.receivePurchaseOrder(purchaseOrder, currentUser); + } + + purchaseOrder.changeStatus(newStatus, currentUser.getId()); + addHistory(purchaseOrder, oldStatus, newStatus, currentUser.getId(), request.comment()); + + return PurchaseOrderResponse.from(purchaseOrder); + } + + @Transactional + public void delete(UUID id, User currentUser) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + + if (purchaseOrder.getStatus() == PurchaseOrderStatus.CANCELLED) { + return; + } + + ensureTransitionAllowed(purchaseOrder.getStatus(), PurchaseOrderStatus.CANCELLED); + PurchaseOrderStatus oldStatus = purchaseOrder.getStatus(); + purchaseOrder.changeStatus(PurchaseOrderStatus.CANCELLED, currentUser.getId()); + addHistory(purchaseOrder, oldStatus, PurchaseOrderStatus.CANCELLED, currentUser.getId(), "Cancelled via DELETE"); + } + + @Transactional(readOnly = true) + public List getStatusHistory(UUID id) { + if (!purchaseOrderRepository.existsById(id)) { + throw new NotFoundException("PURCHASE_ORDER_NOT_FOUND", "Purchase order not found"); + } + + return statusHistoryRepository.findByPurchaseOrder_IdOrderByCreatedAtAsc(id) + .stream() + .map(PurchaseOrderStatusHistoryResponse::from) + .toList(); + } + + private PurchaseOrder findPurchaseOrder(UUID id) { + return purchaseOrderRepository.findById(id) + .orElseThrow(() -> new NotFoundException("PURCHASE_ORDER_NOT_FOUND", "Purchase order not found")); + } + + private Supplier findActiveSupplier(UUID supplierId) { + Supplier supplier = supplierRepository.findById(supplierId) + .orElseThrow(() -> new NotFoundException("SUPPLIER_NOT_FOUND", "Supplier not found")); + + if (!supplier.isActive()) { + throw new BusinessException("SUPPLIER_INACTIVE", "Supplier is inactive"); + } + + return supplier; + } + + private Warehouse findActiveWarehouseOrNull(UUID warehouseId) { + if (warehouseId == null) { + return null; + } + + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + + return product; + } + + private List buildItems(List itemRequests) { + return itemRequests.stream() + .map(item -> new PurchaseOrderItem( + findActiveProduct(item.productId()), + item.quantity(), + item.unitPrice() + )) + .toList(); + } + + private void ensureDraft(PurchaseOrder purchaseOrder) { + if (purchaseOrder.getStatus() != PurchaseOrderStatus.DRAFT) { + throw new BusinessException( + "PURCHASE_ORDER_NOT_EDITABLE", + "Only DRAFT purchase orders can be edited" + ); + } + } + + private void ensureStatusChangeAllowedForRole(PurchaseOrderStatus newStatus, Role role) { + if (role == Role.WAREHOUSE && newStatus != PurchaseOrderStatus.RECEIVED) { + throw new BusinessException( + "STATUS_CHANGE_NOT_ALLOWED", + "WAREHOUSE role can only mark purchase orders as RECEIVED" + ); + } + } + + private void ensureReceivingWarehouseSelected(PurchaseOrder purchaseOrder) { + if (purchaseOrder.getWarehouse() == null) { + throw new BusinessException( + "RECEIVING_WAREHOUSE_REQUIRED", + "Receiving warehouse is required before marking purchase order as RECEIVED" + ); + } + } + + private void ensureTransitionAllowed(PurchaseOrderStatus oldStatus, PurchaseOrderStatus newStatus) { + if (oldStatus == newStatus) { + throw new BusinessException("INVALID_STATUS_TRANSITION", "Purchase order is already in status " + newStatus); + } + + boolean allowed = switch (oldStatus) { + case DRAFT -> newStatus == PurchaseOrderStatus.APPROVED || newStatus == PurchaseOrderStatus.CANCELLED; + case APPROVED -> newStatus == PurchaseOrderStatus.ORDERED || newStatus == PurchaseOrderStatus.CANCELLED; + case ORDERED -> newStatus == PurchaseOrderStatus.RECEIVED || newStatus == PurchaseOrderStatus.CANCELLED; + case RECEIVED, CANCELLED -> false; + }; + + if (!allowed) { + throw new BusinessException( + "INVALID_STATUS_TRANSITION", + "Invalid status transition from " + oldStatus + " to " + newStatus + ); + } + } + + private void addHistory( + PurchaseOrder purchaseOrder, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + statusHistoryRepository.save(new PurchaseOrderStatusHistory( + purchaseOrder, + oldStatus, + newStatus, + changedByUserId, + comment + )); + } + + private String generatePoNumber() { + String prefix = "PO-" + LocalDate.now().format(PO_DATE_FORMAT) + "-"; + long nextNumber = purchaseOrderRepository.countByPoNumberStartingWith(prefix) + 1; + + String poNumber; + do { + poNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (purchaseOrderRepository.existsByPoNumber(poNumber)); + + return poNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._controller b/backend/src/main/java/com/example/erpmvp/modules/sales/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K> list( + @RequestParam(required = false) String search, + @RequestParam(required = false) UUID customerId, + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) CustomerOrderStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(customerOrderService.list(search, customerId, warehouseId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get customer order by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(customerOrderService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create customer order") + public ApiResponse create( + @Valid @RequestBody CreateCustomerOrderRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(customerOrderService.create(request, currentUser.getUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update new customer order") + public ApiResponse update( + @PathVariable UUID id, + @Valid @RequestBody UpdateCustomerOrderRequest request + ) { + return ApiResponse.success(customerOrderService.update(id, request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE')") + @Operation(summary = "Change customer order status") + public ApiResponse changeStatus( + @PathVariable UUID id, + @Valid @RequestBody ChangeCustomerOrderStatusRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(customerOrderService.changeStatus(id, request, currentUser.getUser())); + } + + @GetMapping("/{id}/status-history") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get customer order status history") + public ApiResponse> getStatusHistory(@PathVariable UUID id) { + return ApiResponse.success(customerOrderService.getStatusHistory(id)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Cancel customer order") + public ApiResponse delete( + @PathVariable UUID id, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + customerOrderService.delete(id, currentUser.getUser()); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K items = new ArrayList<>(); + + protected CustomerOrder() { + } + + public CustomerOrder( + String orderNumber, + Customer customer, + Warehouse warehouse, + LocalDate requestedDeliveryDate, + String notes, + UUID createdByUserId + ) { + this.orderNumber = orderNumber; + this.customer = customer; + this.warehouse = warehouse; + this.requestedDeliveryDate = requestedDeliveryDate; + this.notes = trim(notes); + this.createdByUserId = createdByUserId; + this.status = CustomerOrderStatus.NEW; + this.totalAmount = BigDecimal.ZERO; + } + + public void updateNew(Customer customer, Warehouse warehouse, LocalDate requestedDeliveryDate, String notes) { + this.customer = customer; + this.warehouse = warehouse; + this.requestedDeliveryDate = requestedDeliveryDate; + this.notes = trim(notes); + } + + public void replaceItems(List newItems) { + items.clear(); + newItems.forEach(this::addItem); + recalculateTotal(); + } + + public void addItem(CustomerOrderItem item) { + item.setCustomerOrder(this); + items.add(item); + } + + public void recalculateTotal() { + totalAmount = items.stream() + .map(CustomerOrderItem::getLineTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public void changeStatus(CustomerOrderStatus newStatus, UUID changedByUserId) { + status = newStatus; + Instant now = Instant.now(); + + if (newStatus == CustomerOrderStatus.CONFIRMED) { + confirmedByUserId = changedByUserId; + confirmedAt = now; + } else if (newStatus == CustomerOrderStatus.IN_PROGRESS) { + inProgressAt = now; + } else if (newStatus == CustomerOrderStatus.SHIPPED) { + shippedAt = now; + } else if (newStatus == CustomerOrderStatus.CLOSED) { + closedAt = now; + } else if (newStatus == CustomerOrderStatus.CANCELLED) { + cancelledAt = now; + } + } + + public String getOrderNumber() { + return orderNumber; + } + + public Customer getCustomer() { + return customer; + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public CustomerOrderStatus getStatus() { + return status; + } + + public LocalDate getRequestedDeliveryDate() { + return requestedDeliveryDate; + } + + public String getNotes() { + return notes; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public UUID getCreatedByUserId() { + return createdByUserId; + } + + public UUID getConfirmedByUserId() { + return confirmedByUserId; + } + + public Instant getConfirmedAt() { + return confirmedAt; + } + + public Instant getInProgressAt() { + return inProgressAt; + } + + public Instant getShippedAt() { + return shippedAt; + } + + public Instant getClosedAt() { + return closedAt; + } + + public Instant getCancelledAt() { + return cancelledAt; + } + + public List getItems() { + return items; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java new file mode 100644 index 0000000..bbbfa74 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.sales.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "sales_customer_order_items") +public class CustomerOrderItem extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_order_id", nullable = false) + private CustomerOrder customerOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, precision = 19, scale = 3) + private BigDecimal quantity; + + @Column(name = "unit_price", nullable = false, precision = 19, scale = 2) + private BigDecimal unitPrice; + + @Column(name = "line_total", nullable = false, precision = 19, scale = 2) + private BigDecimal lineTotal; + + protected CustomerOrderItem() { + } + + public CustomerOrderItem(Product product, BigDecimal quantity, BigDecimal unitPrice) { + this.product = product; + this.quantity = quantity; + this.unitPrice = unitPrice; + recalculateLineTotal(); + } + + public void setCustomerOrder(CustomerOrder customerOrder) { + this.customerOrder = customerOrder; + } + + public Product getProduct() { + return product; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public BigDecimal getLineTotal() { + return lineTotal; + } + + private void recalculateLineTotal() { + lineTotal = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java new file mode 100644 index 0000000..2f825ee --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.sales.domain; + +public enum CustomerOrderStatus { + NEW, + CONFIRMED, + IN_PROGRESS, + SHIPPED, + CLOSED, + CANCELLED +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java new file mode 100644 index 0000000..42d57aa --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.sales.domain; + +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "sales_customer_order_status_history") +public class CustomerOrderStatusHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_order_id", nullable = false) + private CustomerOrder customerOrder; + + @Enumerated(EnumType.STRING) + @Column(name = "old_status", length = 50) + private CustomerOrderStatus oldStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false, length = 50) + private CustomerOrderStatus newStatus; + + @Column(name = "changed_by_user_id") + private UUID changedByUserId; + + @Column(columnDefinition = "TEXT") + private String comment; + + protected CustomerOrderStatusHistory() { + } + + public CustomerOrderStatusHistory( + CustomerOrder customerOrder, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + this.customerOrder = customerOrder; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedByUserId = changedByUserId; + this.comment = comment == null ? null : comment.trim(); + } + + public CustomerOrderStatus getOldStatus() { + return oldStatus; + } + + public CustomerOrderStatus getNewStatus() { + return newStatus; + } + + public UUID getChangedByUserId() { + return changedByUserId; + } + + public String getComment() { + return comment; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K items +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java new file mode 100644 index 0000000..9d1f24b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java @@ -0,0 +1,21 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Customer; + +public record CustomerOrderCustomerResponse( + UUID id, + String companyName, + String bin +) { + + public static CustomerOrderCustomerResponse from(Customer customer) { + return new CustomerOrderCustomerResponse( + customer.getId(), + customer.getCompanyName(), + customer.getBin() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java new file mode 100644 index 0000000..b844502 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java @@ -0,0 +1,26 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; + +public record CustomerOrderItemResponse( + UUID id, + CustomerOrderProductResponse product, + BigDecimal quantity, + BigDecimal unitPrice, + BigDecimal lineTotal +) { + + public static CustomerOrderItemResponse from(CustomerOrderItem item) { + return new CustomerOrderItemResponse( + item.getId(), + CustomerOrderProductResponse.from(item.getProduct()), + item.getQuantity(), + item.getUnitPrice(), + item.getLineTotal() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java new file mode 100644 index 0000000..da25bb0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java @@ -0,0 +1,23 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record CustomerOrderProductResponse( + UUID id, + String sku, + String name, + String unit +) { + + public static CustomerOrderProductResponse from(Product product) { + return new CustomerOrderProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getUnit() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java new file mode 100644 index 0000000..f37d948 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java @@ -0,0 +1,56 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; + +public record CustomerOrderResponse( + UUID id, + String orderNumber, + CustomerOrderCustomerResponse customer, + CustomerOrderWarehouseResponse warehouse, + CustomerOrderStatus status, + LocalDate requestedDeliveryDate, + String notes, + BigDecimal totalAmount, + UUID createdByUserId, + UUID confirmedByUserId, + Instant confirmedAt, + Instant inProgressAt, + Instant shippedAt, + Instant closedAt, + Instant cancelledAt, + Instant createdAt, + Instant updatedAt, + List items +) { + + public static CustomerOrderResponse from(CustomerOrder customerOrder) { + return new CustomerOrderResponse( + customerOrder.getId(), + customerOrder.getOrderNumber(), + CustomerOrderCustomerResponse.from(customerOrder.getCustomer()), + CustomerOrderWarehouseResponse.from(customerOrder.getWarehouse()), + customerOrder.getStatus(), + customerOrder.getRequestedDeliveryDate(), + customerOrder.getNotes(), + customerOrder.getTotalAmount(), + customerOrder.getCreatedByUserId(), + customerOrder.getConfirmedByUserId(), + customerOrder.getConfirmedAt(), + customerOrder.getInProgressAt(), + customerOrder.getShippedAt(), + customerOrder.getClosedAt(), + customerOrder.getCancelledAt(), + customerOrder.getCreatedAt(), + customerOrder.getUpdatedAt(), + customerOrder.getItems().stream().map(CustomerOrderItemResponse::from).toList() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java new file mode 100644 index 0000000..ffc6ae1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatusHistory; + +public record CustomerOrderStatusHistoryResponse( + UUID id, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment, + Instant createdAt +) { + + public static CustomerOrderStatusHistoryResponse from(CustomerOrderStatusHistory history) { + return new CustomerOrderStatusHistoryResponse( + history.getId(), + history.getOldStatus(), + history.getNewStatus(), + history.getChangedByUserId(), + history.getComment(), + history.getCreatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java new file mode 100644 index 0000000..d63b8a4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java @@ -0,0 +1,25 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record CustomerOrderWarehouseResponse( + UUID id, + String code, + String name +) { + + public static CustomerOrderWarehouseResponse from(Warehouse warehouse) { + if (warehouse == null) { + return null; + } + + return new CustomerOrderWarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java new file mode 100644 index 0000000..35c32f2 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java @@ -0,0 +1,19 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record UpdateCustomerOrderRequest( + @NotNull UUID customerId, + UUID warehouseId, + LocalDate requestedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java new file mode 100644 index 0000000..f27af64 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java @@ -0,0 +1,44 @@ +package com.example.erpmvp.modules.sales.repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CustomerOrderRepository extends JpaRepository { + + boolean existsByOrderNumber(String orderNumber); + + long countByOrderNumberStartingWith(String orderNumberPrefix); + + Optional findFirstByNotesContaining(String marker); + + @EntityGraph(attributePaths = {"customer", "warehouse"}) + @Query(""" + select co from CustomerOrder co + where (:search is null or :search = '' or lower(co.orderNumber) like lower(concat('%', :search, '%'))) + and (:customerId is null or co.customer.id = :customerId) + and (:warehouseId is null or co.warehouse.id = :warehouseId) + and (:status is null or co.status = :status) + and (:fromDate is null or co.requestedDeliveryDate >= :fromDate) + and (:toDate is null or co.requestedDeliveryDate <= :toDate) + """) + Page search( + @Param("search") String search, + @Param("customerId") UUID customerId, + @Param("warehouseId") UUID warehouseId, + @Param("status") CustomerOrderStatus status, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java new file mode 100644 index 0000000..b2c7015 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.sales.repository; + +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatusHistory; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerOrderStatusHistoryRepository extends JpaRepository { + + List findByCustomerOrder_IdOrderByCreatedAtAsc(UUID customerOrderId); +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K list( + String search, + UUID customerId, + UUID warehouseId, + CustomerOrderStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(customerOrderRepository.search( + normalizeSearch(search), + customerId, + warehouseId, + status, + fromDate, + toDate, + pageable + ).map(CustomerOrderResponse::from)); + } + + @Transactional(readOnly = true) + public CustomerOrderResponse getById(UUID id) { + return CustomerOrderResponse.from(findCustomerOrder(id)); + } + + @Transactional + public CustomerOrderResponse create(CreateCustomerOrderRequest request, User currentUser) { + Customer customer = findActiveCustomer(request.customerId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + + CustomerOrder customerOrder = new CustomerOrder( + generateOrderNumber(), + customer, + warehouse, + request.requestedDeliveryDate(), + request.notes(), + currentUser.getId() + ); + customerOrder.replaceItems(buildItems(request.items())); + + CustomerOrder saved = customerOrderRepository.save(customerOrder); + addHistory(saved, null, CustomerOrderStatus.NEW, currentUser.getId(), "Created"); + + return CustomerOrderResponse.from(saved); + } + + @Transactional + public CustomerOrderResponse update(UUID id, UpdateCustomerOrderRequest request) { + CustomerOrder customerOrder = findCustomerOrder(id); + ensureNew(customerOrder); + + Customer customer = findActiveCustomer(request.customerId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + customerOrder.updateNew(customer, warehouse, request.requestedDeliveryDate(), request.notes()); + customerOrder.replaceItems(buildItems(request.items())); + + return CustomerOrderResponse.from(customerOrder); + } + + @Transactional + public CustomerOrderResponse changeStatus(UUID id, ChangeCustomerOrderStatusRequest request, User currentUser) { + CustomerOrder customerOrder = findCustomerOrder(id); + CustomerOrderStatus oldStatus = customerOrder.getStatus(); + CustomerOrderStatus newStatus = request.status(); + + ensureStatusChangeAllowedForRole(oldStatus, newStatus, currentUser.getRole()); + ensureTransitionAllowed(oldStatus, newStatus); + + if (newStatus == CustomerOrderStatus.SHIPPED) { + ensureShippingWarehouseSelected(customerOrder); + warehouseStockService.shipCustomerOrder(customerOrder, currentUser); + } + + customerOrder.changeStatus(newStatus, currentUser.getId()); + addHistory(customerOrder, oldStatus, newStatus, currentUser.getId(), request.comment()); + + return CustomerOrderResponse.from(customerOrder); + } + + @Transactional + public void delete(UUID id, User currentUser) { + CustomerOrder customerOrder = findCustomerOrder(id); + + if (customerOrder.getStatus() == CustomerOrderStatus.CANCELLED) { + return; + } + + if (customerOrder.getStatus() == CustomerOrderStatus.CLOSED || customerOrder.getStatus() == CustomerOrderStatus.SHIPPED) { + throw new BusinessException("CUSTOMER_ORDER_NOT_CANCELLABLE", "SHIPPED or CLOSED customer orders cannot be cancelled"); + } + + ensureTransitionAllowed(customerOrder.getStatus(), CustomerOrderStatus.CANCELLED); + CustomerOrderStatus oldStatus = customerOrder.getStatus(); + customerOrder.changeStatus(CustomerOrderStatus.CANCELLED, currentUser.getId()); + addHistory(customerOrder, oldStatus, CustomerOrderStatus.CANCELLED, currentUser.getId(), "Cancelled via DELETE"); + } + + @Transactional(readOnly = true) + public List getStatusHistory(UUID id) { + if (!customerOrderRepository.existsById(id)) { + throw new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found"); + } + + return statusHistoryRepository.findByCustomerOrder_IdOrderByCreatedAtAsc(id) + .stream() + .map(CustomerOrderStatusHistoryResponse::from) + .toList(); + } + + private CustomerOrder findCustomerOrder(UUID id) { + return customerOrderRepository.findById(id) + .orElseThrow(() -> new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found")); + } + + private Customer findActiveCustomer(UUID customerId) { + Customer customer = customerRepository.findById(customerId) + .orElseThrow(() -> new NotFoundException("CUSTOMER_NOT_FOUND", "Customer not found")); + + if (!customer.isActive()) { + throw new BusinessException("CUSTOMER_INACTIVE", "Customer is inactive"); + } + + return customer; + } + + private Warehouse findActiveWarehouseOrNull(UUID warehouseId) { + if (warehouseId == null) { + return null; + } + + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + + return product; + } + + private List buildItems(List itemRequests) { + return itemRequests.stream() + .map(item -> new CustomerOrderItem( + findActiveProduct(item.productId()), + item.quantity(), + item.unitPrice() + )) + .toList(); + } + + private void ensureNew(CustomerOrder customerOrder) { + if (customerOrder.getStatus() != CustomerOrderStatus.NEW) { + throw new BusinessException( + "CUSTOMER_ORDER_NOT_EDITABLE", + "Only NEW customer orders can be edited" + ); + } + } + + private void ensureStatusChangeAllowedForRole(CustomerOrderStatus oldStatus, CustomerOrderStatus newStatus, Role role) { + if (role != Role.WAREHOUSE) { + return; + } + + boolean allowed = oldStatus == CustomerOrderStatus.CONFIRMED && newStatus == CustomerOrderStatus.IN_PROGRESS + || oldStatus == CustomerOrderStatus.IN_PROGRESS && newStatus == CustomerOrderStatus.SHIPPED; + + if (!allowed) { + throw new BusinessException( + "STATUS_CHANGE_NOT_ALLOWED", + "WAREHOUSE role can only move CONFIRMED to IN_PROGRESS and IN_PROGRESS to SHIPPED" + ); + } + } + + private void ensureShippingWarehouseSelected(CustomerOrder customerOrder) { + if (customerOrder.getWarehouse() == null) { + throw new BusinessException( + "SHIPPING_WAREHOUSE_REQUIRED", + "Warehouse is required before marking customer order as SHIPPED" + ); + } + } + + private void ensureTransitionAllowed(CustomerOrderStatus oldStatus, CustomerOrderStatus newStatus) { + if (oldStatus == newStatus) { + throw new BusinessException("INVALID_STATUS_TRANSITION", "Customer order is already in status " + newStatus); + } + + boolean allowed = switch (oldStatus) { + case NEW -> newStatus == CustomerOrderStatus.CONFIRMED || newStatus == CustomerOrderStatus.CANCELLED; + case CONFIRMED -> newStatus == CustomerOrderStatus.IN_PROGRESS || newStatus == CustomerOrderStatus.CANCELLED; + case IN_PROGRESS -> newStatus == CustomerOrderStatus.SHIPPED || newStatus == CustomerOrderStatus.CANCELLED; + case SHIPPED -> newStatus == CustomerOrderStatus.CLOSED; + case CLOSED, CANCELLED -> false; + }; + + if (!allowed) { + throw new BusinessException( + "INVALID_STATUS_TRANSITION", + "Invalid status transition from " + oldStatus + " to " + newStatus + ); + } + } + + private void addHistory( + CustomerOrder customerOrder, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + statusHistoryRepository.save(new CustomerOrderStatusHistory( + customerOrder, + oldStatus, + newStatus, + changedByUserId, + comment + )); + } + + private String generateOrderNumber() { + String prefix = "SO-" + LocalDate.now().format(ORDER_DATE_FORMAT) + "-"; + long nextNumber = customerOrderRepository.countByOrderNumberStartingWith(prefix) + 1; + + String orderNumber; + do { + orderNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (customerOrderRepository.existsByOrderNumber(orderNumber)); + + return orderNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller new file mode 100755 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K> listBalances( + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) UUID productId, + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseStockService.listBalances(warehouseId, productId, search, page, size)); + } + + @GetMapping("/stock-movements") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List stock movements") + public ApiResponse> listMovements( + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) UUID productId, + @RequestParam(required = false) StockMovementType movementType, + @RequestParam(required = false) StockMovementSourceType sourceType, + @RequestParam(required = false) UUID sourceId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseStockService.listMovements( + warehouseId, + productId, + movementType, + sourceType, + sourceId, + fromDate, + toDate, + page, + size + )); + } + + @PostMapping("/stock-adjustments") + @PreAuthorize("hasAnyRole('ADMIN', 'WAREHOUSE')") + @Operation(summary = "Create manual stock adjustment") + public ApiResponse manualAdjustment( + @Valid @RequestBody ManualStockAdjustmentRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(warehouseStockService.manualAdjustment(request, currentUser.getUser())); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K { + + @EntityGraph(attributePaths = {"warehouse", "product"}) + Optional findByWarehouse_IdAndProduct_Id(UUID warehouseId, UUID productId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sb from StockBalance sb + where sb.warehouse.id = :warehouseId + and sb.product.id = :productId + """) + Optional findLockedByWarehouseIdAndProductId( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId + ); + + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sb from StockBalance sb + join sb.warehouse w + join sb.product p + where (:warehouseId is null or w.id = :warehouseId) + and (:productId is null or p.id = :productId) + and ( + :search is null + or :search = '' + or lower(w.code) like lower(concat('%', :search, '%')) + or lower(w.name) like lower(concat('%', :search, '%')) + or lower(p.sku) like lower(concat('%', :search, '%')) + or lower(p.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId, + @Param("search") String search, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java new file mode 100644 index 0000000..0fbeee0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java @@ -0,0 +1,51 @@ +package com.example.erpmvp.modules.warehouse.repository; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockMovement; +import com.example.erpmvp.modules.warehouse.domain.StockMovementSourceType; +import com.example.erpmvp.modules.warehouse.domain.StockMovementType; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StockMovementRepository extends JpaRepository { + + boolean existsByMovementNumber(String movementNumber); + + boolean existsBySourceTypeAndWarehouse_IdAndProduct_IdAndComment( + StockMovementSourceType sourceType, + UUID warehouseId, + UUID productId, + String comment + ); + + long countByMovementNumberStartingWith(String movementNumberPrefix); + + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sm from StockMovement sm + where (:warehouseId is null or sm.warehouse.id = :warehouseId) + and (:productId is null or sm.product.id = :productId) + and (:movementType is null or sm.movementType = :movementType) + and (:sourceType is null or sm.sourceType = :sourceType) + and (:sourceId is null or sm.sourceId = :sourceId) + and (:fromDateTime is null or sm.createdAt >= :fromDateTime) + and (:toDateTime is null or sm.createdAt < :toDateTime) + """) + Page search( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId, + @Param("movementType") StockMovementType movementType, + @Param("sourceType") StockMovementSourceType sourceType, + @Param("sourceId") UUID sourceId, + @Param("fromDateTime") Instant fromDateTime, + @Param("toDateTime") Instant toDateTime, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K new NotFoundException("STOCK_BALANCE_NOT_FOUND", "Stock balance not found")); + + return StockBalanceResponse.from(stockBalance); + } + + @Transactional(readOnly = true) + public PageResponseDto listBalances( + UUID warehouseId, + UUID productId, + String search, + int page, + int size + ) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + return PageResponseDto.from(stockBalanceRepository.search( + warehouseId, + productId, + normalizeSearch(search), + pageable + ).map(StockBalanceResponse::from)); + } + + @Transactional(readOnly = true) + public PageResponseDto listMovements( + UUID warehouseId, + UUID productId, + StockMovementType movementType, + StockMovementSourceType sourceType, + UUID sourceId, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(stockMovementRepository.search( + warehouseId, + productId, + movementType, + sourceType, + sourceId, + toStartInstant(fromDate), + toExclusiveEndInstant(toDate), + pageable + ).map(StockMovementResponse::from)); + } + + @Transactional + public void receivePurchaseOrder(PurchaseOrder purchaseOrder, User currentUser) { + Warehouse warehouse = purchaseOrder.getWarehouse(); + + if (warehouse == null) { + throw new BusinessException( + "RECEIVING_WAREHOUSE_REQUIRED", + "Receiving warehouse is required before marking purchase order as RECEIVED" + ); + } + + ensureActiveWarehouse(warehouse); + + for (PurchaseOrderItem item : purchaseOrder.getItems()) { + Product product = item.getProduct(); + ensureActiveProduct(product); + StockBalance balance = getOrCreateLockedBalance(warehouse, product); + applyMovement( + balance, + StockMovementType.INBOUND, + item.getQuantity(), + StockMovementSourceType.PURCHASE_ORDER, + purchaseOrder.getId(), + "Received from purchase order " + purchaseOrder.getPoNumber(), + currentUser.getId() + ); + } + } + + @Transactional + public void shipCustomerOrder(CustomerOrder customerOrder, User currentUser) { + Warehouse warehouse = customerOrder.getWarehouse(); + + if (warehouse == null) { + throw new BusinessException( + "SHIPPING_WAREHOUSE_REQUIRED", + "Warehouse is required before marking customer order as SHIPPED" + ); + } + + ensureActiveWarehouse(warehouse); + + for (CustomerOrderItem item : customerOrder.getItems()) { + Product product = item.getProduct(); + ensureActiveProduct(product); + StockBalance balance = findLockedBalanceOrThrow(warehouse, product); + applyMovement( + balance, + StockMovementType.OUTBOUND, + item.getQuantity(), + StockMovementSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + "Shipped for customer order " + customerOrder.getOrderNumber(), + currentUser.getId() + ); + } + } + + @Transactional + public StockMovementResponse manualAdjustment(ManualStockAdjustmentRequest request, User currentUser) { + ensureManualAdjustmentType(request.type()); + + Warehouse warehouse = findActiveWarehouse(request.warehouseId()); + Product product = findActiveProduct(request.productId()); + StockBalance balance = request.type() == StockMovementType.ADJUSTMENT_IN + ? getOrCreateLockedBalance(warehouse, product) + : findLockedBalanceOrThrow(warehouse, product); + + StockMovement movement = applyMovement( + balance, + request.type(), + request.quantity(), + StockMovementSourceType.MANUAL_ADJUSTMENT, + null, + request.comment(), + currentUser.getId() + ); + + return StockMovementResponse.from(movement); + } + + private StockMovement applyMovement( + StockBalance balance, + StockMovementType movementType, + BigDecimal quantity, + StockMovementSourceType sourceType, + UUID sourceId, + String comment, + UUID createdByUserId + ) { + BigDecimal normalizedQuantity = normalizeQuantity(quantity); + BigDecimal quantityBefore = balance.getQuantityOnHand(); + + if (movementType == StockMovementType.INBOUND || movementType == StockMovementType.ADJUSTMENT_IN) { + balance.increase(normalizedQuantity); + } else { + ensureSufficientStock(balance, normalizedQuantity); + balance.decrease(normalizedQuantity); + } + + StockMovement movement = new StockMovement( + generateMovementNumber(), + balance.getWarehouse(), + balance.getProduct(), + movementType, + normalizedQuantity, + quantityBefore, + balance.getQuantityOnHand(), + sourceType, + sourceId, + comment, + createdByUserId + ); + + return stockMovementRepository.save(movement); + } + + private StockBalance getOrCreateLockedBalance(Warehouse warehouse, Product product) { + return stockBalanceRepository.findLockedByWarehouseIdAndProductId(warehouse.getId(), product.getId()) + .orElseGet(() -> stockBalanceRepository.saveAndFlush(new StockBalance(warehouse, product))); + } + + private StockBalance findLockedBalanceOrThrow(Warehouse warehouse, Product product) { + return stockBalanceRepository.findLockedByWarehouseIdAndProductId(warehouse.getId(), product.getId()) + .orElseThrow(() -> insufficientStock(product, warehouse)); + } + + private Warehouse findActiveWarehouse(UUID warehouseId) { + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + ensureActiveWarehouse(warehouse); + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + ensureActiveProduct(product); + + return product; + } + + private void ensureActiveWarehouse(Warehouse warehouse) { + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + } + + private void ensureActiveProduct(Product product) { + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + } + + private void ensureSufficientStock(StockBalance balance, BigDecimal quantity) { + if (balance.getQuantityOnHand().compareTo(quantity) < 0) { + throw insufficientStock(balance.getProduct(), balance.getWarehouse()); + } + } + + private BusinessException insufficientStock(Product product, Warehouse warehouse) { + return new BusinessException( + "INSUFFICIENT_STOCK", + "Insufficient stock for product " + product.getSku() + " in warehouse " + warehouse.getCode() + ); + } + + private void ensureManualAdjustmentType(StockMovementType type) { + if (type != StockMovementType.ADJUSTMENT_IN && type != StockMovementType.ADJUSTMENT_OUT) { + throw new BadRequestException( + "INVALID_STOCK_ADJUSTMENT_TYPE", + "Manual adjustment type must be ADJUSTMENT_IN or ADJUSTMENT_OUT" + ); + } + } + + private String generateMovementNumber() { + String prefix = "SM-" + LocalDate.now().format(MOVEMENT_DATE_FORMAT) + "-"; + long nextNumber = stockMovementRepository.countByMovementNumberStartingWith(prefix) + 1; + + String movementNumber; + do { + movementNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (stockMovementRepository.existsByMovementNumber(movementNumber)); + + return movementNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private Instant toStartInstant(LocalDate date) { + return date == null ? null : date.atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private Instant toExclusiveEndInstant(LocalDate date) { + return date == null ? null : date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private BigDecimal normalizeQuantity(BigDecimal quantity) { + return quantity.setScale(3, RoundingMode.HALF_UP); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/resources/._application.yml b/backend/src/main/resources/._application.yml new file mode 100644 index 0000000000000000000000000000000000000000..e34c7dbc3b32c8842ff06e87ace06278618f9150 GIT binary patch literal 163 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K$Vqox1Ojhs@R)|o50+1L3ClDI}aUl?c_=|y<2;dkJ5(HHS(lG;wxzV&S oBE&_L^K= 0) +); + +CREATE TABLE IF NOT EXISTS warehouse_stock_movements ( + id UUID PRIMARY KEY, + movement_number VARCHAR(100) NOT NULL UNIQUE, + warehouse_id UUID NOT NULL REFERENCES catalog_warehouses(id), + product_id UUID NOT NULL REFERENCES catalog_products(id), + movement_type VARCHAR(50) NOT NULL, + quantity NUMERIC(19,3) NOT NULL, + quantity_before NUMERIC(19,3) NOT NULL, + quantity_after NUMERIC(19,3) NOT NULL, + source_type VARCHAR(50), + source_id UUID, + comment TEXT, + created_by_user_id UUID REFERENCES app_users(id), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_warehouse_id + ON warehouse_stock_balances (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_product_id + ON warehouse_stock_balances (product_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_warehouse_product + ON warehouse_stock_balances (warehouse_id, product_id); + +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_movement_number + ON warehouse_stock_movements (movement_number); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_warehouse_id + ON warehouse_stock_movements (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_product_id + ON warehouse_stock_movements (product_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_movement_type + ON warehouse_stock_movements (movement_type); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_source + ON warehouse_stock_movements (source_type, source_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_created_at + ON warehouse_stock_movements (created_at); + +ALTER TABLE procurement_purchase_orders + ADD COLUMN IF NOT EXISTS warehouse_id UUID REFERENCES catalog_warehouses(id); + +CREATE INDEX IF NOT EXISTS idx_procurement_po_warehouse_id + ON procurement_purchase_orders (warehouse_id); diff --git a/backend/src/main/resources/db/migration/V8__documents_init.sql b/backend/src/main/resources/db/migration/V8__documents_init.sql new file mode 100644 index 0000000..5eb3fc4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__documents_init.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS erp_documents ( + id UUID PRIMARY KEY, + document_number VARCHAR(100) NOT NULL UNIQUE, + document_type VARCHAR(50) NOT NULL, + source_type VARCHAR(50) NOT NULL, + source_id UUID NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL, + pdf_data BYTEA NOT NULL, + generated_by_user_id UUID REFERENCES app_users(id), + generated_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uq_erp_documents_source_type_source_id_document_type UNIQUE (source_type, source_id, document_type) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_erp_documents_document_number + ON erp_documents (document_number); +CREATE INDEX IF NOT EXISTS idx_erp_documents_document_type + ON erp_documents (document_type); +CREATE INDEX IF NOT EXISTS idx_erp_documents_source + ON erp_documents (source_type, source_id); +CREATE INDEX IF NOT EXISTS idx_erp_documents_generated_at + ON erp_documents (generated_at); diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..2a97593 --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,66 @@ +name: erp-mvp + +services: + postgres: + image: postgres:16-alpine + container_name: erp-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-erp_mvp} + POSTGRES_USER: ${POSTGRES_USER:-erp_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp_password} + volumes: + - erp_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp_user} -d ${POSTGRES_DB:-erp_mvp}"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - erp_internal + + backend: + build: + context: ./backend + container_name: erp-backend + restart: unless-stopped + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-erp_mvp} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-erp_user} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-erp_password} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-https://erp.konturai.kz,http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-dev-secret-change-me} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@erp.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin12345} + DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true} + depends_on: + postgres: + condition: service_healthy + networks: + erp_internal: + common_network: + aliases: + - erp-backend + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.server + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://erp.konturai.kz} + container_name: erp-frontend + restart: unless-stopped + depends_on: + - backend + networks: + common_network: + aliases: + - erp-frontend + +volumes: + erp_postgres_data: + +networks: + erp_internal: + common_network: + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16bc5c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + postgres: + image: postgres:16-alpine + container_name: erp-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-erp_mvp} + POSTGRES_USER: ${POSTGRES_USER:-erp} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp} -d ${POSTGRES_DB:-erp_mvp}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: ./backend + container_name: erp-backend + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-erp_mvp} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-erp} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-erp} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-dev-secret-change-me} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@erp.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin12345} + DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true} + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: erp-frontend + environment: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8080} + ports: + - "5173:5173" + depends_on: + - backend + +volumes: + postgres_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..8ce2dde --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.vite +npm-debug.log + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d7ca890 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.server b/frontend/Dockerfile.server new file mode 100644 index 0000000..0e7d0c4 --- /dev/null +++ b/frontend/Dockerfile.server @@ -0,0 +1,19 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +ARG VITE_API_BASE_URL=https://erp.konturai.kz +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx.server.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0d35036 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ERP MVP + + +
+ + + diff --git a/frontend/nginx.server.conf b/frontend/nginx.server.conf new file mode 100644 index 0000000..d043fd2 --- /dev/null +++ b/frontend/nginx.server.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /health { + access_log off; + return 200 "OK\n"; + add_header Content-Type text/plain; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8f1e28b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2443 @@ +{ + "name": "erp-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "erp-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.16.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "vite": "^8.0.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.354", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", + "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e436dfc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "erp-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "axios": "^1.16.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "vite": "^8.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..1d92651 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..463a26c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,42 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { ProtectedRoute } from './auth/ProtectedRoute'; +import { CustomersPage } from './pages/CustomersPage'; +import { CustomerOrderDetailsPage } from './pages/CustomerOrderDetailsPage'; +import { CustomerOrdersPage } from './pages/CustomerOrdersPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { DocumentsPage } from './pages/DocumentsPage'; +import { LoginPage } from './pages/LoginPage'; +import { PurchaseOrderDetailsPage } from './pages/PurchaseOrderDetailsPage'; +import { PurchaseOrdersPage } from './pages/PurchaseOrdersPage'; +import { ProductsPage } from './pages/ProductsPage'; +import { StockBalancesPage } from './pages/StockBalancesPage'; +import { StockMovementsPage } from './pages/StockMovementsPage'; +import { SuppliersPage } from './pages/SuppliersPage'; +import { WarehousesPage } from './pages/WarehousesPage'; + +function App() { + return ( + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default App; diff --git a/frontend/src/api/catalogApi.ts b/frontend/src/api/catalogApi.ts new file mode 100644 index 0000000..cfb316a --- /dev/null +++ b/frontend/src/api/catalogApi.ts @@ -0,0 +1,140 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type CatalogListParams = { + page?: number; + size?: number; + search?: string; + active?: boolean; +}; + +export type Product = { + id: string; + sku: string; + name: string; + category: string | null; + unit: string; + barcode: string | null; + description: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ProductPayload = { + sku?: string; + name: string; + category?: string; + unit: string; + barcode?: string; + description?: string; +}; + +export type Supplier = { + id: string; + companyName: string; + bin: string | null; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type SupplierPayload = { + companyName: string; + bin?: string; + contactName?: string; + phone?: string; + email?: string; + address?: string; +}; + +export type Customer = Supplier; +export type CustomerPayload = SupplierPayload; + +export type Warehouse = { + id: string; + code: string; + name: string; + address: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type WarehousePayload = { + code?: string; + name: string; + address?: string; +}; + +async function listResource(path: string, params: CatalogListParams) { + const response = await apiClient.get>>(path, { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search ?? '', + active: params.active ?? true, + }, + }); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось загрузить данные справочника'); + } + + return response.data.data; +} + +async function createResource(path: string, payload: TPayload) { + const response = await apiClient.post>(path, payload); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось создать запись'); + } + + return response.data.data; +} + +async function updateResource(path: string, id: string, payload: TPayload) { + const response = await apiClient.put>(`${path}/${id}`, payload); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось обновить запись'); + } + + return response.data.data; +} + +async function deleteResource(path: string, id: string) { + await apiClient.delete(`${path}/${id}`); +} + +export const catalogApi = { + listProducts: (params: CatalogListParams) => listResource('/api/catalog/products', params), + createProduct: (payload: ProductPayload) => createResource('/api/catalog/products', payload), + updateProduct: (id: string, payload: Omit) => + updateResource, Product>('/api/catalog/products', id, payload), + deleteProduct: (id: string) => deleteResource('/api/catalog/products', id), + + listSuppliers: (params: CatalogListParams) => listResource('/api/catalog/suppliers', params), + createSupplier: (payload: SupplierPayload) => createResource('/api/catalog/suppliers', payload), + updateSupplier: (id: string, payload: SupplierPayload) => + updateResource('/api/catalog/suppliers', id, payload), + deleteSupplier: (id: string) => deleteResource('/api/catalog/suppliers', id), + + listCustomers: (params: CatalogListParams) => listResource('/api/catalog/customers', params), + createCustomer: (payload: CustomerPayload) => createResource('/api/catalog/customers', payload), + updateCustomer: (id: string, payload: CustomerPayload) => + updateResource('/api/catalog/customers', id, payload), + deleteCustomer: (id: string) => deleteResource('/api/catalog/customers', id), + + listWarehouses: (params: CatalogListParams) => listResource('/api/catalog/warehouses', params), + createWarehouse: (payload: WarehousePayload) => + createResource('/api/catalog/warehouses', payload), + updateWarehouse: (id: string, payload: Omit) => + updateResource, Warehouse>('/api/catalog/warehouses', id, payload), + deleteWarehouse: (id: string) => deleteResource('/api/catalog/warehouses', id), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ed0b9c4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,34 @@ +import axios from 'axios'; + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; + +export const apiClient = axios.create({ + baseURL: apiBaseUrl, +}); + +let unauthorizedHandler: (() => void) | null = null; + +export function setUnauthorizedHandler(handler: (() => void) | null) { + unauthorizedHandler = handler; +} + +export function setAuthToken(token: string | null) { + if (token) { + apiClient.defaults.headers.common.Authorization = `Bearer ${token}`; + return; + } + + delete apiClient.defaults.headers.common.Authorization; +} + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (axios.isAxiosError(error) && error.response?.status === 401) { + setAuthToken(null); + unauthorizedHandler?.(); + } + + return Promise.reject(error); + }, +); diff --git a/frontend/src/api/dashboardApi.ts b/frontend/src/api/dashboardApi.ts new file mode 100644 index 0000000..d997295 --- /dev/null +++ b/frontend/src/api/dashboardApi.ts @@ -0,0 +1,171 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type StatusAmountMetric = { + status: string; + count: number; + totalAmount: number; +}; + +export type TypeCountMetric = { + type: string; + count: number; +}; + +export type DailyAmountMetric = { + date: string; + ordersCount: number; + totalAmount: number; +}; + +export type TopPartyMetric = { + partyId: string; + companyName: string; + ordersCount: number; + totalAmount: number; +}; + +export type WarehouseStockMetric = { + warehouseId: string; + warehouseCode: string; + warehouseName: string; + productsCount: number; + totalQuantityOnHand: number; +}; + +export type MovementTypeMetric = { + movementType: string; + count: number; + totalQuantity: number; +}; + +export type RecentActivity = { + id: string; + type: 'CUSTOMER_ORDER' | 'PURCHASE_ORDER' | 'STOCK_MOVEMENT' | 'DOCUMENT'; + title: string; + description: string; + createdAt: string; + link: string | null; +}; + +export type LowStockItem = { + warehouseId: string; + warehouseCode: string; + warehouseName: string; + productId: string; + productSku: string; + productName: string; + unit: string; + quantityOnHand: number; + threshold: number; +}; + +export type DashboardSummary = { + productsCount: number; + activeProductsCount: number; + suppliersCount: number; + customersCount: number; + warehousesCount: number; + customerOrdersCount: number; + activeCustomerOrdersCount: number; + totalSalesAmount: number; + salesOrdersByStatus: StatusAmountMetric[]; + purchaseOrdersCount: number; + activePurchaseOrdersCount: number; + totalProcurementAmount: number; + purchaseOrdersByStatus: StatusAmountMetric[]; + stockItemsCount: number; + totalQuantityOnHand: number; + lowStockItemsCount: number; + stockMovementsCount: number; + documentsCount: number; + documentsByType: TypeCountMetric[]; + recentCustomerOrders: RecentActivity[]; + recentPurchaseOrders: RecentActivity[]; + recentStockMovements: RecentActivity[]; + recentDocuments: RecentActivity[]; +}; + +export type DashboardSales = { + totalOrders: number; + totalAmount: number; + averageOrderAmount: number; + ordersByStatus: StatusAmountMetric[]; + dailySales: DailyAmountMetric[]; + topCustomers: TopPartyMetric[]; +}; + +export type DashboardProcurement = { + totalPurchaseOrders: number; + totalAmount: number; + averagePurchaseOrderAmount: number; + purchaseOrdersByStatus: StatusAmountMetric[]; + dailyProcurement: DailyAmountMetric[]; + topSuppliers: TopPartyMetric[]; +}; + +export type DashboardWarehouse = { + stockItemsCount: number; + totalQuantityOnHand: number; + lowStockItemsCount: number; + movementsCount: number; + inboundQuantity: number; + outboundQuantity: number; + adjustmentInQuantity: number; + adjustmentOutQuantity: number; + stockByWarehouse: WarehouseStockMetric[]; + movementsByType: MovementTypeMetric[]; +}; + +export type PeriodParams = { + fromDate?: string; + toDate?: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const dashboardApi = { + async getDashboardSummary() { + const response = await apiClient.get>('/api/dashboard/summary'); + return unwrap(response.data, 'Не удалось загрузить сводку панели управления'); + }, + + async getSalesDashboard(params: PeriodParams = {}) { + const response = await apiClient.get>('/api/dashboard/sales', { + params, + }); + return unwrap(response.data, 'Не удалось загрузить аналитику продаж'); + }, + + async getProcurementDashboard(params: PeriodParams = {}) { + const response = await apiClient.get>('/api/dashboard/procurement', { + params, + }); + return unwrap(response.data, 'Не удалось загрузить аналитику закупок'); + }, + + async getWarehouseDashboard() { + const response = await apiClient.get>('/api/dashboard/warehouse'); + return unwrap(response.data, 'Не удалось загрузить аналитику склада'); + }, + + async getRecentActivities(limit = 20) { + const response = await apiClient.get>('/api/dashboard/recent-activities', { + params: { limit }, + }); + return unwrap(response.data, 'Не удалось загрузить последние события'); + }, + + async getLowStock(threshold = 10, page = 0, size = 20) { + const response = await apiClient.get>>('/api/dashboard/low-stock', { + params: { threshold, page, size }, + }); + return unwrap(response.data, 'Не удалось загрузить низкие остатки'); + }, +}; diff --git a/frontend/src/api/documentsApi.ts b/frontend/src/api/documentsApi.ts new file mode 100644 index 0000000..1730209 --- /dev/null +++ b/frontend/src/api/documentsApi.ts @@ -0,0 +1,106 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type DocumentType = 'INVOICE' | 'CONTRACT' | 'DELIVERY_NOTE'; +export type DocumentSourceType = 'CUSTOMER_ORDER'; +export type DocumentStatus = 'GENERATED'; + +export type DocumentRecord = { + id: string; + documentNumber: string; + documentType: DocumentType; + sourceType: DocumentSourceType; + sourceId: string; + fileName: string; + contentType: string; + fileSize: number; + status: DocumentStatus; + generatedByUserId: string | null; + generatedAt: string; + createdAt: string; + updatedAt: string; +}; + +export type DocumentListParams = { + page?: number; + size?: number; + documentType?: DocumentType | ''; + sourceType?: DocumentSourceType | ''; + sourceId?: string; + status?: DocumentStatus | ''; + fromDate?: string; + toDate?: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +function fileNameFromContentDisposition(value: string | undefined, fallback: string) { + if (!value) { + return fallback; + } + + const match = value.match(/filename="?([^"]+)"?/i); + return match?.[1] || fallback; +} + +export const documentsApi = { + async listDocuments(params: DocumentListParams) { + const response = await apiClient.get>>('/api/documents', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + documentType: params.documentType || undefined, + sourceType: params.sourceType || undefined, + sourceId: params.sourceId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить документы'); + }, + + async getDocument(id: string) { + const response = await apiClient.get>(`/api/documents/${id}`); + return unwrap(response.data, 'Не удалось загрузить документ'); + }, + + async getCustomerOrderDocuments(customerOrderId: string) { + const response = await apiClient.get>(`/api/documents/customer-orders/${customerOrderId}`); + return unwrap(response.data, 'Не удалось загрузить документы заказа'); + }, + + async generateCustomerOrderDocument(customerOrderId: string, documentType: DocumentType) { + const response = await apiClient.post>(`/api/documents/customer-orders/${customerOrderId}/generate`, { + documentType, + }); + return unwrap(response.data, 'Не удалось сформировать документ'); + }, + + async downloadDocument(id: string) { + const response = await apiClient.get(`/api/documents/${id}/download`, { + responseType: 'blob', + }); + + return { + blob: response.data, + fileName: fileNameFromContentDisposition(response.headers['content-disposition'], `document-${id}.pdf`), + }; + }, +}; + +export function saveBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts new file mode 100644 index 0000000..31aad1c --- /dev/null +++ b/frontend/src/api/errors.ts @@ -0,0 +1,50 @@ +import { AxiosError } from 'axios'; + +import type { ApiResponse } from './types'; + +export function getApiErrorMessage(error: unknown, fallback: string) { + if (error instanceof AxiosError) { + const response = error.response?.data as ApiResponse | undefined; + const backendMessage = response?.error?.message; + + if (backendMessage) { + return translateBackendMessage(backendMessage); + } + + if (error.response?.status === 401) { + return 'Сессия истекла. Войдите снова.'; + } + + return fallback; + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +} + +function translateBackendMessage(message: string) { + const exact: Record = { + 'Receiving warehouse is required before marking purchase order as RECEIVED': + 'Перед приемкой закупки нужно выбрать склад поступления.', + 'Warehouse is required before marking customer order as SHIPPED': + 'Перед отгрузкой клиентского заказа нужно выбрать склад.', + 'Cannot generate documents for cancelled order': 'Нельзя формировать документы для отмененного заказа.', + 'Delivery note can be generated only for SHIPPED or CLOSED orders': + 'Накладную можно сформировать только для отгруженного или закрытого заказа.', + }; + + if (exact[message]) { + return exact[message]; + } + + if (message.startsWith('Insufficient stock for product')) { + return message + .replace('Insufficient stock for product', 'Недостаточно остатка по товару') + .replace('in warehouse', 'на складе'); + } + + return message; +} diff --git a/frontend/src/api/procurementApi.ts b/frontend/src/api/procurementApi.ts new file mode 100644 index 0000000..9c97354 --- /dev/null +++ b/frontend/src/api/procurementApi.ts @@ -0,0 +1,143 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type PurchaseOrderStatus = 'DRAFT' | 'APPROVED' | 'ORDERED' | 'RECEIVED' | 'CANCELLED'; + +export type PurchaseOrderListParams = { + page?: number; + size?: number; + search?: string; + supplierId?: string; + status?: PurchaseOrderStatus | ''; + fromDate?: string; + toDate?: string; +}; + +export type PurchaseOrderSupplier = { + id: string; + companyName: string; + bin: string | null; +}; + +export type PurchaseOrderWarehouse = { + id: string; + code: string; + name: string; +} | null; + +export type PurchaseOrderProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type PurchaseOrderItem = { + id: string; + product: PurchaseOrderProduct; + quantity: number; + unitPrice: number; + lineTotal: number; +}; + +export type PurchaseOrder = { + id: string; + poNumber: string; + supplier: PurchaseOrderSupplier; + warehouse: PurchaseOrderWarehouse; + status: PurchaseOrderStatus; + expectedDeliveryDate: string | null; + notes: string | null; + totalAmount: number; + createdByUserId: string | null; + approvedByUserId: string | null; + orderedAt: string | null; + receivedAt: string | null; + cancelledAt: string | null; + createdAt: string; + updatedAt: string; + items: PurchaseOrderItem[]; +}; + +export type PurchaseOrderItemPayload = { + productId: string; + quantity: number; + unitPrice: number; +}; + +export type PurchaseOrderPayload = { + supplierId: string; + warehouseId?: string; + expectedDeliveryDate?: string; + notes?: string; + items: PurchaseOrderItemPayload[]; +}; + +export type PurchaseOrderStatusHistory = { + id: string; + oldStatus: PurchaseOrderStatus | null; + newStatus: PurchaseOrderStatus; + changedByUserId: string | null; + comment: string | null; + createdAt: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const procurementApi = { + async listPurchaseOrders(params: PurchaseOrderListParams) { + const response = await apiClient.get>>('/api/procurement/purchase-orders', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search || undefined, + supplierId: params.supplierId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить закупки'); + }, + + async getPurchaseOrder(id: string) { + const response = await apiClient.get>(`/api/procurement/purchase-orders/${id}`); + return unwrap(response.data, 'Не удалось загрузить закупку'); + }, + + async createPurchaseOrder(payload: PurchaseOrderPayload) { + const response = await apiClient.post>('/api/procurement/purchase-orders', payload); + return unwrap(response.data, 'Не удалось создать закупку'); + }, + + async updatePurchaseOrder(id: string, payload: PurchaseOrderPayload) { + const response = await apiClient.put>(`/api/procurement/purchase-orders/${id}`, payload); + return unwrap(response.data, 'Не удалось обновить закупку'); + }, + + async changePurchaseOrderStatus(id: string, status: PurchaseOrderStatus, comment?: string) { + const response = await apiClient.patch>(`/api/procurement/purchase-orders/${id}/status`, { + status, + comment, + }); + return unwrap(response.data, 'Не удалось изменить статус закупки'); + }, + + async getPurchaseOrderStatusHistory(id: string) { + const response = await apiClient.get>( + `/api/procurement/purchase-orders/${id}/status-history`, + ); + return unwrap(response.data, 'Не удалось загрузить историю статусов'); + }, + + async deletePurchaseOrder(id: string) { + await apiClient.delete(`/api/procurement/purchase-orders/${id}`); + }, +}; diff --git a/frontend/src/api/salesApi.ts b/frontend/src/api/salesApi.ts new file mode 100644 index 0000000..f61d11d --- /dev/null +++ b/frontend/src/api/salesApi.ts @@ -0,0 +1,147 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type CustomerOrderStatus = 'NEW' | 'CONFIRMED' | 'IN_PROGRESS' | 'SHIPPED' | 'CLOSED' | 'CANCELLED'; + +export type CustomerOrderListParams = { + page?: number; + size?: number; + search?: string; + customerId?: string; + warehouseId?: string; + status?: CustomerOrderStatus | ''; + fromDate?: string; + toDate?: string; +}; + +export type CustomerOrderCustomer = { + id: string; + companyName: string; + bin: string | null; +}; + +export type CustomerOrderWarehouse = { + id: string; + code: string; + name: string; +} | null; + +export type CustomerOrderProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type CustomerOrderItem = { + id: string; + product: CustomerOrderProduct; + quantity: number; + unitPrice: number; + lineTotal: number; +}; + +export type CustomerOrder = { + id: string; + orderNumber: string; + customer: CustomerOrderCustomer; + warehouse: CustomerOrderWarehouse; + status: CustomerOrderStatus; + requestedDeliveryDate: string | null; + notes: string | null; + totalAmount: number; + createdByUserId: string | null; + confirmedByUserId: string | null; + confirmedAt: string | null; + inProgressAt: string | null; + shippedAt: string | null; + closedAt: string | null; + cancelledAt: string | null; + createdAt: string; + updatedAt: string; + items: CustomerOrderItem[]; +}; + +export type CustomerOrderItemPayload = { + productId: string; + quantity: number; + unitPrice: number; +}; + +export type CustomerOrderPayload = { + customerId: string; + warehouseId?: string; + requestedDeliveryDate?: string; + notes?: string; + items: CustomerOrderItemPayload[]; +}; + +export type CustomerOrderStatusHistory = { + id: string; + oldStatus: CustomerOrderStatus | null; + newStatus: CustomerOrderStatus; + changedByUserId: string | null; + comment: string | null; + createdAt: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const salesApi = { + async listCustomerOrders(params: CustomerOrderListParams) { + const response = await apiClient.get>>('/api/sales/customer-orders', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search || undefined, + customerId: params.customerId || undefined, + warehouseId: params.warehouseId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить клиентские заказы'); + }, + + async getCustomerOrder(id: string) { + const response = await apiClient.get>(`/api/sales/customer-orders/${id}`); + return unwrap(response.data, 'Не удалось загрузить клиентский заказ'); + }, + + async createCustomerOrder(payload: CustomerOrderPayload) { + const response = await apiClient.post>('/api/sales/customer-orders', payload); + return unwrap(response.data, 'Не удалось создать клиентский заказ'); + }, + + async updateCustomerOrder(id: string, payload: CustomerOrderPayload) { + const response = await apiClient.put>(`/api/sales/customer-orders/${id}`, payload); + return unwrap(response.data, 'Не удалось обновить клиентский заказ'); + }, + + async changeCustomerOrderStatus(id: string, status: CustomerOrderStatus, comment?: string) { + const response = await apiClient.patch>(`/api/sales/customer-orders/${id}/status`, { + status, + comment, + }); + return unwrap(response.data, 'Не удалось изменить статус клиентского заказа'); + }, + + async getCustomerOrderStatusHistory(id: string) { + const response = await apiClient.get>( + `/api/sales/customer-orders/${id}/status-history`, + ); + return unwrap(response.data, 'Не удалось загрузить историю статусов'); + }, + + async deleteCustomerOrder(id: string) { + await apiClient.delete(`/api/sales/customer-orders/${id}`); + }, +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..aaf32f3 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,21 @@ +export type ApiResponse = { + success: boolean; + data: T | null; + error: { + code: string; + message: string; + details: unknown[]; + } | null; + timestamp: string; +}; + +export type PageResponse = { + items: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +}; + diff --git a/frontend/src/api/warehouseApi.ts b/frontend/src/api/warehouseApi.ts new file mode 100644 index 0000000..aecb6e6 --- /dev/null +++ b/frontend/src/api/warehouseApi.ts @@ -0,0 +1,118 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type StockMovementType = 'INBOUND' | 'OUTBOUND' | 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT'; +export type StockMovementSourceType = 'PURCHASE_ORDER' | 'CUSTOMER_ORDER' | 'MANUAL_ADJUSTMENT'; + +export type StockWarehouse = { + id: string; + code: string; + name: string; +}; + +export type StockProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type StockBalance = { + id: string; + warehouse: StockWarehouse; + product: StockProduct; + quantityOnHand: number; + createdAt: string; + updatedAt: string; +}; + +export type StockMovement = { + id: string; + movementNumber: string; + warehouse: StockWarehouse; + product: StockProduct; + movementType: StockMovementType; + quantity: number; + quantityBefore: number; + quantityAfter: number; + sourceType: StockMovementSourceType | null; + sourceId: string | null; + comment: string | null; + createdByUserId: string | null; + createdAt: string; +}; + +export type StockBalanceListParams = { + page?: number; + size?: number; + warehouseId?: string; + productId?: string; + search?: string; +}; + +export type StockMovementListParams = { + page?: number; + size?: number; + warehouseId?: string; + productId?: string; + movementType?: StockMovementType | ''; + sourceType?: StockMovementSourceType | ''; + sourceId?: string; + fromDate?: string; + toDate?: string; +}; + +export type ManualStockAdjustmentPayload = { + warehouseId: string; + productId: string; + type: 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT'; + quantity: number; + comment: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const warehouseApi = { + async listStockBalances(params: StockBalanceListParams) { + const response = await apiClient.get>>('/api/warehouse/stock-balances', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + warehouseId: params.warehouseId || undefined, + productId: params.productId || undefined, + search: params.search || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить остатки'); + }, + + async listStockMovements(params: StockMovementListParams) { + const response = await apiClient.get>>('/api/warehouse/stock-movements', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + warehouseId: params.warehouseId || undefined, + productId: params.productId || undefined, + movementType: params.movementType || undefined, + sourceType: params.sourceType || undefined, + sourceId: params.sourceId || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить движения склада'); + }, + + async manualStockAdjustment(payload: ManualStockAdjustmentPayload) { + const response = await apiClient.post>('/api/warehouse/stock-adjustments', payload); + return unwrap(response.data, 'Не удалось создать корректировку'); + }, +}; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..324d107 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,141 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { apiClient, setAuthToken, setUnauthorizedHandler } from '../api/client'; +import type { ApiResponse } from '../api/types'; + +const TOKEN_STORAGE_KEY = 'erp_mvp_access_token'; + +export type AuthUser = { + id: string; + email: string; + fullName: string; + role: 'ADMIN' | 'MANAGER' | 'WAREHOUSE' | 'FINANCE'; + active?: boolean; +}; + +type LoginResponse = { + accessToken: string; + tokenType: 'Bearer'; + expiresInMinutes: number; + user: AuthUser; +}; + +type AuthContextValue = { + token: string | null; + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + refreshCurrentUser: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_STORAGE_KEY)); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const storeToken = useCallback((nextToken: string | null) => { + setToken(nextToken); + setAuthToken(nextToken); + + if (nextToken) { + localStorage.setItem(TOKEN_STORAGE_KEY, nextToken); + return; + } + + localStorage.removeItem(TOKEN_STORAGE_KEY); + }, []); + + const refreshCurrentUser = useCallback(async () => { + const response = await apiClient.get>('/api/auth/me'); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось загрузить текущего пользователя'); + } + + setUser(response.data.data); + }, []); + + const logout = useCallback(() => { + storeToken(null); + setUser(null); + }, [storeToken]); + + const login = useCallback( + async (email: string, password: string) => { + const response = await apiClient.post>('/api/auth/login', { + email, + password, + }); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось войти'); + } + + storeToken(response.data.data.accessToken); + setUser(response.data.data.user); + }, + [storeToken], + ); + + useEffect(() => { + setUnauthorizedHandler(() => { + storeToken(null); + setUser(null); + navigate('/login', { replace: true }); + }); + + return () => setUnauthorizedHandler(null); + }, [navigate, storeToken]); + + useEffect(() => { + setAuthToken(token); + + if (!token) { + setIsLoading(false); + return; + } + + refreshCurrentUser() + .catch(() => logout()) + .finally(() => setIsLoading(false)); + }, [logout, refreshCurrentUser, token]); + + const value = useMemo( + () => ({ + token, + user, + isAuthenticated: Boolean(token && user), + isLoading, + login, + logout, + refreshCurrentUser, + }), + [isLoading, login, logout, refreshCurrentUser, token, user], + ); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used inside AuthProvider'); + } + + return context; +} diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..ae0a8eb --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; + +import { useAuth } from './AuthContext'; +import { LoadingState } from '../components/ui/LoadingState'; + +export function ProtectedRoute() { + const location = useLocation(); + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/components/CustomerOrderDocuments.tsx b/frontend/src/components/CustomerOrderDocuments.tsx new file mode 100644 index 0000000..9039225 --- /dev/null +++ b/frontend/src/components/CustomerOrderDocuments.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + type DocumentRecord, + type DocumentType, + documentsApi, + saveBlob, +} from '../api/documentsApi'; +import { getApiErrorMessage } from '../api/errors'; +import { useAuth } from '../auth/AuthContext'; +import { formatDateTime, formatDocumentType } from '../utils/formatters'; +import { DataTable, type TableColumn } from './DataTable'; +import { Button } from './ui/Button'; +import { ErrorState } from './ui/ErrorState'; +import { LoadingState } from './ui/LoadingState'; + +type CustomerOrderDocumentsProps = { + customerOrderId: string; +}; + +const documentTypes: Array<{ label: string; value: DocumentType }> = [ + { label: 'Сформировать счет', value: 'INVOICE' }, + { label: 'Сформировать договор', value: 'CONTRACT' }, + { label: 'Сформировать накладную', value: 'DELIVERY_NOTE' }, +]; + +export function CustomerOrderDocuments({ customerOrderId }: CustomerOrderDocumentsProps) { + const { user } = useAuth(); + const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeDocumentType, setActiveDocumentType] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const loadDocuments = useCallback(async () => { + setIsLoading(true); + setError(''); + + try { + const data = await documentsApi.getCustomerOrderDocuments(customerOrderId); + setDocuments(data); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось загрузить документы')); + } finally { + setIsLoading(false); + } + }, [customerOrderId]); + + useEffect(() => { + void loadDocuments(); + }, [loadDocuments]); + + const generateDocument = async (documentType: DocumentType) => { + setActiveDocumentType(documentType); + setError(''); + setSuccess(''); + + try { + const document = await documentsApi.generateCustomerOrderDocument(customerOrderId, documentType); + setSuccess(`${formatDocumentType(document.documentType)} сформирован`); + await loadDocuments(); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось сформировать документ')); + } finally { + setActiveDocumentType(null); + } + }; + + const downloadDocument = async (document: DocumentRecord) => { + setDownloadingId(document.id); + setError(''); + + try { + const { blob, fileName } = await documentsApi.downloadDocument(document.id); + saveBlob(blob, fileName || document.fileName); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось скачать документ')); + } finally { + setDownloadingId(null); + } + }; + + const canGenerate = (documentType: DocumentType) => { + if (!user) { + return false; + } + + if (documentType === 'DELIVERY_NOTE') { + return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'WAREHOUSE'; + } + + return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'FINANCE'; + }; + + const columns: TableColumn[] = [ + { key: 'documentNumber', label: 'Номер документа', render: (document) => document.documentNumber }, + { key: 'documentType', label: 'Тип', render: (document) => formatDocumentType(document.documentType) }, + { key: 'fileName', label: 'Файл', render: (document) => document.fileName }, + { key: 'generatedAt', label: 'Сформирован', render: (document) => formatDateTime(document.generatedAt) }, + ]; + + return ( +
+
+

Документы

+
+ {documentTypes.map((documentType) => + canGenerate(documentType.value) ? ( + + ) : null, + )} +
+
+ + {error && ( +
+ +
+ )} + + {success && ( +

+ {success} +

+ )} + + {isLoading ? ( +
+ +
+ ) : ( +
+ document.id} + emptyTitle="Документы еще не сформированы" + actions={(document) => ( + + )} + /> +
+ )} +
+ ); +} diff --git a/frontend/src/components/CustomerOrderForm.tsx b/frontend/src/components/CustomerOrderForm.tsx new file mode 100644 index 0000000..b5c18aa --- /dev/null +++ b/frontend/src/components/CustomerOrderForm.tsx @@ -0,0 +1,323 @@ +import { type FormEvent, useEffect, useMemo, useState } from 'react'; + +import { catalogApi, type Customer, type Product, type Warehouse } from '../api/catalogApi'; +import { getApiErrorMessage } from '../api/errors'; +import type { CustomerOrder, CustomerOrderPayload } from '../api/salesApi'; +import { formatMoney } from '../utils/formatters'; +import { Button } from './ui/Button'; +import { ErrorState } from './ui/ErrorState'; +import { LoadingState } from './ui/LoadingState'; + +type FormItem = { + productId: string; + quantity: string; + unitPrice: string; +}; + +type CustomerOrderFormProps = { + initialOrder?: CustomerOrder; + submitLabel: string; + onSubmit: (payload: CustomerOrderPayload) => Promise; + onCancel?: () => void; +}; + +export function CustomerOrderForm({ initialOrder, submitLabel, onSubmit, onCancel }: CustomerOrderFormProps) { + const [customers, setCustomers] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [products, setProducts] = useState([]); + const [customerId, setCustomerId] = useState(initialOrder?.customer.id ?? ''); + const [warehouseId, setWarehouseId] = useState(initialOrder?.warehouse?.id ?? ''); + const [requestedDeliveryDate, setRequestedDeliveryDate] = useState(initialOrder?.requestedDeliveryDate ?? ''); + const [notes, setNotes] = useState(initialOrder?.notes ?? ''); + const [items, setItems] = useState( + initialOrder?.items.map((item) => ({ + productId: item.product.id, + quantity: String(item.quantity), + unitPrice: String(item.unitPrice), + })) ?? [{ productId: '', quantity: '1', unitPrice: '0' }], + ); + const [isLoadingCatalog, setIsLoadingCatalog] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + let isMounted = true; + + async function loadCatalogData() { + setIsLoadingCatalog(true); + setError(''); + + try { + const [customerPage, warehousePage, productPage] = await Promise.all([ + catalogApi.listCustomers({ page: 0, size: 100, active: true }), + catalogApi.listWarehouses({ page: 0, size: 100, active: true }), + catalogApi.listProducts({ page: 0, size: 100, active: true }), + ]); + + if (!isMounted) { + return; + } + + setCustomers(customerPage.items); + setWarehouses(warehousePage.items); + setProducts(productPage.items); + setCustomerId((current) => current || customerPage.items[0]?.id || ''); + setItems((current) => + current.map((item, index) => ({ + ...item, + productId: item.productId || (index === 0 ? productPage.items[0]?.id ?? '' : ''), + })), + ); + } catch (caughtError) { + if (isMounted) { + setError(getApiErrorMessage(caughtError, 'Не удалось загрузить клиентов, склады и товары')); + } + } finally { + if (isMounted) { + setIsLoadingCatalog(false); + } + } + } + + void loadCatalogData(); + + return () => { + isMounted = false; + }; + }, []); + + const productById = useMemo(() => new Map(products.map((product) => [product.id, product])), [products]); + const hasCatalogData = customers.length > 0 && products.length > 0; + const totalAmount = items.reduce((sum, item) => sum + lineTotal(item), 0); + + const updateItem = (index: number, patch: Partial) => { + setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item))); + }; + + const addItem = () => { + setItems((current) => [ + ...current, + { + productId: products[0]?.id ?? '', + quantity: '1', + unitPrice: '0', + }, + ]); + }; + + const removeItem = (index: number) => { + setItems((current) => current.filter((_, itemIndex) => itemIndex !== index)); + }; + + const lineTotal = (item: FormItem) => { + const quantity = Number(item.quantity || 0); + const unitPrice = Number(item.unitPrice || 0); + return quantity * unitPrice; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + setError(''); + + try { + await onSubmit({ + customerId, + warehouseId: warehouseId || undefined, + requestedDeliveryDate: requestedDeliveryDate || undefined, + notes: notes.trim() || undefined, + items: items.map((item) => ({ + productId: item.productId, + quantity: Number(item.quantity), + unitPrice: Number(item.unitPrice), + })), + }); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось сохранить клиентский заказ')); + } finally { + setIsSaving(false); + } + }; + + if (isLoadingCatalog) { + return ; + } + + if (!hasCatalogData) { + return ( +
+ Сначала создайте клиентов и товары в справочниках. +
+ ); + } + + return ( +
+
+ + + + + +
+ +