From 0e14a5fa1c7024551d84b41e0ccfde7584bc75f8 Mon Sep 17 00:00:00 2001 From: Wdp-ab <2182606194@qq.com> Date: Wed, 8 Oct 2025 19:15:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=86=E5=A4=87=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/views/parts/Templates.vue | 53 +- backend/.env | 39 +- backend/.env.example | 39 +- backend/.gitignore | 35 + .../.mvn/wrapper/maven-wrapper.properties | 2 + backend/.stage-src-20251004-193018/db/db.sql | 565 +++++++++ backend/.stage-src-20251004-193018/mvnw | 295 +++++ backend/.stage-src-20251004-193018/mvnw.cmd | 189 +++ backend/.stage-src-20251004-193018/pom.xml | 106 ++ .../com/example/demo/DemoApplication.java | 13 + .../demo/account/AccountController.java | 65 + .../com/example/demo/account/AccountDtos.java | 17 + .../example/demo/account/AccountService.java | 126 ++ .../demo/admin/AdminAuthController.java | 23 + .../example/demo/admin/AdminAuthService.java | 84 ++ .../demo/admin/AdminConsultController.java | 117 ++ .../demo/admin/AdminDictController.java | 133 ++ .../demo/admin/AdminNoticeController.java | 158 +++ .../demo/admin/AdminPartController.java | 177 +++ .../demo/admin/AdminUserController.java | 94 ++ .../demo/admin/AdminVipController.java | 114 ++ .../demo/admin/AdminVipPriceController.java | 44 + .../demo/admin/AdminVipSystemController.java | 55 + .../admin/NormalAdminApprovalController.java | 100 ++ .../demo/attachment/AttachmentController.java | 236 ++++ .../AttachmentPlaceholderProperties.java | 35 + .../attachment/AttachmentStorageService.java | 135 ++ .../AttachmentUploadProperties.java | 32 + .../AttachmentUrlValidationProperties.java | 58 + .../attachment/AttachmentUrlValidator.java | 250 ++++ .../demo/auth/EmailAuthController.java | 56 + .../example/demo/auth/EmailAuthService.java | 318 +++++ .../com/example/demo/auth/JwtProperties.java | 24 + .../com/example/demo/auth/JwtService.java | 93 ++ .../demo/auth/NormalAdminApplyController.java | 124 ++ .../demo/auth/PasswordAuthController.java | 21 + .../demo/auth/PasswordAuthService.java | 88 ++ .../example/demo/auth/RegisterController.java | 23 + .../example/demo/auth/RegisterService.java | 167 +++ .../example/demo/auth/SmsAuthController.java | 45 + .../com/example/demo/auth/SmsAuthService.java | 204 +++ .../demo/barcode/BarcodeProxyController.java | 126 ++ .../barcode/PythonBarcodeAutoStarter.java | 42 + .../barcode/PythonBarcodeProcessManager.java | 107 ++ .../demo/barcode/PythonBarcodeProperties.java | 132 ++ .../common/AccountDefaultsProperties.java | 25 + .../demo/common/AdminAuthInterceptor.java | 90 ++ .../demo/common/AppDefaultsProperties.java | 54 + .../com/example/demo/common/CorsConfig.java | 32 + .../demo/common/DefaultSeedService.java | 41 + .../example/demo/common/EmailProperties.java | 19 + .../demo/common/EmailSenderService.java | 57 + .../demo/common/FinanceController.java | 29 + .../common/FinanceDefaultsProperties.java | 21 + .../example/demo/common/FinanceService.java | 129 ++ .../demo/common/GlobalExceptionHandler.java | 53 + .../com/example/demo/common/JsonUtils.java | 39 + .../common/NormalAdminAuthInterceptor.java | 89 ++ .../demo/common/RequestLoggingFilter.java | 63 + .../demo/common/SearchFuzzyProperties.java | 22 + .../demo/common/ShopDefaultsProperties.java | 23 + .../example/demo/common/SystemParameter.java | 48 + .../common/SystemParameterRepository.java | 11 + .../com/example/demo/common/WebConfig.java | 48 + .../demo/consult/ConsultController.java | 150 +++ .../controller/CustomerController.java | 77 ++ .../demo/customer/dto/CustomerDtos.java | 34 + .../demo/customer/entity/Customer.java | 88 ++ .../customer/repo/CustomerRepository.java | 16 + .../customer/service/CustomerService.java | 98 ++ .../demo/dashboard/DashboardController.java | 29 + .../dashboard/DashboardOverviewResponse.java | 36 + .../demo/dashboard/DashboardRepository.java | 76 ++ .../demo/dashboard/DashboardService.java | 39 + .../java/com/example/demo/notice/Notice.java | 72 ++ .../example/demo/notice/NoticeController.java | 26 + .../example/demo/notice/NoticeRepository.java | 17 + .../example/demo/notice/NoticeService.java | 20 + .../com/example/demo/notice/NoticeStatus.java | 12 + .../demo/notice/NoticeStatusConverter.java | 39 + .../example/demo/order/OrderController.java | 151 +++ .../demo/order/OrderNumberGenerator.java | 24 + .../com/example/demo/order/OrderService.java | 690 +++++++++++ .../com/example/demo/order/dto/OrderDtos.java | 55 + .../controller/MetadataController.java | 91 ++ .../NormalAdminSubmissionController.java | 78 ++ .../controller/PartTemplateController.java | 52 + .../product/controller/ProductController.java | 86 ++ .../ProductSubmissionController.java | 119 ++ .../demo/product/dto/PartTemplateDtos.java | 55 + .../example/demo/product/dto/ProductDtos.java | 85 ++ .../product/dto/ProductSubmissionDtos.java | 117 ++ .../demo/product/entity/Inventory.java | 44 + .../demo/product/entity/PartTemplate.java | 57 + .../product/entity/PartTemplateParam.java | 93 ++ .../example/demo/product/entity/Product.java | 123 ++ .../demo/product/entity/ProductCategory.java | 61 + .../ProductEntityImportsPlaceholder.java | 0 .../demo/product/entity/ProductImage.java | 46 + .../demo/product/entity/ProductPrice.java | 59 + .../product/entity/ProductSubmission.java | 166 +++ .../demo/product/entity/ProductUnit.java | 51 + .../demo/product/repo/CategoryRepository.java | 22 + .../product/repo/InventoryRepository.java | 14 + .../repo/PartTemplateParamRepository.java | 12 + .../product/repo/PartTemplateRepository.java | 12 + .../product/repo/ProductImageRepository.java | 18 + .../product/repo/ProductPriceRepository.java | 14 + .../demo/product/repo/ProductRepository.java | 35 + .../repo/ProductSubmissionRepository.java | 42 + .../demo/product/repo/UnitRepository.java | 21 + .../product/service/PartTemplateService.java | 240 ++++ .../demo/product/service/ProductService.java | 443 +++++++ .../service/ProductSubmissionService.java | 519 ++++++++ .../example/demo/report/ReportController.java | 27 + .../example/demo/report/ReportService.java | 189 +++ .../controller/SupplierController.java | 73 ++ .../demo/supplier/dto/SupplierDtos.java | 49 + .../supplier/service/SupplierService.java | 96 ++ .../com/example/demo/user/UserController.java | 64 + .../com/example/demo/user/UserService.java | 136 ++ .../com/example/demo/vip/VipController.java | 143 +++ .../src/main/resources/application.properties | 107 ++ .../txm/2dbb4e14b6b0df047806bef434338058.jpg | Bin 0 -> 123340 bytes .../.stage-src-20251004-193018/txm/README.md | 23 + .../txm/image copy 2.png | Bin 0 -> 238397 bytes .../txm/image copy.png | Bin 0 -> 7773 bytes .../.stage-src-20251004-193018/txm/image.png | Bin 0 -> 29632 bytes .../txm/requirements.txt | 9 + backend/README.md | 0 backend/db/db.sql | Bin 27235 -> 136372 bytes backend/db/init_minimal.sql | 55 + backend/env.example | 63 + backend/scripts/package-backend.ps1 | 33 + .../barcode/PythonBarcodeAutoStarter.java | 13 +- .../barcode/PythonBarcodeProcessManager.java | 40 +- .../demo/barcode/PythonBarcodeProperties.java | 14 + .../example/demo/product/dto/ProductDtos.java | 1 - .../src/main/resources/application.properties | 7 +- .../__pycache__/pyzbar_engine.cpython-311.pyc | Bin 4203 -> 4825 bytes backend/txm/app/pyzbar_engine.py | 28 +- backend/txm/debug_out/txm.log | 274 +---- doc/admin_development.md | 28 - doc/admin_requirements.md | 26 - doc/database_documentation.md | 1092 +++++------------ doc/openapi.yaml | 2 - doc/product_enhancement_requirements.md | 187 --- doc/requirements.md | 176 --- doc/vip_development.md | 331 ----- doc/后端使用文档.md | 139 --- doc/开发进度与问题.md | 35 - doc/数据库设计文档-核心业务表.md | 997 +++++++++++++++ doc/数据库设计文档-辅助配置表.md | 584 +++++++++ doc/模板参数可模糊查询_功能需求文档.md | 127 -- doc/货品删除功能开发文档.md | 175 --- doc/项目开发文档.md | 793 ++++++++++++ frontend/pages/index/index.vue | 48 + frontend/pages/my/index.vue | 9 + frontend/pages/product/list.vue | 31 +- frontend/pages/product/product-detail.vue | 146 ++- frontend/pages/product/submit.vue | 17 +- frontend/static/icons/ro.pem | 27 + .../icons/~$配件审核_1759156818563.xlsx | Bin 165 -> 0 bytes .../.sourcemap/mp-weixin/common/assets.js.map | 2 +- .../mp-weixin/pages/index/index.js.map | 2 +- .../mp-weixin/pages/my/index.js.map | 2 +- .../mp-weixin/pages/product/list.js.map | 2 +- .../pages/product/product-detail.js.map | 2 +- .../mp-weixin/pages/product/submit.js.map | 2 +- .../dist/dev/app-plus/app-service.js | 65 +- .../dist/dev/app-plus/pages/index/index.css | 29 + .../dist/dev/app-plus/pages/product/list.css | 9 +- .../app-plus/pages/product/product-detail.css | 39 +- .../dev/app-plus/pages/product/submit.css | 22 +- .../icons/~$配件审核_1759156818563.xlsx | Bin 165 -> 0 bytes .../dist/dev/mp-weixin/common/assets.js | 12 +- .../dist/dev/mp-weixin/common/vendor.js | 2 +- .../dev/mp-weixin/components/ImageUploader.js | 2 +- .../dist/dev/mp-weixin/pages/index/index.js | 18 +- .../dist/dev/mp-weixin/pages/index/index.wxml | 2 +- .../dist/dev/mp-weixin/pages/index/index.wxss | 29 + .../dist/dev/mp-weixin/pages/my/about.js | 2 +- .../dist/dev/mp-weixin/pages/my/index.js | 11 +- .../dist/dev/mp-weixin/pages/my/vip.js | 2 +- .../dist/dev/mp-weixin/pages/order/create.js | 2 +- .../dist/dev/mp-weixin/pages/product/list.js | 21 +- .../dev/mp-weixin/pages/product/list.wxml | 2 +- .../dev/mp-weixin/pages/product/list.wxss | 9 +- .../pages/product/product-detail.wxss | 39 +- .../dev/mp-weixin/pages/product/submit.wxml | 2 +- .../dev/mp-weixin/pages/product/submit.wxss | 22 +- .../icons/~$配件审核_1759156818563.xlsx | Bin 165 -> 0 bytes 沟通.md | 15 - 193 files changed, 14697 insertions(+), 2461 deletions(-) create mode 100644 backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties create mode 100644 backend/.stage-src-20251004-193018/db/db.sql create mode 100644 backend/.stage-src-20251004-193018/mvnw create mode 100644 backend/.stage-src-20251004-193018/mvnw.cmd create mode 100644 backend/.stage-src-20251004-193018/pom.xml create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/DemoApplication.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminConsultController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminDictController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminNoticeController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminPartController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminUserController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipPriceController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipSystemController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/NormalAdminApprovalController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentStorageService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUploadProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidationProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidator.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/NormalAdminApplyController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/BarcodeProxyController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AccountDefaultsProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AdminAuthInterceptor.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AppDefaultsProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/CorsConfig.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/DefaultSeedService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailSenderService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceDefaultsProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/GlobalExceptionHandler.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/JsonUtils.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/NormalAdminAuthInterceptor.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/RequestLoggingFilter.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SearchFuzzyProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/ShopDefaultsProperties.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameter.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameterRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/WebConfig.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/consult/ConsultController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/controller/CustomerController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/dto/CustomerDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/entity/Customer.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/repo/CustomerRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/service/CustomerService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/Notice.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatus.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatusConverter.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderNumberGenerator.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/dto/OrderDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/MetadataController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/NormalAdminSubmissionController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/PartTemplateController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductSubmissionController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/PartTemplateDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductSubmissionDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Inventory.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplate.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplateParam.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Product.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductCategory.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductEntityImportsPlaceholder.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductImage.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductPrice.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductSubmission.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductUnit.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/CategoryRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/InventoryRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateParamRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductImageRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductSubmissionRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/UnitRepository.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/PartTemplateService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductSubmissionService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/controller/SupplierController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/dto/SupplierDtos.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/service/SupplierService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserService.java create mode 100644 backend/.stage-src-20251004-193018/src/main/java/com/example/demo/vip/VipController.java create mode 100644 backend/.stage-src-20251004-193018/src/main/resources/application.properties create mode 100644 backend/.stage-src-20251004-193018/txm/2dbb4e14b6b0df047806bef434338058.jpg create mode 100644 backend/.stage-src-20251004-193018/txm/README.md create mode 100644 backend/.stage-src-20251004-193018/txm/image copy 2.png create mode 100644 backend/.stage-src-20251004-193018/txm/image copy.png create mode 100644 backend/.stage-src-20251004-193018/txm/image.png create mode 100644 backend/.stage-src-20251004-193018/txm/requirements.txt create mode 100644 backend/README.md create mode 100644 backend/db/init_minimal.sql create mode 100644 backend/env.example create mode 100644 backend/scripts/package-backend.ps1 delete mode 100644 doc/admin_development.md delete mode 100644 doc/admin_requirements.md delete mode 100644 doc/product_enhancement_requirements.md delete mode 100644 doc/requirements.md delete mode 100644 doc/vip_development.md delete mode 100644 doc/后端使用文档.md delete mode 100644 doc/开发进度与问题.md create mode 100644 doc/数据库设计文档-核心业务表.md create mode 100644 doc/数据库设计文档-辅助配置表.md delete mode 100644 doc/模板参数可模糊查询_功能需求文档.md delete mode 100644 doc/货品删除功能开发文档.md create mode 100644 doc/项目开发文档.md create mode 100644 frontend/static/icons/ro.pem delete mode 100644 frontend/static/icons/~$配件审核_1759156818563.xlsx delete mode 100644 frontend/unpackage/dist/dev/app-plus/static/icons/~$配件审核_1759156818563.xlsx delete mode 100644 frontend/unpackage/dist/dev/mp-weixin/static/icons/~$配件审核_1759156818563.xlsx delete mode 100644 沟通.md diff --git a/admin/src/views/parts/Templates.vue b/admin/src/views/parts/Templates.vue index 3de0346..828307f 100644 --- a/admin/src/views/parts/Templates.vue +++ b/admin/src/views/parts/Templates.vue @@ -13,7 +13,7 @@ - + @@ -39,18 +39,22 @@ - + + {{ categories.find(c=>c.id===form.categoryId)?.name || '-' }} - + + {{ form.name || '-' }} - + + {{ form.modelRule || '-' }} - + + {{ form.status===1?'启用':'停用' }} 参数字段
@@ -61,38 +65,53 @@ - + - + - + @@ -255,6 +274,14 @@ function uniqueKey(base: string, currentRow:any){ diff --git a/backend/.env b/backend/.env index dd73acb..14f2e98 100644 --- a/backend/.env +++ b/backend/.env @@ -1,8 +1,33 @@ +# 数据库配置 +DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&connectionCollation=utf8mb4_0900_ai_ci +DB_HOST=mysql.tonaspace.com +DB_PORT=3306 +DB_DATABASE=partsinquiry +DB_USER=dp +DB_PASSWORD=tHT5EcPT5WY6FfcK -WECHAT_MP_APP_ID=wx8c514804683e4be4 -WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899 -WECHAT_MP_TOKEN_CACHE_SECONDS=6900 -JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs= -DB_USER=root -DB_PASSWORD=TONA1234 -DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci \ No newline at end of file +# 163 邮件SMTP配置 +MAIL_HOST=smtp.163.com +MAIL_PORT=465 +MAIL_PROTOCOL=smtps +MAIL_USERNAME=sdssds@163.com +MAIL_PASSWORD=NQLihrab8vGiAjiE +MAIL_FROM=sdssds@163.com +MAIL_SUBJECT_PREFIX=[配件查询] + +# CORS配置 +CORS_ALLOWED_ORIGINS=* + +# Python 条码服务配置(由 Java 自动拉起) +PY_BARCODE_ENABLED=true +PY_BARCODE_WORKDIR=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm +PY_BARCODE_PYTHON=python +PY_BARCODE_APP_MODULE=app.server.main +PY_BARCODE_USE_MODULE=true +PY_BARCODE_HOST=127.0.0.1 +PY_BARCODE_PORT=8000 +PY_BARCODE_TIMEOUT=20 +PY_BARCODE_MAX_UPLOAD_MB=8 + +# 可选:将 Python 输出写入文件 +PY_BARCODE_LOG=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm\debug_out\txm.log \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 25dcdcd..1bad93a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,33 @@ +# 数据库配置 +DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&connectionCollation=utf8mb4_0900_ai_ci +DB_HOST=mysql.tonaspace.com +DB_PORT=3306 +DB_DATABASE=partsinquiry +DB_USER=root +DB_PASSWORD=TONA1234 -WECHAT_MP_APP_ID=wx8c514804683e4be4 -WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899 -WECHAT_MP_TOKEN_CACHE_SECONDS=6900 -JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs= -spring.datasource.username=${DB_USER:root} -spring.datasource.password=${DB_PASSWORD:TONA1234} -spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci} \ No newline at end of file +# 163 邮件SMTP配置 +MAIL_HOST=smtp.163.com +MAIL_PORT=465 +MAIL_PROTOCOL=smtps +MAIL_USERNAME=sdssds@163.com +MAIL_PASSWORD=NQLihrab8vGiAjiE +MAIL_FROM=sdssds@163.com +MAIL_SUBJECT_PREFIX=[配件查询] + +# CORS配置 +CORS_ALLOWED_ORIGINS=* + +# Python 条码服务配置(由 Java 自动拉起) +PY_BARCODE_ENABLED=true +PY_BARCODE_WORKDIR=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm +PY_BARCODE_PYTHON=python +PY_BARCODE_APP_MODULE=app.server.main +PY_BARCODE_USE_MODULE=true +PY_BARCODE_HOST=127.0.0.1 +PY_BARCODE_PORT=8000 +PY_BARCODE_TIMEOUT=20 +PY_BARCODE_MAX_UPLOAD_MB=8 + + + PY_BARCODE_LOG=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm\debug_out\txm.log \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 667aaef..a54867e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,38 @@ +# Build artifacts +target/ +dist/ +*.jar +*.war + +# Environment files (do not commit real secrets) +.env +.env.* +!env.example + +# Logs +logs/ +*.log +run.log + +# OS/IDE +.DS_Store +.idea/ +.vscode/ +*.iml +*.swp + +# Python caches (txm) +**/__pycache__/ +**/*.pyc +txm/venv/ +txm/.venv/ +txm/debug_out/ + +# Data (runtime uploads, keep out of source bundle) +data/ + +# Archives produced by scripts +backend_source_*.zip HELP.md target/ .mvn/wrapper/maven-wrapper.jar diff --git a/backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties b/backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..44f3cf2 --- /dev/null +++ b/backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/backend/.stage-src-20251004-193018/db/db.sql b/backend/.stage-src-20251004-193018/db/db.sql new file mode 100644 index 0000000..333ecff --- /dev/null +++ b/backend/.stage-src-20251004-193018/db/db.sql @@ -0,0 +1,565 @@ +-- ===================================================================== +-- 配件查询 App 数据库结构(MySQL 8.0) +-- 依据:/doc/requirements.md、/doc/functional_spec.md、/doc/architecture.md +-- 注意:不在此文件中创建数据库与用户,请在外部以环境配置完成 +-- 字符集统一为 utf8mb4,排序规则 utf8mb4_0900_ai_ci +-- ===================================================================== + +SET NAMES utf8mb4; +SET time_zone = '+00:00'; +SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; +SET FOREIGN_KEY_CHECKS = 0; + +-- ===================================================================== +-- 基础:租户与用户 +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS shops ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '店铺/租户ID', + name VARCHAR(100) NOT NULL COMMENT '店铺名称', + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_shops_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='店铺/租户'; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', + shop_id BIGINT UNSIGNED NOT NULL COMMENT '所属店铺', + phone VARCHAR(32) NULL COMMENT '手机号', + name VARCHAR(64) NOT NULL COMMENT '姓名', + role VARCHAR(32) NOT NULL DEFAULT 'staff' COMMENT '角色:owner/staff/finance/...', + password_hash VARCHAR(255) NULL COMMENT '密码哈希(若采用短信登录可为空)', + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', + is_owner TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否店主', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_users_shop_phone (shop_id, phone), + KEY idx_users_shop (shop_id), + CONSTRAINT fk_users_shop FOREIGN KEY (shop_id) REFERENCES shops(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户'; + +-- 第三方身份映射(微信) +CREATE TABLE IF NOT EXISTS user_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + provider ENUM('wechat_mp','wechat_app') NOT NULL COMMENT '身份提供方:小程序/APP', + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(64) NULL, + avatar_url VARCHAR(512) NULL, + last_login_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ux_identity_provider_openid (provider, openid), + UNIQUE KEY ux_identity_unionid (unionid), + KEY idx_identity_user (user_id), + KEY idx_identity_shop (shop_id), + CONSTRAINT fk_identity_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方身份映射(微信)'; + +-- 微信会话(小程序/APP 临时会话) +CREATE TABLE IF NOT EXISTS wechat_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider ENUM('wechat_mp','wechat_app') NOT NULL, + openid VARCHAR(64) NOT NULL, + session_key VARCHAR(128) NOT NULL, + expires_at DATETIME NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ux_wechat_session (provider, openid), + KEY idx_wechat_session_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信会话(临时)'; + +CREATE TABLE IF NOT EXISTS system_parameters ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL COMMENT '创建/最后修改人', + `key` VARCHAR(64) NOT NULL COMMENT '参数键', + `value` JSON NOT NULL COMMENT '参数值(JSON)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ux_sysparams_shop_key (shop_id, `key`), + KEY idx_sysparams_shop (shop_id), + CONSTRAINT fk_sysparams_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_sysparams_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统参数(租户级)'; + +-- ===================================================================== +-- 货品域(含价格/库存/图片/别名) +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS product_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + parent_id BIGINT UNSIGNED NULL, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_categories_shop_name (shop_id, name), + KEY idx_categories_shop (shop_id), + KEY idx_categories_parent (parent_id), + CONSTRAINT fk_categories_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES product_categories(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品类别'; + +CREATE TABLE IF NOT EXISTS product_units ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(16) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_units_shop_name (shop_id, name), + KEY idx_units_shop (shop_id), + CONSTRAINT fk_units_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_units_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品单位'; + +CREATE TABLE IF NOT EXISTS global_skus ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(120) NOT NULL COMMENT 'SKU名称', + brand VARCHAR(64) NULL, + model VARCHAR(64) NULL, + spec VARCHAR(128) NULL, + barcode VARCHAR(32) NULL, + unit_id BIGINT UNSIGNED NULL, + tags JSON NULL, + status ENUM('published','offline') NOT NULL DEFAULT 'published', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_global_skus_barcode (barcode), + KEY idx_global_skus_brand_model (brand, model), + CONSTRAINT fk_globalsku_unit FOREIGN KEY (unit_id) REFERENCES product_units(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全局SKU(众包)'; + +CREATE TABLE IF NOT EXISTS products ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + category_id BIGINT UNSIGNED NULL, + -- unit_id 已移除 + brand VARCHAR(64) NULL, + model VARCHAR(64) NULL, + spec VARCHAR(128) NULL, + origin VARCHAR(64) NULL, + barcode VARCHAR(32) NULL, + alias VARCHAR(120) NULL, + description TEXT NULL, + global_sku_id BIGINT UNSIGNED NULL, + safe_min DECIMAL(18,3) NULL, + safe_max DECIMAL(18,3) NULL, + search_text TEXT NULL COMMENT '供全文检索的聚合字段(名称/品牌/型号/规格/别名)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_products_shop_barcode (shop_id, barcode), + KEY idx_products_shop (shop_id), + KEY idx_products_category (category_id), + -- KEY idx_products_unit (unit_id), + FULLTEXT KEY ft_products_search (name, brand, model, spec, search_text), + CONSTRAINT fk_products_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_products_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES product_categories(id), + -- CONSTRAINT fk_products_unit FOREIGN KEY (unit_id) REFERENCES product_units(id), + CONSTRAINT fk_products_globalsku FOREIGN KEY (global_sku_id) REFERENCES global_skus(id), + CONSTRAINT ck_products_safe_range CHECK (safe_min IS NULL OR safe_max IS NULL OR safe_min <= safe_max) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品'; + +CREATE TABLE IF NOT EXISTS product_aliases ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + alias VARCHAR(120) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_product_alias (product_id, alias), + KEY idx_product_alias_product (product_id), + CONSTRAINT fk_alias_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_alias_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_alias_product FOREIGN KEY (product_id) REFERENCES products(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品别名'; + +CREATE TABLE IF NOT EXISTS product_prices ( + product_id BIGINT UNSIGNED NOT NULL, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + purchase_price DECIMAL(18,2) NOT NULL DEFAULT 0, + retail_price DECIMAL(18,2) NOT NULL DEFAULT 0, + distribution_price DECIMAL(18,2) NOT NULL DEFAULT 0, + wholesale_price DECIMAL(18,2) NOT NULL DEFAULT 0, + big_client_price DECIMAL(18,2) NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (product_id), + KEY idx_prices_shop (shop_id), + CONSTRAINT fk_prices_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, + CONSTRAINT fk_prices_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_prices_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT ck_prices_non_negative CHECK ( + purchase_price >= 0 AND retail_price >= 0 AND distribution_price >= 0 AND wholesale_price >= 0 AND big_client_price >= 0 + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品价格(含四列销售价)'; + +CREATE TABLE IF NOT EXISTS inventories ( + product_id BIGINT UNSIGNED NOT NULL, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + quantity DECIMAL(18,3) NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (product_id), + KEY idx_inventories_shop (shop_id), + CONSTRAINT fk_inv_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, + CONSTRAINT fk_inv_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_inv_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT ck_inv_qty_non_negative CHECK (quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存'; + +CREATE TABLE IF NOT EXISTS product_images ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + url VARCHAR(512) NOT NULL, + hash VARCHAR(64) NULL COMMENT '内容哈希(去重)', + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ux_product_image_hash (product_id, hash), + KEY idx_product_images_product (product_id), + CONSTRAINT fk_pimg_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_pimg_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_pimg_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品图片'; + +-- ===================================================================== +-- 往来单位与账户 +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + phone VARCHAR(32) NULL, + price_level ENUM('retail','distribution','wholesale','big_client') NOT NULL DEFAULT 'retail' COMMENT '默认售价列', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_customers_shop (shop_id), + KEY idx_customers_phone (phone), + CONSTRAINT fk_customers_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_customers_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户'; + +CREATE TABLE IF NOT EXISTS suppliers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + phone VARCHAR(32) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_suppliers_shop (shop_id), + KEY idx_suppliers_phone (phone), + CONSTRAINT fk_suppliers_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_suppliers_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商'; + +CREATE TABLE IF NOT EXISTS accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + `type` ENUM('cash','bank','alipay','wechat','other') NOT NULL DEFAULT 'cash', + balance DECIMAL(18,2) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_accounts_shop_name (shop_id, name), + KEY idx_accounts_shop (shop_id), + CONSTRAINT fk_accounts_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='结算账户'; + +-- ===================================================================== +-- 单据域(销售/进货/其他收支/流水) +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS sales_orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL COMMENT '创建人', + customer_id BIGINT UNSIGNED NULL, + order_no VARCHAR(32) NOT NULL, + order_time DATETIME NOT NULL, + status ENUM('draft','approved','returned','void') NOT NULL DEFAULT 'draft', + amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应收合计', + paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已收合计', + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_sales_order_no (shop_id, order_no), + KEY idx_sales_shop_time (shop_id, order_time), + KEY idx_sales_customer (customer_id), + CONSTRAINT fk_sales_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_sales_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_sales_customer FOREIGN KEY (customer_id) REFERENCES customers(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单'; + +CREATE TABLE IF NOT EXISTS sales_order_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + quantity DECIMAL(18,3) NOT NULL, + unit_price DECIMAL(18,2) NOT NULL, + discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '折扣百分比0-100', + amount DECIMAL(18,2) NOT NULL, + PRIMARY KEY (id), + KEY idx_soi_order (order_id), + KEY idx_soi_product (product_id), + CONSTRAINT fk_soi_order FOREIGN KEY (order_id) REFERENCES sales_orders(id) ON DELETE CASCADE, + CONSTRAINT fk_soi_product FOREIGN KEY (product_id) REFERENCES products(id), + CONSTRAINT ck_soi_qty CHECK (quantity > 0), + CONSTRAINT ck_soi_price CHECK (unit_price >= 0), + CONSTRAINT ck_soi_discount CHECK (discount_rate >= 0 AND discount_rate <= 100) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单明细'; + +CREATE TABLE IF NOT EXISTS purchase_orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + supplier_id BIGINT UNSIGNED NULL, + order_no VARCHAR(32) NOT NULL, + order_time DATETIME NOT NULL, + status ENUM('draft','approved','void') NOT NULL DEFAULT 'draft', + amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应付合计', + paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已付合计', + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_purchase_order_no (shop_id, order_no), + KEY idx_purchase_shop_time (shop_id, order_time), + KEY idx_purchase_supplier (supplier_id), + CONSTRAINT fk_purchase_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_purchase_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_purchase_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单'; + +CREATE TABLE IF NOT EXISTS purchase_order_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + quantity DECIMAL(18,3) NOT NULL, + unit_price DECIMAL(18,2) NOT NULL, + amount DECIMAL(18,2) NOT NULL, + PRIMARY KEY (id), + KEY idx_poi_order (order_id), + KEY idx_poi_product (product_id), + CONSTRAINT fk_poi_order FOREIGN KEY (order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE, + CONSTRAINT fk_poi_product FOREIGN KEY (product_id) REFERENCES products(id), + CONSTRAINT ck_poi_qty CHECK (quantity > 0), + CONSTRAINT ck_poi_price CHECK (unit_price >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单明细'; + +CREATE TABLE IF NOT EXISTS payments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + biz_type ENUM('sale','purchase','other') NOT NULL, + biz_id BIGINT UNSIGNED NULL COMMENT '业务表ID:sales_orders/purchase_orders/other_transactions', + account_id BIGINT UNSIGNED NOT NULL, + direction ENUM('in','out') NOT NULL COMMENT '收款/付款', + amount DECIMAL(18,2) NOT NULL, + pay_time DATETIME NOT NULL, + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_payments_shop_time (shop_id, pay_time), + KEY idx_payments_biz (biz_type, biz_id), + CONSTRAINT fk_payments_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_payments_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_payments_account FOREIGN KEY (account_id) REFERENCES accounts(id), + CONSTRAINT ck_payments_amount CHECK (amount > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='收付款记录'; + +CREATE TABLE IF NOT EXISTS other_transactions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + `type` ENUM('income','expense') NOT NULL, + category VARCHAR(64) NOT NULL, + counterparty_type VARCHAR(32) NULL COMMENT 'customer/supplier/other', + counterparty_id BIGINT UNSIGNED NULL, + account_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(18,2) NOT NULL, + tx_time DATETIME NOT NULL, + remark VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_ot_shop_time (shop_id, tx_time), + KEY idx_ot_account (account_id), + CONSTRAINT fk_ot_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_ot_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_ot_account FOREIGN KEY (account_id) REFERENCES accounts(id), + CONSTRAINT ck_ot_amount CHECK (amount > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='其他收入/支出'; + +-- ===================================================================== +-- 配件查询与审核、附件 +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS part_submissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + model_unique VARCHAR(128) NOT NULL COMMENT '型号(唯一)', + brand VARCHAR(64) NULL, + spec VARCHAR(128) NULL, + size VARCHAR(64) NULL, + aperture VARCHAR(64) NULL, + compatible TEXT NULL COMMENT '适配信息', + status ENUM('draft','pending','rejected','published') NOT NULL DEFAULT 'pending', + reason VARCHAR(255) NULL COMMENT '驳回原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY ux_part_model_unique (model_unique), + KEY idx_part_submissions_shop (shop_id), + CONSTRAINT fk_part_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_part_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='配件数据提交(审核)'; + +CREATE TABLE IF NOT EXISTS attachments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + shop_id BIGINT UNSIGNED NULL COMMENT '全局资源可空,本地资源属于租户', + user_id BIGINT UNSIGNED NULL, + owner_type VARCHAR(32) NOT NULL COMMENT '资源归属类型:product/part_submission/global_sku/...', + owner_id BIGINT UNSIGNED NOT NULL, + url VARCHAR(512) NOT NULL, + hash VARCHAR(64) NULL, + meta JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY ux_attachments_hash (hash), + KEY idx_attachments_owner (owner_type, owner_id), + CONSTRAINT fk_att_shop FOREIGN KEY (shop_id) REFERENCES shops(id), + CONSTRAINT fk_att_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='通用附件(图片等)'; + +-- ===================================================================== +-- 触发器:维护 products.search_text 聚合字段 +-- ===================================================================== + +DROP TRIGGER IF EXISTS trg_products_ai; +DELIMITER $$ +CREATE TRIGGER trg_products_ai AFTER INSERT ON products +FOR EACH ROW +BEGIN + UPDATE products + SET search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec) + WHERE id = NEW.id; +END $$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_products_au; +DELIMITER $$ +CREATE TRIGGER trg_products_au BEFORE UPDATE ON products +FOR EACH ROW +BEGIN + SET NEW.search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec); +END $$ +DELIMITER ; + +-- 当别名变化时重建 search_text(名称/品牌/型号/规格 + 所有别名) +DROP TRIGGER IF EXISTS trg_palias_ai; +DELIMITER $$ +CREATE TRIGGER trg_palias_ai AFTER INSERT ON product_aliases +FOR EACH ROW +BEGIN + UPDATE products p + JOIN ( + SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases + FROM product_aliases pa + WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL + GROUP BY pa.product_id + ) a ON a.product_id = p.id + SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases) + WHERE p.id = NEW.product_id; +END $$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_palias_au; +DELIMITER $$ +CREATE TRIGGER trg_palias_au AFTER UPDATE ON product_aliases +FOR EACH ROW +BEGIN + UPDATE products p + JOIN ( + SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases + FROM product_aliases pa + WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL + GROUP BY pa.product_id + ) a ON a.product_id = p.id + SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases) + WHERE p.id = NEW.product_id; +END $$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_palias_ad; +DELIMITER $$ +CREATE TRIGGER trg_palias_ad AFTER DELETE ON product_aliases +FOR EACH ROW +BEGIN + UPDATE products p + LEFT JOIN ( + SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases + FROM product_aliases pa + WHERE pa.product_id = OLD.product_id AND pa.deleted_at IS NULL + GROUP BY pa.product_id + ) a ON a.product_id = p.id + SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, COALESCE(a.aliases, '')) + WHERE p.id = OLD.product_id; +END $$ +DELIMITER ; + +SET FOREIGN_KEY_CHECKS = 1; + + diff --git a/backend/.stage-src-20251004-193018/mvnw b/backend/.stage-src-20251004-193018/mvnw new file mode 100644 index 0000000..e9cf8d3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.3 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/backend/.stage-src-20251004-193018/mvnw.cmd b/backend/.stage-src-20251004-193018/mvnw.cmd new file mode 100644 index 0000000..2e2dbe0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.3 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/backend/.stage-src-20251004-193018/pom.xml b/backend/.stage-src-20251004-193018/pom.xml new file mode 100644 index 0000000..9cfc045 --- /dev/null +++ b/backend/.stage-src-20251004-193018/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.5 + + + com.example + demo + 0.0.1-SNAPSHOT + demo + Demo project for Spring Boot + + + + + + + + + + + + + + + 17 + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.apache.poi + poi-ooxml + 5.2.5 + + + + + com.auth0 + java-jwt + 4.4.0 + + + + + org.springframework.security + spring-security-crypto + 6.3.4 + + + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/DemoApplication.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/DemoApplication.java new file mode 100644 index 0000000..64b538a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/DemoApplication.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountController.java new file mode 100644 index 0000000..83c0ad6 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountController.java @@ -0,0 +1,65 @@ +package com.example.demo.account; + +import com.example.demo.common.AppDefaultsProperties; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/accounts") +public class AccountController { + + private final AccountService accountService; + private final AppDefaultsProperties defaults; + + public AccountController(AccountService accountService, AppDefaultsProperties defaults) { + this.accountService = accountService; + this.defaults = defaults; + } + + @GetMapping + public ResponseEntity list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "status", required = false) Integer status, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "50") int size) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(accountService.list(sid, kw, status == null ? 1 : status, Math.max(0, page - 1), size)); + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody AccountDtos.CreateAccountRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + Long id = accountService.create(sid, uid, req); + java.util.Map body = new java.util.HashMap<>(); + body.put("id", id); + return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody AccountDtos.CreateAccountRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + accountService.update(id, sid, uid, req); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{id}/ledger") + public ResponseEntity ledger(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(accountService.ledger(id, sid, kw, Math.max(0, page-1), size, startDate, endDate)); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountDtos.java new file mode 100644 index 0000000..82b678b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountDtos.java @@ -0,0 +1,17 @@ +package com.example.demo.account; + +import java.math.BigDecimal; + +public class AccountDtos { + + public static class CreateAccountRequest { + public String name; + public String type; // cash, bank, alipay, wechat, other + public String bankName; + public String bankAccount; + public BigDecimal openingBalance; // 可选,创建时作为期初 + public Integer status; // 1 启用 0 停用 + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountService.java new file mode 100644 index 0000000..6363cbd --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/account/AccountService.java @@ -0,0 +1,126 @@ +package com.example.demo.account; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class AccountService { + + private final JdbcTemplate jdbcTemplate; + + public AccountService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Object list(Long shopId, String kw, Integer status, int page, int size) { + StringBuilder sql = new StringBuilder("SELECT id, name, type, bank_name, bank_account, balance, status FROM accounts WHERE shop_id=?"); + java.util.List ps = new java.util.ArrayList<>(); ps.add(shopId); + if (status != null) { sql.append(" AND status=?"); ps.add(status); } + if (kw != null && !kw.isBlank()) { sql.append(" AND (name LIKE ? OR bank_name LIKE ? OR bank_account LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); } + sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size); + List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + Map body = new HashMap<>(); + body.put("list", list); + return body; + } + + @Transactional + public Long create(Long shopId, Long userId, AccountDtos.CreateAccountRequest req) { + if (req == null || req.name == null || req.name.isBlank()) throw new IllegalArgumentException("账户名称必填"); + String type = (req.type == null || req.type.isBlank()) ? "cash" : req.type.toLowerCase(); + int status = req.status == null ? 1 : req.status; + jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,bank_name,bank_account,balance,status,created_at,updated_at) VALUES (?,?,?,?,?,?,0,?,NOW(),NOW())", + shopId, userId, req.name, type, req.bankName, req.bankAccount, status); + Long id = jdbcTemplate.queryForObject("SELECT id FROM accounts WHERE shop_id=? AND name=? ORDER BY id DESC LIMIT 1", Long.class, shopId, req.name); + + BigDecimal opening = req.openingBalance == null ? BigDecimal.ZERO : req.openingBalance.setScale(2, java.math.RoundingMode.HALF_UP); + if (opening.compareTo(BigDecimal.ZERO) != 0) { + java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis()); + // other_transactions + String otType = opening.compareTo(BigDecimal.ZERO) > 0 ? "income" : "expense"; + BigDecimal amt = opening.abs(); + jdbcTemplate.update("INSERT INTO other_transactions (shop_id,user_id,type,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())", + shopId, userId, otType, "account_operation", null, null, id, amt, now, "期初余额"); + // payments + String direction = opening.compareTo(BigDecimal.ZERO) > 0 ? "in" : "out"; + jdbcTemplate.update("INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())", + shopId, userId, "other", null, id, direction, amt, now, "期初余额"); + // update balance + BigDecimal delta = opening; + jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", delta, id, shopId); + } + + return id; + } + + @Transactional + public void update(Long id, Long shopId, Long userId, AccountDtos.CreateAccountRequest req) { + StringBuilder sql = new StringBuilder("UPDATE accounts SET updated_at=NOW()"); + java.util.List ps = new java.util.ArrayList<>(); + if (req.name != null) { sql.append(", name=?"); ps.add(req.name); } + if (req.type != null) { sql.append(", type=?"); ps.add(req.type.toLowerCase()); } + if (req.bankName != null) { sql.append(", bank_name=?"); ps.add(req.bankName); } + if (req.bankAccount != null) { sql.append(", bank_account=?"); ps.add(req.bankAccount); } + if (req.status != null) { sql.append(", status=?"); ps.add(req.status); } + sql.append(" WHERE id=? AND shop_id=?"); ps.add(id); ps.add(shopId); + jdbcTemplate.update(sql.toString(), ps.toArray()); + } + + public Map ledger(Long accountId, Long shopId, String kw, int page, int size, String startDate, String endDate) { + // 汇总 + String baseCond = " shop_id=? AND account_id=?"; + java.util.List basePs = new java.util.ArrayList<>(); basePs.add(shopId); basePs.add(accountId); + java.util.function.BiFunction, java.math.BigDecimal> sum = (sql, ps) -> { + java.math.BigDecimal v = jdbcTemplate.queryForObject(sql, java.math.BigDecimal.class, ps.toArray()); + return v == null ? java.math.BigDecimal.ZERO : v; + }; + String dateStart = (startDate == null || startDate.isBlank()) ? null : startDate; + String dateEnd = (endDate == null || endDate.isBlank()) ? null : endDate; + + // opening = 截止开始日期前净额(仅 payments) + String payOpenSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE -amount END),0) FROM payments WHERE" + baseCond + (dateStart==null?"":" AND pay_time payOpenPs = new java.util.ArrayList<>(basePs); + if (dateStart!=null) payOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); + java.math.BigDecimal opening = sum.apply(payOpenSql, payOpenPs); + + // 区间收入/支出(仅 payments) + String payRangeSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN direction='out' THEN amount ELSE 0 END),0) FROM payments WHERE" + baseCond + + (dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?"); + java.util.List payRangePs = new java.util.ArrayList<>(basePs); + if (dateStart!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); + if (dateEnd!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59")); + java.util.Map pr = jdbcTemplate.queryForMap(payRangeSql, payRangePs.toArray()); + java.math.BigDecimal payIn = (java.math.BigDecimal) pr.values().toArray()[0]; + java.math.BigDecimal payOut = (java.math.BigDecimal) pr.values().toArray()[1]; + + java.math.BigDecimal income = payIn; + java.math.BigDecimal expense = payOut; + java.math.BigDecimal ending = opening.add(income).subtract(expense); + + // 明细列表(仅 payments,按时间倒序) + String listSql = "SELECT id, biz_type AS src, pay_time AS tx_time, direction, amount, remark, biz_id, category FROM payments WHERE" + baseCond + + (dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?") + + " ORDER BY tx_time DESC LIMIT ? OFFSET ?"; + java.util.List lp = new java.util.ArrayList<>(basePs); + if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); } + if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + lp.add(size); lp.add(page * size); + List> list = jdbcTemplate.queryForList(listSql, lp.toArray()); + + Map resp = new HashMap<>(); + resp.put("opening", opening); + resp.put("income", income); + resp.put("expense", expense); + resp.put("ending", ending); + resp.put("list", list); + return resp; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthController.java new file mode 100644 index 0000000..6883ca8 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthController.java @@ -0,0 +1,23 @@ +package com.example.demo.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/auth") +public class AdminAuthController { + + private final AdminAuthService adminAuthService; + + public AdminAuthController(AdminAuthService adminAuthService) { + this.adminAuthService = adminAuthService; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody AdminAuthService.LoginRequest req) { + var resp = adminAuthService.login(req); + return ResponseEntity.ok(resp); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthService.java new file mode 100644 index 0000000..903698a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminAuthService.java @@ -0,0 +1,84 @@ +package com.example.demo.admin; + +import com.example.demo.auth.JwtProperties; +import com.example.demo.auth.JwtService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class AdminAuthService { + + private final JdbcTemplate jdbcTemplate; + private final JwtService jwtService; + private final JwtProperties jwtProps; + + public AdminAuthService(JdbcTemplate jdbcTemplate, + JwtService jwtService, + JwtProperties jwtProps) { + this.jdbcTemplate = jdbcTemplate; + this.jwtService = jwtService; + this.jwtProps = jwtProps; + } + + public static class LoginRequest { public String username; public String phone; public String password; } + public static class LoginResponse { public String token; public long expiresIn; public Map admin; } + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest req) { + String keyTmp = null; + String valTmp = null; + if (req.username != null && !req.username.isBlank()) { keyTmp = "username"; valTmp = req.username.trim(); } + else if (req.phone != null && !req.phone.isBlank()) { keyTmp = "phone"; valTmp = req.phone.trim(); } + if (keyTmp == null) throw new IllegalArgumentException("用户名或手机号不能为空"); + if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空"); + final String loginKey = keyTmp; + final String loginVal = valTmp; + + Map row = jdbcTemplate.query( + con -> { + var ps = con.prepareStatement("SELECT id, username, phone, password_hash, status FROM admins WHERE "+loginKey+"=? LIMIT 1"); + ps.setString(1, loginVal); + return ps; + }, + rs -> { + if (rs.next()) { + Map m = new HashMap<>(); + m.put("id", rs.getLong(1)); + m.put("username", rs.getString(2)); + m.put("phone", rs.getString(3)); + m.put("password_hash", rs.getString(4)); + m.put("status", rs.getInt(5)); + return m; + } + return null; + } + ); + if (row == null) throw new IllegalArgumentException("管理员不存在"); + int status = ((Number)row.get("status")).intValue(); + if (status != 1) throw new IllegalArgumentException("管理员未启用"); + String hash = (String) row.get("password_hash"); + if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD"); + boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash); + if (!ok) throw new IllegalArgumentException("密码错误"); + + Long adminId = ((Number)row.get("id")).longValue(); + String username = (String) row.get("username"); + + String token = jwtService.signAdminToken(adminId, username); + LoginResponse out = new LoginResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + Map admin = new HashMap<>(); + admin.put("adminId", adminId); + admin.put("username", username); + admin.put("phone", row.get("phone")); + out.admin = admin; + return out; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminConsultController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminConsultController.java new file mode 100644 index 0000000..5364d96 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminConsultController.java @@ -0,0 +1,117 @@ +package com.example.demo.admin; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import com.example.demo.common.AppDefaultsProperties; + +@RestController +@RequestMapping("/api/admin/consults") +public class AdminConsultController { + + private final JdbcTemplate jdbcTemplate; + private final AppDefaultsProperties defaults; + + public AdminConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) { + this.jdbcTemplate = jdbcTemplate; + this.defaults = defaults; + } + + @GetMapping + public ResponseEntity list(@RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, page - 1) * Math.max(1, size); + StringBuilder sql = new StringBuilder( + "SELECT c.id,c.user_id AS userId,c.shop_id AS shopId,s.name AS shopName,c.message,c.status,c.created_at," + + "cr.content AS replyContent, cr.created_at AS replyAt " + + "FROM consults c JOIN shops s ON s.id=c.shop_id LEFT JOIN consult_replies cr ON cr.consult_id=c.id WHERE 1=1"); + List ps = new ArrayList<>(); + if (shopId != null) { sql.append(" AND c.shop_id=?"); ps.add(shopId); } + if (status != null && !status.isBlank()) { sql.append(" AND c.status=?"); ps.add(status); } + if (kw != null && !kw.isBlank()) { + sql.append(" AND (c.topic LIKE ? OR c.message LIKE ?)"); String like = "%"+kw.trim()+"%"; ps.add(like); ps.add(like); + } + sql.append(" ORDER BY c.id DESC LIMIT ").append(offset).append(", ").append(size); + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("userId", rs.getLong("userId")); + m.put("shopId", rs.getLong("shopId")); + m.put("shopName", rs.getString("shopName")); + m.put("message", rs.getString("message")); + m.put("status", rs.getString("status")); + m.put("createdAt", rs.getTimestamp("created_at")); + m.put("replyContent", rs.getString("replyContent")); + m.put("replyAt", rs.getTimestamp("replyAt")); + return m; + }); + Map body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body); + } + + @PostMapping("/{id}/reply") + public ResponseEntity reply(@PathVariable("id") Long id, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody Map body) { + String content = body == null ? null : String.valueOf(body.get("content")); + if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","content required")); + // 单条只允许一条回复:唯一索引兜底,这里先查避免 500 + Integer exists = jdbcTemplate.query("SELECT 1 FROM consult_replies WHERE consult_id=? LIMIT 1", ps -> ps.setLong(1, id), rs -> rs.next() ? 1 : 0); + if (Objects.equals(exists, 1)) { + return ResponseEntity.status(409).body(Map.of("message", "该咨询已被回复")); + } + Long uid = (userId != null ? userId : (adminId != null ? adminId : defaults.getUserId())); + jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, uid, content); + // 自动判定为已解决 + jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok(java.util.Map.of("status", "resolved")); + } + + @PutMapping("/{id}/resolve") + public ResponseEntity resolve(@PathVariable("id") Long id) { + jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok().build(); + } + + // New: Get full history of a user's consults with replies for admin view + @GetMapping("/users/{userId}/history") + public ResponseEntity userHistory(@PathVariable("userId") Long userId, + @RequestParam(name = "shopId", required = false) Long shopId) { + StringBuilder sql = new StringBuilder( + "SELECT id, topic, message, status, created_at FROM consults WHERE user_id=?"); + List ps = new ArrayList<>(); + ps.add(userId); + if (shopId != null) { sql.append(" AND shop_id=?"); ps.add(shopId); } + sql.append(" ORDER BY id DESC"); + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + Long cid = rs.getLong("id"); + m.put("id", cid); + m.put("topic", rs.getString("topic")); + m.put("message", rs.getString("message")); + m.put("status", rs.getString("status")); + m.put("createdAt", rs.getTimestamp("created_at")); + List> replies = jdbcTemplate.query( + "SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC", + (rs2, j) -> { + Map r = new LinkedHashMap<>(); + r.put("id", rs2.getLong("id")); + r.put("userId", rs2.getLong("userId")); + r.put("content", rs2.getString("content")); + r.put("createdAt", rs2.getTimestamp("created_at")); + return r; + }, cid); + m.put("replies", replies); + return m; + }); + Map body = new HashMap<>(); + body.put("list", list); + return ResponseEntity.ok(body); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminDictController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminDictController.java new file mode 100644 index 0000000..ae0a5c0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminDictController.java @@ -0,0 +1,133 @@ +package com.example.demo.admin; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.product.entity.ProductCategory; +import com.example.demo.product.entity.ProductUnit; +import com.example.demo.product.repo.CategoryRepository; +import com.example.demo.product.repo.UnitRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/dicts") +public class AdminDictController { + + private final JdbcTemplate jdbcTemplate; + private final UnitRepository unitRepository; + private final CategoryRepository categoryRepository; + private final AppDefaultsProperties defaults; + + public AdminDictController(JdbcTemplate jdbcTemplate, UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) { + this.jdbcTemplate = jdbcTemplate; + this.unitRepository = unitRepository; + this.categoryRepository = categoryRepository; + this.defaults = defaults; + } + + // 管理员校验已由拦截器基于 admins 表统一处理 + + // ===== Units ===== + @PostMapping("/units") + @Transactional + public ResponseEntity createUnit(@RequestBody Map body) { + String name = body == null ? null : (String) body.get("name"); + if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required")); + Long sid = defaults.getDictShopId(); + if (unitRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在")); + LocalDateTime now = LocalDateTime.now(); + ProductUnit u = new ProductUnit(); + u.setShopId(sid); + u.setUserId(defaults.getUserId()); + u.setName(name.trim()); + u.setCreatedAt(now); + u.setUpdatedAt(now); + unitRepository.save(u); + return ResponseEntity.ok(Map.of("id", u.getId())); + } + + @PutMapping("/units/{id}") + @Transactional + public ResponseEntity updateUnit(@PathVariable("id") Long id, @RequestBody Map body) { + String name = body == null ? null : (String) body.get("name"); + if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required")); + ProductUnit u = unitRepository.findById(id).orElse(null); + if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found")); + if (!u.getName().equals(name.trim()) && unitRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim())) + return ResponseEntity.badRequest().body(Map.of("message","名称已存在")); + u.setUserId(defaults.getUserId()); + u.setName(name.trim()); + u.setUpdatedAt(LocalDateTime.now()); + unitRepository.save(u); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/units/{id}") + @Transactional + public ResponseEntity deleteUnit(@PathVariable("id") Long id) { + ProductUnit u = unitRepository.findById(id).orElse(null); + if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found")); + // 按新方案:移除对 products.unit_id 的引用校验(该字段已移除) + unitRepository.deleteById(id); + return ResponseEntity.ok().build(); + } + + // ===== Categories ===== + @PostMapping("/categories") + @Transactional + public ResponseEntity createCategory(@RequestBody Map body) { + String name = body == null ? null : (String) body.get("name"); + if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required")); + Long sid = defaults.getDictShopId(); + if (categoryRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在")); + LocalDateTime now = LocalDateTime.now(); + ProductCategory c = new ProductCategory(); + c.setShopId(sid); + c.setUserId(defaults.getUserId()); + c.setName(name.trim()); + c.setSortOrder(0); + c.setCreatedAt(now); + c.setUpdatedAt(now); + categoryRepository.save(c); + return ResponseEntity.ok(Map.of("id", c.getId())); + } + + @PutMapping("/categories/{id}") + @Transactional + public ResponseEntity updateCategory(@PathVariable("id") Long id, @RequestBody Map body) { + String name = body == null ? null : (String) body.get("name"); + if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required")); + ProductCategory c = categoryRepository.findById(id).orElse(null); + if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found")); + if (!c.getName().equals(name.trim()) && categoryRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim())) + return ResponseEntity.badRequest().body(Map.of("message","名称已存在")); + c.setUserId(defaults.getUserId()); + c.setName(name.trim()); + c.setUpdatedAt(LocalDateTime.now()); + categoryRepository.save(c); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/categories/{id}") + @Transactional + public ResponseEntity deleteCategory(@PathVariable("id") Long id) { + ProductCategory c = categoryRepository.findById(id).orElse(null); + if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found")); + // 平台管理员二次确认可在拦截器或前端完成;此处执行软删级联 + // 1) 软删分类 + jdbcTemplate.update("UPDATE product_categories SET deleted_at=NOW(), updated_at=NOW() WHERE id=? AND deleted_at IS NULL", id); + // 2) 软删分类下模板(使用 deleted_at 统一标记) + jdbcTemplate.update("UPDATE part_templates SET deleted_at=NOW(), updated_at=NOW() WHERE category_id=? AND (deleted_at IS NULL)", id); + // 3) 软删该分类下的所有商品:包括通过模板创建的与直接挂分类的 + jdbcTemplate.update("UPDATE products SET deleted_at=NOW(), updated_at=NOW() WHERE (category_id=? OR template_id IN (SELECT id FROM part_templates WHERE category_id=?)) AND deleted_at IS NULL", id, id); + // 4) 软删该分类下的所有配件提交:包含直接指向分类的与指向该分类下模板的 + jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW(), updated_at=NOW() WHERE (category_id=? OR template_id IN (SELECT id FROM part_templates WHERE category_id=?)) AND deleted_at IS NULL", id, id); + return ResponseEntity.ok().build(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminNoticeController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminNoticeController.java new file mode 100644 index 0000000..06c9358 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminNoticeController.java @@ -0,0 +1,158 @@ +package com.example.demo.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@RestController +@RequestMapping("/api/admin/notices") +public class AdminNoticeController { + + private final JdbcTemplate jdbcTemplate; + + public AdminNoticeController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping + public ResponseEntity list(@RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int limit = Math.max(1, size); + int offset = Math.max(0, page - 1) * limit; + + StringBuilder sql = new StringBuilder("SELECT id,title,content,tag,is_pinned AS pinned,starts_at,ends_at,status,created_at,updated_at FROM notices WHERE deleted_at IS NULL"); + List ps = new ArrayList<>(); + if (status != null && !status.isBlank()) { + sql.append(" AND status=?"); + ps.add(status.trim()); + } + if (kw != null && !kw.isBlank()) { + sql.append(" AND (title LIKE ? OR content LIKE ?)"); + String like = "%" + kw.trim() + "%"; + ps.add(like); ps.add(like); + } + sql.append(" ORDER BY is_pinned DESC, created_at DESC LIMIT ? OFFSET ?"); + ps.add(limit); + ps.add(offset); + + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("title", rs.getString("title")); + m.put("content", rs.getString("content")); + m.put("tag", rs.getString("tag")); + m.put("pinned", rs.getBoolean("pinned")); + m.put("startsAt", rs.getTimestamp("starts_at")); + m.put("endsAt", rs.getTimestamp("ends_at")); + m.put("status", rs.getString("status")); + m.put("createdAt", rs.getTimestamp("created_at")); + m.put("updatedAt", rs.getTimestamp("updated_at")); + return m; + }); + Map body = new HashMap<>(); + body.put("list", list); + return ResponseEntity.ok(body); + } + + @PostMapping + public ResponseEntity create(@RequestBody Map body) { + String title = optString(body.get("title")); + String content = optString(body.get("content")); + if (title == null || title.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "title required")); + if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "content required")); + + String tag = optString(body.get("tag")); + Boolean pinned = optBoolean(body.get("pinned")); + String status = sanitizeStatus(optString(body.get("status"))); // draft|published|offline + Timestamp startsAt = parseDateTime(optString(body.get("startsAt"))); + Timestamp endsAt = parseDateTime(optString(body.get("endsAt"))); + + jdbcTemplate.update( + "INSERT INTO notices (title,content,tag,is_pinned,starts_at,ends_at,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,NOW(),NOW())", + title, content, tag, (pinned != null && pinned) ? 1 : 0, startsAt, endsAt, (status == null ? "draft" : status) + ); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, @RequestBody Map body) { + List sets = new ArrayList<>(); + List ps = new ArrayList<>(); + + String title = optString(body.get("title")); + String content = optString(body.get("content")); + String tag = optString(body.get("tag")); + Boolean pinned = optBoolean(body.get("pinned")); + String status = sanitizeStatus(optString(body.get("status"))); + Timestamp startsAt = parseDateTime(optString(body.get("startsAt"))); + Timestamp endsAt = parseDateTime(optString(body.get("endsAt"))); + + if (title != null) { sets.add("title=?"); ps.add(title); } + if (content != null) { sets.add("content=?"); ps.add(content); } + if (tag != null) { sets.add("tag=?"); ps.add(tag); } + if (pinned != null) { sets.add("is_pinned=?"); ps.add(pinned ? 1 : 0); } + if (status != null) { sets.add("status=?"); ps.add(status); } + if (body.containsKey("startsAt")) { sets.add("starts_at=?"); ps.add(startsAt); } + if (body.containsKey("endsAt")) { sets.add("ends_at=?"); ps.add(endsAt); } + + if (sets.isEmpty()) return ResponseEntity.ok().build(); + String sql = "UPDATE notices SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?"; + ps.add(id); + jdbcTemplate.update(sql, ps.toArray()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable("id") Long id) { + jdbcTemplate.update("UPDATE notices SET status='published', updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/offline") + public ResponseEntity offline(@PathVariable("id") Long id) { + jdbcTemplate.update("UPDATE notices SET status='offline', updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok().build(); + } + + private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); } + private static Boolean optBoolean(Object v) { + if (v == null) return null; + String s = String.valueOf(v); + return ("1".equals(s) || "true".equalsIgnoreCase(s)); + } + private static Timestamp parseDateTime(String s) { + if (s == null || s.isBlank()) return null; + try { + // ISO-8601 with zone + OffsetDateTime odt = OffsetDateTime.parse(s); + return Timestamp.from(odt.toInstant()); + } catch (Exception ignore) { } + try { + // ISO-8601 local date-time + LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ISO_DATE_TIME); + return Timestamp.valueOf(ldt); + } catch (Exception ignore) { } + try { + // Fallback: yyyy-MM-dd HH:mm:ss + LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + return Timestamp.valueOf(ldt); + } catch (Exception ignore) { } + return null; + } + private static String sanitizeStatus(String s) { + if (s == null) return null; + String v = s.trim().toLowerCase(Locale.ROOT); + if ("draft".equals(v) || "published".equals(v) || "offline".equals(v)) return v; + return null; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminPartController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminPartController.java new file mode 100644 index 0000000..6803922 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminPartController.java @@ -0,0 +1,177 @@ +package com.example.demo.admin; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/admin/parts") +public class AdminPartController { + + private final JdbcTemplate jdbcTemplate; + + public AdminPartController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping + public ResponseEntity list(@RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, page - 1) * Math.max(1, size); + StringBuilder sql = new StringBuilder( + "SELECT p.id,p.user_id AS userId,p.shop_id AS shopId,s.name AS shopName,p.name,p.brand,p.model,p.spec,p.is_blacklisted, " + + "p.template_id AS templateId, t.name AS templateName, p.attributes_json AS attributesJson " + + "FROM products p JOIN shops s ON s.id=p.shop_id LEFT JOIN part_templates t ON t.id=p.template_id WHERE p.deleted_at IS NULL"); + List ps = new ArrayList<>(); + if (shopId != null) { sql.append(" AND p.shop_id=?"); ps.add(shopId); } + if (kw != null && !kw.isBlank()) { + sql.append(" AND (p.name LIKE ? OR p.brand LIKE ? OR p.model LIKE ? OR p.spec LIKE ?)"); + String like = "%" + kw.trim() + "%"; + ps.add(like); ps.add(like); ps.add(like); ps.add(like); + } + // 为兼容已有前端查询参数,保留 status 入参但不再基于黑名单做过滤(忽略非数字值) + if (status != null && !status.isBlank()) { + try { + Integer.parseInt(status); // no-op, keep compatibility + } catch (NumberFormatException ignore) { } + } + sql.append(" ORDER BY p.id DESC LIMIT ").append(offset).append(", ").append(size); + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("userId", rs.getLong("userId")); + m.put("shopId", rs.getLong("shopId")); + m.put("shopName", rs.getString("shopName")); + m.put("name", rs.getString("name")); + m.put("brand", rs.getString("brand")); + m.put("model", rs.getString("model")); + m.put("spec", rs.getString("spec")); + Object tid = rs.getObject("templateId"); + if (tid != null) m.put("templateId", tid); + m.put("templateName", rs.getString("templateName")); + m.put("attributesJson", rs.getString("attributesJson")); + return m; + }); + // 附加每个商品的图片列表 + if (!list.isEmpty()) { + List ids = new ArrayList<>(); + for (Map m : list) { + Object v = m.get("id"); + if (v instanceof Number) ids.add(((Number) v).longValue()); + } + if (!ids.isEmpty()) { + StringBuilder in = new StringBuilder(); + for (int i = 0; i < ids.size(); i++) { if (i>0) in.append(','); in.append('?'); } + List> imgRows = jdbcTemplate.query( + "SELECT product_id AS productId, url FROM product_images WHERE product_id IN (" + in + ") ORDER BY sort_order ASC, id ASC", + ids.toArray(), + (rs, i) -> { + Map m = new HashMap<>(); + m.put("productId", rs.getLong("productId")); + m.put("url", rs.getString("url")); + return m; + } + ); + Map> map = new HashMap<>(); + for (Map r : imgRows) { + Long pid = ((Number) r.get("productId")).longValue(); + String url = String.valueOf(r.get("url")); + map.computeIfAbsent(pid, k -> new ArrayList<>()).add(url); + } + for (Map m : list) { + Long pid = ((Number) m.get("id")).longValue(); + List imgs = map.get(pid); + m.put("images", imgs == null ? Collections.emptyList() : imgs); + } + } + } + Map body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + @Transactional + public ResponseEntity update(@PathVariable("id") Long id, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody Map body) { + // 校验商品是否存在,并取出 shopId + List> prodRows = jdbcTemplate.query( + "SELECT id, shop_id FROM products WHERE id=?", + new Object[]{id}, + (rs, i) -> { + Map m = new HashMap<>(); + m.put("id", rs.getLong(1)); + m.put("shopId", rs.getLong(2)); + return m; + } + ); + if (prodRows.isEmpty()) return ResponseEntity.notFound().build(); + Long shopId = ((Number) prodRows.get(0).get("shopId")).longValue(); + + String brand = optString(body.get("brand")); + String model = optString(body.get("model")); + String spec = optString(body.get("spec")); + List images = optStringList(body.get("images")); + + // 更新 products 基本字段(可选) + List sets = new ArrayList<>(); + List ps = new ArrayList<>(); + if (brand != null) { sets.add("brand=?"); ps.add(brand); } + if (model != null) { sets.add("model=?"); ps.add(model); } + if (spec != null) { sets.add("spec=?"); ps.add(spec); } + if (!sets.isEmpty()) { + String sql = "UPDATE products SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?"; + ps.add(id); + jdbcTemplate.update(sql, ps.toArray()); + } + + // 覆盖式更新图片(提供 images 列表时) + if (images != null) { + if (adminId == null) throw new IllegalArgumentException("X-Admin-Id 不能为空"); + jdbcTemplate.update("DELETE FROM product_images WHERE product_id= ?", id); + if (!images.isEmpty()) { + List batch = new ArrayList<>(); + int sort = 0; + for (String url : images) { + if (url == null || url.isBlank()) continue; + // 管理员为平台操作,不属于店铺用户;使用默认用户ID(1)满足非空外键 + batch.add(new Object[]{shopId, 1L, id, url.trim(), sort++}); + } + if (!batch.isEmpty()) { + jdbcTemplate.batchUpdate( + "INSERT INTO product_images (shop_id, user_id, product_id, url, sort_order, created_at) VALUES (?,?,?,?,?,NOW())", + batch + ); + } + } + } + + return ResponseEntity.ok().build(); + } + + private static String optString(Object v) { return v == null ? null : String.valueOf(v); } + + @SuppressWarnings("unchecked") + private static List optStringList(Object v) { + if (v == null) return null; + if (v instanceof List) { + List src = (List) v; + List out = new ArrayList<>(); + for (Object o : src) { if (o != null) out.add(String.valueOf(o)); } + return out; + } + // 兼容逗号分隔字符串 + String s = String.valueOf(v); + if (s.isBlank()) return new ArrayList<>(); + String[] arr = s.split(","); + List out = new ArrayList<>(); + for (String x : arr) { String t = x.trim(); if (!t.isEmpty()) out.add(t); } + return out; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminUserController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminUserController.java new file mode 100644 index 0000000..f6f2c4d --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminUserController.java @@ -0,0 +1,94 @@ +package com.example.demo.admin; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/admin/users") +public class AdminUserController { + + private final JdbcTemplate jdbcTemplate; + + public AdminUserController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping + public ResponseEntity list(@RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, page - 1) * Math.max(1, size); + StringBuilder sql = new StringBuilder( + "SELECT u.id,u.name,u.phone,u.role,u.status,u.is_owner AS isOwner,u.shop_id AS shopId,s.name AS shopName " + + "FROM users u JOIN shops s ON s.id=u.shop_id WHERE 1=1"); + List ps = new ArrayList<>(); + if (shopId != null) { sql.append(" AND u.shop_id=?"); ps.add(shopId); } + if (kw != null && !kw.isBlank()) { + sql.append(" AND (u.name LIKE ? OR u.phone LIKE ? OR u.role LIKE ?)"); + String like = "%" + kw.trim() + "%"; + ps.add(like); ps.add(like); ps.add(like); + } + sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); + ps.add(size); ps.add(offset); + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("name", rs.getString("name")); + m.put("phone", rs.getString("phone")); + m.put("role", rs.getString("role")); + m.put("status", rs.getInt("status")); + m.put("isOwner", rs.getBoolean("isOwner")); + m.put("shopId", rs.getLong("shopId")); + m.put("shopName", rs.getString("shopName")); + return m; + }); + Map body = new HashMap<>(); + body.put("list", list); + return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestBody Map body) { + // 仅允许更新以下字段 + String name = optString(body.get("name")); + String phone = optString(body.get("phone")); + String role = optString(body.get("role")); + Integer status = optInteger(body.get("status")); + Boolean isOwner = optBoolean(body.get("isOwner")); + + List sets = new ArrayList<>(); + List ps = new ArrayList<>(); + if (name != null) { sets.add("name=?"); ps.add(name); } + if (phone != null) { sets.add("phone=?"); ps.add(phone); } + if (role != null) { sets.add("role=?"); ps.add(role); } + if (status != null) { sets.add("status=?"); ps.add(status); } + if (isOwner != null) { sets.add("is_owner=?"); ps.add(isOwner ? 1 : 0); } + if (sets.isEmpty()) return ResponseEntity.ok().build(); + String sql = "UPDATE users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?"; + ps.add(id); + jdbcTemplate.update(sql, ps.toArray()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{id}/blacklist") + public ResponseEntity blacklist(@PathVariable("id") Long id) { + jdbcTemplate.update("UPDATE users SET status=0, updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{id}/restore") + public ResponseEntity restore(@PathVariable("id") Long id) { + jdbcTemplate.update("UPDATE users SET status=1, updated_at=NOW() WHERE id=?", id); + return ResponseEntity.ok().build(); + } + + private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); } + private static Integer optInteger(Object v) { try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } } + private static Boolean optBoolean(Object v) { if (v==null) return null; String s=String.valueOf(v); return ("1".equals(s) || "true".equalsIgnoreCase(s)); } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipController.java new file mode 100644 index 0000000..d3bb042 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipController.java @@ -0,0 +1,114 @@ +package com.example.demo.admin; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/admin/vips") +public class AdminVipController { + + private final JdbcTemplate jdbcTemplate; + + public AdminVipController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping + public ResponseEntity list(@RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "phone", required = false) String phone, + @RequestParam(name = "status", required = false) Integer status, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, page - 1) * Math.max(1, size); + StringBuilder sql = new StringBuilder( + "SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt,v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " + + "FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE 1=1"); + List ps = new ArrayList<>(); + if (shopId != null) { sql.append(" AND v.shop_id=?"); ps.add(shopId); } + if (phone != null && !phone.isBlank()) { sql.append(" AND u.phone LIKE ?"); ps.add("%"+phone.trim()+"%"); } + if (status != null) { sql.append(" AND v.status = ?"); ps.add(status); } + sql.append(" ORDER BY v.id DESC LIMIT ").append(offset).append(", ").append(size); + List> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("userId", rs.getLong("userId")); + m.put("isVip", rs.getInt("isVip")); + m.put("status", rs.getInt("status")); + m.put("expireAt", rs.getTimestamp("expireAt")); + m.put("shopId", rs.getLong("shopId")); + m.put("shopName", rs.getString("shopName")); + m.put("name", rs.getString("name")); + m.put("phone", rs.getString("phone")); + return m; + }); + return ResponseEntity.ok(Map.of("list", list)); + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-User-Id") Long userId, + @RequestBody Map body) { + Long sid = asLong(body.get("shopId")); + if (sid == null) return ResponseEntity.badRequest().body(Map.of("message","shopId required")); + Long uid = asLong(body.get("userId")); if (uid == null) return ResponseEntity.badRequest().body(Map.of("message","userId required")); + Integer isVip = asIntOr(body.get("isVip"), 1); + java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt")); + String remark = str(body.get("remark")); + jdbcTemplate.update("INSERT INTO vip_users (shop_id,user_id,is_vip,status,expire_at,remark,created_at,updated_at) VALUES (?,?,?,?,?, ?,NOW(),NOW())", + sid, uid, isVip, 0, expireAt, remark); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestBody Map body) { + List sets = new ArrayList<>(); List ps = new ArrayList<>(); + Integer isVip = asInt(body.get("isVip")); if (isVip != null) { sets.add("is_vip=?"); ps.add(isVip); } + Integer status = asInt(body.get("status")); if (status != null) { sets.add("status=?"); ps.add(status); } + java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt")); if (expireAt != null) { sets.add("expire_at=?"); ps.add(expireAt); } + String remark = str(body.get("remark")); if (remark != null) { sets.add("remark=?"); ps.add(remark); } + if (sets.isEmpty()) return ResponseEntity.ok().build(); + String sql = "UPDATE vip_users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?"; + ps.add(id); + jdbcTemplate.update(sql, ps.toArray()); + return ResponseEntity.ok().build(); + } + + @GetMapping("/user/{userId}") + public ResponseEntity getByUser(@PathVariable("userId") Long userId) { + String sql = "SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt, v.created_at AS createdAt, v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " + + "FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE v.user_id=? ORDER BY v.id DESC LIMIT 1"; + java.util.List> list = jdbcTemplate.query(sql, (rs) -> { + java.util.List> rows = new java.util.ArrayList<>(); + while (rs.next()) { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("userId", rs.getLong("userId")); + m.put("isVip", rs.getInt("isVip")); + m.put("status", rs.getInt("status")); + m.put("expireAt", rs.getTimestamp("expireAt")); + m.put("createdAt", rs.getTimestamp("createdAt")); + m.put("shopId", rs.getLong("shopId")); + m.put("shopName", rs.getString("shopName")); + m.put("name", rs.getString("name")); + m.put("phone", rs.getString("phone")); + rows.add(m); + } + return rows; + }, userId); + if (list == null || list.isEmpty()) return ResponseEntity.ok(java.util.Map.of()); + return ResponseEntity.ok(list.get(0)); + } + + private static String str(Object v){ return v==null?null:String.valueOf(v); } + private static Integer asInt(Object v){ try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } } + private static Integer asIntOr(Object v, int d){ Integer i = asInt(v); return i==null?d:i; } + private static Long asLong(Object v){ try { return v==null?null:Long.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } } + private static java.sql.Timestamp asTimestamp(Object v){ + if (v == null) return null; + try { return java.sql.Timestamp.valueOf(String.valueOf(v).replace('T',' ').replace('Z',' ')); } catch(Exception e){ return null; } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipPriceController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipPriceController.java new file mode 100644 index 0000000..1f3136d --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipPriceController.java @@ -0,0 +1,44 @@ +package com.example.demo.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/vip/price") +public class AdminVipPriceController { + + private final JdbcTemplate jdbcTemplate; + + public AdminVipPriceController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping + public ResponseEntity getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) { + Double price = 0d; + try { + price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d); + } catch (Exception ignored) {} + return ResponseEntity.ok(Map.of("price", price)); + } + + @PutMapping + public ResponseEntity setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId, + @RequestBody Map body) { + Double price = asDouble(body.get("price")); + if (price == null) return ResponseEntity.badRequest().body(Map.of("message", "price required")); + // 单记录表:清空后插入 + jdbcTemplate.update("DELETE FROM vip_price"); + jdbcTemplate.update("INSERT INTO vip_price (price) VALUES (?)", price); + return ResponseEntity.ok().build(); + } + + private static Double asDouble(Object v) { + try { return v == null ? null : Double.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipSystemController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipSystemController.java new file mode 100644 index 0000000..dc2f74e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/AdminVipSystemController.java @@ -0,0 +1,55 @@ +package com.example.demo.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/vip/system") +public class AdminVipSystemController { + + private final JdbcTemplate jdbcTemplate; + + public AdminVipSystemController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping("/price") + public ResponseEntity getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) { + Double price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d); + return ResponseEntity.ok(Map.of("price", price)); + } + + @PutMapping("/price") + public ResponseEntity setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId, @RequestBody Map body) { + Double price = asDouble(body.get("price")); + if (price == null) return ResponseEntity.badRequest().body(Map.of("message","price required")); + jdbcTemplate.update("DELETE FROM vip_price"); + jdbcTemplate.update("INSERT INTO vip_price(price) VALUES(?)", price); + return ResponseEntity.ok().build(); + } + + @GetMapping("/recharges") + public ResponseEntity listRecharges(@RequestHeader(name = "X-Admin-Id") Long adminId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, page - 1) * Math.max(1, size); + StringBuilder sql = new StringBuilder("SELECT r.id,r.shop_id AS shopId,s.name AS shopName,r.user_id AS userId,u.name,u.phone,r.price,r.duration_days AS durationDays,r.expire_from AS expireFrom,r.expire_to AS expireTo,r.channel,r.created_at AS createdAt FROM vip_recharges r LEFT JOIN users u ON u.id=r.user_id LEFT JOIN shops s ON s.id=r.shop_id WHERE 1=1"); + java.util.List ps = new java.util.ArrayList<>(); + if (kw != null && !kw.isBlank()) { sql.append(" AND (u.phone LIKE ? OR u.name LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); } + sql.append(" ORDER BY r.id DESC LIMIT ").append(size).append(" OFFSET ").append(offset); + List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + Map resp = new HashMap<>(); + resp.put("list", list); + return ResponseEntity.ok(resp); + } + + private static Double asDouble(Object v) { try { return v==null?null:Double.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/NormalAdminApprovalController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/NormalAdminApprovalController.java new file mode 100644 index 0000000..ad45390 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/admin/NormalAdminApprovalController.java @@ -0,0 +1,100 @@ +package com.example.demo.admin; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/admin/normal-admin") +public class NormalAdminApprovalController { + + private final JdbcTemplate jdbc; + + public NormalAdminApprovalController(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @GetMapping("/applications") + public ResponseEntity list(@RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int offset = Math.max(0, (page - 1) * size); + String base = "SELECT a.id,a.shop_id AS shopId,a.user_id AS userId,u.name,u.email,u.phone,a.remark,a.created_at AS createdAt " + + "FROM normal_admin_audits a JOIN users u ON u.id=a.user_id WHERE a.action='apply' AND NOT EXISTS (" + + "SELECT 1 FROM normal_admin_audits x WHERE x.user_id=a.user_id AND x.created_at>a.created_at AND x.action IN ('approve','reject'))"; + List ps = new ArrayList<>(); + if (kw != null && !kw.isBlank()) { + base += " AND (u.name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)"; + String like = "%" + kw.trim() + "%"; + ps.add(like); ps.add(like); ps.add(like); + } + String pageSql = base + " ORDER BY a.created_at DESC LIMIT ? OFFSET ?"; + ps.add(size); ps.add(offset); + List> list = jdbc.query(pageSql, ps.toArray(), (rs, i) -> { + Map m = new HashMap<>(); + m.put("id", rs.getLong("id")); + m.put("shopId", rs.getLong("shopId")); + m.put("userId", rs.getLong("userId")); + m.put("name", rs.getString("name")); + m.put("email", rs.getString("email")); + m.put("phone", rs.getString("phone")); + m.put("remark", rs.getString("remark")); + m.put("createdAt", rs.getTimestamp("createdAt")); + return m; + }); + // 简化:total 暂以当前页大小代替(可扩展 count) + return ResponseEntity.ok(Map.of("list", list, "total", list.size())); + } + + @PostMapping("/applications/{userId}/approve") + public ResponseEntity approve(@PathVariable("userId") long userId, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody(required = false) Map body) { + // 记录 previous_role + final String prev = jdbc.query("SELECT role FROM users WHERE id=? LIMIT 1", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null); + if (prev == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found")); + jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, "normal_admin"); ps.setLong(2, userId); }); + jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " + + "SELECT u.shop_id, u.id, 'approve', ?, ?, ?, ?, NOW() FROM users u WHERE u.id=?", + ps -> { ps.setString(1, body != null ? Objects.toString(body.get("remark"), null) : null); + if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId); + ps.setString(3, prev); ps.setString(4, "normal_admin"); ps.setLong(5, userId);} ); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/applications/{userId}/reject") + public ResponseEntity reject(@PathVariable("userId") long userId, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody Map body) { + String remark = body == null ? null : Objects.toString(body.get("remark"), null); + jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,created_at) " + + "SELECT u.shop_id, u.id, 'reject', ?, ?, NOW() FROM users u WHERE u.id=?", + ps -> { ps.setString(1, remark); if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId); ps.setLong(3, userId);} ); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/users/{userId}/revoke") + public ResponseEntity revoke(@PathVariable("userId") long userId, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody(required = false) Map body) { + // 找到最近一次 approve 的 previous_role + final String prev = jdbc.query("SELECT previous_role FROM normal_admin_audits WHERE user_id=? AND action='approve' ORDER BY created_at DESC LIMIT 1", + ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null); + String finalPrev = prev; + if (finalPrev == null || finalPrev.isBlank()) { + // fallback:根据是否店主回退 + Boolean owner = jdbc.query("SELECT is_owner FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getBoolean(1) : false); + finalPrev = (owner != null && owner) ? "owner" : "staff"; + } + String prevRoleForAudit = finalPrev; + jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, prevRoleForAudit); ps.setLong(2, userId); }); + jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " + + "SELECT u.shop_id, u.id, 'revoke', ?, ?, 'normal_admin', ?, NOW() FROM users u WHERE u.id=?", + ps -> { ps.setString(1, body == null ? null : Objects.toString(body.get("remark"), null)); + if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId); + ps.setString(3, prevRoleForAudit); ps.setLong(4, userId);} ); + return ResponseEntity.ok(Map.of("ok", true)); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentController.java new file mode 100644 index 0000000..aa3dd24 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentController.java @@ -0,0 +1,236 @@ +package com.example.demo.attachment; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.util.StringUtils; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; + +@RestController +@RequestMapping("/api/attachments") +public class AttachmentController { + + private final AttachmentPlaceholderProperties placeholderProperties; + private final AttachmentUrlValidator urlValidator; + private final AttachmentStorageService storageService; + private final JdbcTemplate jdbcTemplate; + + public AttachmentController(AttachmentPlaceholderProperties placeholderProperties, + AttachmentUrlValidator urlValidator, + AttachmentStorageService storageService, + JdbcTemplate jdbcTemplate) { + this.placeholderProperties = placeholderProperties; + this.urlValidator = urlValidator; + this.storageService = storageService; + this.jdbcTemplate = jdbcTemplate; + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> upload(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "ownerType", required = false) String ownerType, + @RequestParam(value = "ownerId", required = false) Long ownerId) throws IOException { + AttachmentStorageService.StoredObject so = storageService.store(file); + + String ot = StringUtils.hasText(ownerType) ? ownerType.trim() : "global"; + Long oid = ownerId == null ? 0L : ownerId; + + // 写入 attachments 表 + String metaJson = buildMetaJson(so.relativePath(), so.contentType(), so.size()); + try { + jdbcTemplate.update( + "INSERT INTO attachments (shop_id, user_id, owner_type, owner_id, url, hash, meta, created_at) VALUES (?,?,?,?,?,?,?,NOW())", + shopId, userId, ot, oid, "/api/attachments/content/" + so.sha256(), so.sha256(), metaJson + ); + } catch (DuplicateKeyException ignore) { + // 已存在相同hash记录,忽略插入以实现幂等 + } + + Map body = new HashMap<>(); + body.put("url", "/api/attachments/content/" + so.sha256()); + body.put("contentType", so.contentType()); + body.put("contentLength", so.size()); + return ResponseEntity.ok(body); + } + + @PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> validateUrlJson(@RequestBody Map body) { + String url = body == null ? null : String.valueOf(body.get("url")); + AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url); + Map resp = new HashMap<>(); + resp.put("url", vr.url()); + resp.put("contentType", vr.contentType()); + if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength()); + return ResponseEntity.ok(resp); + } + + @PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity> validateUrlForm(@RequestParam("url") String url) { + AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url); + Map resp = new HashMap<>(); + resp.put("url", vr.url()); + resp.put("contentType", vr.contentType()); + if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength()); + return ResponseEntity.ok(resp); + } + + @GetMapping("/placeholder") + public ResponseEntity placeholder() throws IOException { + String imagePath = placeholderProperties.getImagePath(); + if (!StringUtils.hasText(imagePath)) { + return ResponseEntity.status(404).build(); + } + Path path = Path.of(imagePath); + if (!Files.exists(path)) { + return ResponseEntity.status(404).build(); + } + Resource resource = new FileSystemResource(path); + String contentType = null; + try { + contentType = Files.probeContentType(path); + } catch (IOException ignore) { + contentType = null; + } + MediaType mediaType; + try { + if (contentType == null || contentType.isBlank()) { + mediaType = MediaType.IMAGE_PNG; + } else { + mediaType = MediaType.parseMediaType(contentType); + } + } catch (Exception e) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder") + .cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic()) + .contentType(mediaType) + .body(resource); + } + + @GetMapping("/content/{sha256}") + public ResponseEntity contentByHash(@PathVariable("sha256") String sha256) throws IOException { + if (!StringUtils.hasText(sha256)) return ResponseEntity.badRequest().build(); + // 从数据库读取 meta.path 获取相对路径 + String relativePath = null; + try { + String meta = jdbcTemplate.query("SELECT meta FROM attachments WHERE hash=? ORDER BY id DESC LIMIT 1", + ps -> ps.setString(1, sha256), + rs -> rs.next() ? rs.getString(1) : null); + relativePath = extractPathFromMetaJson(meta); + if (!StringUtils.hasText(relativePath)) { + relativePath = extractPathFromMeta(meta); + } + } catch (Exception ignore) { relativePath = null; } + + Path found = null; + if (StringUtils.hasText(relativePath)) { + try { found = storageService.resolveAbsolutePath(relativePath); } catch (Exception ignore) { found = null; } + } + if (found == null || !Files.exists(found)) { + // 兜底:全目录扫描(少量文件可接受) + Path storageRoot = storageService != null ? storageService.getStorageRoot() : Path.of("./data/attachments"); + if (Files.exists(storageRoot)) { + found = findFileByHash(storageRoot, sha256); + } + if (found == null) return ResponseEntity.notFound().build(); + } + + Resource resource = new FileSystemResource(found); + String contentType = null; + try { contentType = Files.probeContentType(found); } catch (IOException ignore) { contentType = null; } + MediaType mediaType; + try { + if (contentType == null || contentType.isBlank()) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } else { + mediaType = MediaType.parseMediaType(contentType); + } + } catch (Exception e) { mediaType = MediaType.APPLICATION_OCTET_STREAM; } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + found.getFileName()) + .cacheControl(CacheControl.maxAge(30, java.util.concurrent.TimeUnit.DAYS).cachePublic()) + .contentType(mediaType) + .body(resource); + } + + private Path findFileByHash(Path root, String sha256) throws IOException { + try (var stream = Files.walk(root)) { + return stream + .filter(p -> Files.isRegularFile(p)) + .filter(p -> { + String name = p.getFileName().toString(); + return name.equals(sha256) || name.startsWith(sha256 + "."); + }) + .findFirst() + .orElse(null); + } + } + + private String buildMetaJson(String relativePath, String contentType, long size) { + return "{" + + "\"path\":\"" + escapeJson(relativePath) + "\"," + + "\"contentType\":\"" + escapeJson(contentType) + "\"," + + "\"size\":" + size + + "}"; + } + + private String extractPathFromMeta(String meta) { + if (!StringUtils.hasText(meta)) return null; + int i = meta.indexOf("\"path\":\""); + if (i < 0) return null; + int s = i + 8; // length of "path":" + int e = meta.indexOf('"', s); + if (e < 0) return null; + String val = meta.substring(s, e); + return val.replace("\\\\", "/"); + } + + private String extractPathFromMetaJson(String meta) { + if (!StringUtils.hasText(meta)) return null; + try { + ObjectMapper om = new ObjectMapper(); + Map m = om.readValue(meta, new TypeReference>(){}); + Object p = m.get("path"); + return p == null ? null : String.valueOf(p); + } catch (Exception e) { + return null; + } + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java new file mode 100644 index 0000000..92283c0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java @@ -0,0 +1,35 @@ +package com.example.demo.attachment; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "attachments.placeholder") +public class AttachmentPlaceholderProperties { + + private String imagePath; + private String urlPath = "/api/attachments/placeholder"; + + public String getImagePath() { + return imagePath; + } + + public void setImagePath(String imagePath) { + this.imagePath = imagePath; + } + + public String getUrlPath() { + return urlPath; + } + + public void setUrlPath(String urlPath) { + this.urlPath = urlPath; + } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentStorageService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentStorageService.java new file mode 100644 index 0000000..1399000 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentStorageService.java @@ -0,0 +1,135 @@ +package com.example.demo.attachment; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.Locale; + +@Service +public class AttachmentStorageService { + + private final AttachmentUploadProperties props; + + public AttachmentStorageService(AttachmentUploadProperties props) { + this.props = props; + } + + public StoredObject store(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("文件为空"); + } + long size = file.getSize(); + if (size > props.getMaxSizeBytes()) { + throw new IllegalArgumentException("文件过大,超过上限" + props.getMaxSizeMb() + "MB"); + } + + String contentType = normalizeContentType(file.getContentType()); + if (!isAllowedContentType(contentType)) { + // 尝试根据扩展名推断 + String guessed = guessContentTypeFromFilename(file.getOriginalFilename()); + if (!isAllowedContentType(guessed)) { + throw new IllegalArgumentException("不支持的文件类型"); + } + contentType = guessed; + } + + // 计算哈希 + String sha256 = sha256Hex(file); + + // 生成相对路径:yyyy/MM/dd/. + String ext = extensionForContentType(contentType); + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + Path baseDir = Path.of(props.getStorageDir()); + Path dir = baseDir.resolve(datePath); + Files.createDirectories(dir); + Path target = dir.resolve(sha256 + (ext == null ? "" : ("." + ext))); + + // 保存文件(覆盖策略:相同哈希重复上传时幂等) + try (InputStream in = file.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + + String relativePath = baseDir.toAbsolutePath().normalize().relativize(target.toAbsolutePath().normalize()).toString().replace('\\', '/'); + return new StoredObject(relativePath, contentType, size, sha256); + } + + public Path resolveAbsolutePath(String relativePath) { + if (!StringUtils.hasText(relativePath)) { + throw new IllegalArgumentException("路径无效"); + } + return Path.of(props.getStorageDir()).resolve(relativePath).normalize(); + } + + public Path getStorageRoot() { + return Path.of(props.getStorageDir()).normalize(); + } + + private String normalizeContentType(String ct) { + if (!StringUtils.hasText(ct)) return null; + int idx = ct.indexOf(';'); + String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT); + return base; + } + + private boolean isAllowedContentType(String ct) { + if (!StringUtils.hasText(ct)) return false; + for (String allowed : props.getAllowedContentTypes()) { + if (ct.equalsIgnoreCase(allowed)) return true; + } + return false; + } + + private String extensionForContentType(String ct) { + if (!StringUtils.hasText(ct)) return null; + return switch (ct) { + case "image/jpeg" -> "jpg"; + case "image/png" -> "png"; + case "image/gif" -> "gif"; + case "image/webp" -> "webp"; + case "image/svg+xml" -> "svg"; + default -> null; + }; + } + + private String guessContentTypeFromFilename(String name) { + if (!StringUtils.hasText(name)) return null; + String n = name.toLowerCase(Locale.ROOT); + if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return MediaType.IMAGE_JPEG_VALUE; + if (n.endsWith(".png")) return MediaType.IMAGE_PNG_VALUE; + if (n.endsWith(".gif")) return MediaType.IMAGE_GIF_VALUE; + if (n.endsWith(".webp")) return "image/webp"; + if (n.endsWith(".svg")) return "image/svg+xml"; + return null; + } + + private String sha256Hex(MultipartFile file) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (InputStream in = file.getInputStream(); DigestInputStream dis = new DigestInputStream(in, md)) { + byte[] buffer = new byte[8192]; + while (dis.read(buffer) != -1) { /* drain */ } + } + byte[] digest = md.digest(); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + + public record StoredObject(String relativePath, String contentType, long size, String sha256) { } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUploadProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUploadProperties.java new file mode 100644 index 0000000..d28a857 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUploadProperties.java @@ -0,0 +1,32 @@ +package com.example.demo.attachment; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Component +@ConfigurationProperties(prefix = "attachments.upload") +public class AttachmentUploadProperties { + + private String storageDir = "./data/attachments"; + private int maxSizeMb = 5; + private List allowedContentTypes = new ArrayList<>(Arrays.asList( + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml" + )); + + public String getStorageDir() { return storageDir; } + public void setStorageDir(String storageDir) { this.storageDir = storageDir; } + + public int getMaxSizeMb() { return maxSizeMb; } + public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; } + + public List getAllowedContentTypes() { return allowedContentTypes; } + public void setAllowedContentTypes(List allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; } + + public long getMaxSizeBytes() { return (long) maxSizeMb * 1024L * 1024L; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidationProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidationProperties.java new file mode 100644 index 0000000..7fd4233 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidationProperties.java @@ -0,0 +1,58 @@ +package com.example.demo.attachment; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Component +@ConfigurationProperties(prefix = "attachments.url") +public class AttachmentUrlValidationProperties { + + private boolean ssrfProtection = true; + private boolean allowPrivateIp = false; + private boolean followRedirects = true; + private int maxRedirects = 2; + private int connectTimeoutMs = 3000; + private int readTimeoutMs = 5000; + private int maxSizeMb = 5; + private List allowlist = new ArrayList<>(); + private List allowedContentTypes = new ArrayList<>(Arrays.asList( + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml" + )); + + public boolean isSsrfProtection() { return ssrfProtection; } + public void setSsrfProtection(boolean ssrfProtection) { this.ssrfProtection = ssrfProtection; } + + public boolean isAllowPrivateIp() { return allowPrivateIp; } + public void setAllowPrivateIp(boolean allowPrivateIp) { this.allowPrivateIp = allowPrivateIp; } + + public boolean isFollowRedirects() { return followRedirects; } + public void setFollowRedirects(boolean followRedirects) { this.followRedirects = followRedirects; } + + public int getMaxRedirects() { return maxRedirects; } + public void setMaxRedirects(int maxRedirects) { this.maxRedirects = maxRedirects; } + + public int getConnectTimeoutMs() { return connectTimeoutMs; } + public void setConnectTimeoutMs(int connectTimeoutMs) { this.connectTimeoutMs = connectTimeoutMs; } + + public int getReadTimeoutMs() { return readTimeoutMs; } + public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; } + + public int getMaxSizeMb() { return maxSizeMb; } + public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; } + + public List getAllowlist() { return allowlist; } + public void setAllowlist(List allowlist) { this.allowlist = allowlist; } + + public List getAllowedContentTypes() { return allowedContentTypes; } + public void setAllowedContentTypes(List allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; } + + public long getMaxSizeBytes() { + return (long) maxSizeMb * 1024L * 1024L; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidator.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidator.java new file mode 100644 index 0000000..20d345c --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/attachment/AttachmentUrlValidator.java @@ -0,0 +1,250 @@ +package com.example.demo.attachment; + +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.net.*; +import java.util.List; +import java.util.Locale; + +@Service +public class AttachmentUrlValidator { + + private final AttachmentUrlValidationProperties props; + + public AttachmentUrlValidator(AttachmentUrlValidationProperties props) { + this.props = props; + } + + public ValidationResult validate(String urlString) { + if (!StringUtils.hasText(urlString)) { + throw new IllegalArgumentException("url不能为空"); + } + + try { + URI uri = new URI(urlString.trim()); + if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException("仅支持http/https"); + } + if (!StringUtils.hasText(uri.getHost())) { + throw new IllegalArgumentException("URL缺少主机名"); + } + + // allowlist 校验 + enforceAllowlist(uri.getHost()); + + // SSRF/IP 私网校验 + if (props.isSsrfProtection()) { + enforceIpSafety(uri.getHost()); + } + + // 发起 HEAD 请求(必要时回退 GET Range)并处理重定向 + HttpResult http = headCheckWithRedirects(uri, props.getMaxRedirects()); + + // 内容类型校验 + String contentType = normalizeContentType(http.contentType); + if (!isAllowedContentType(contentType)) { + // 允许根据扩展名兜底一次 + String guessed = guessContentTypeFromPath(http.finalUri.getPath()); + if (!isAllowedContentType(guessed)) { + throw new IllegalArgumentException("不支持的图片类型"); + } + contentType = guessed; + } + + // 大小校验(若已知) + if (http.contentLength != null && http.contentLength > 0) { + if (http.contentLength > props.getMaxSizeBytes()) { + throw new IllegalArgumentException("图片过大,超过上限" + props.getMaxSizeMb() + "MB"); + } + } + + return new ValidationResult(http.finalUri.toString(), contentType, http.contentLength); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("URL不合法"); + } + } + + private void enforceAllowlist(String host) { + List list = props.getAllowlist(); + if (list == null || list.isEmpty()) return; + String h = host.toLowerCase(Locale.ROOT); + for (String item : list) { + if (!StringUtils.hasText(item)) continue; + String rule = item.trim().toLowerCase(Locale.ROOT); + if (rule.startsWith("*.")) { + String suf = rule.substring(1); // .example.com + if (h.endsWith(suf)) return; + } else { + if (h.equals(rule) || h.endsWith("." + rule)) return; + } + } + throw new IllegalArgumentException("域名不在白名单"); + } + + private void enforceIpSafety(String host) { + try { + InetAddress[] all = InetAddress.getAllByName(host); + for (InetAddress addr : all) { + if (!isPublicAddress(addr)) { + if (!props.isAllowPrivateIp()) { + throw new IllegalArgumentException("禁止访问内网/本地地址"); + } + } + } + } catch (UnknownHostException e) { + throw new IllegalArgumentException("无法解析域名"); + } + } + + private boolean isPublicAddress(InetAddress addr) { + return !(addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || + addr.isSiteLocalAddress() || addr.isMulticastAddress()); + } + + private HttpResult headCheckWithRedirects(URI uri, int remainingRedirects) { + try { + URI current = uri; + int redirects = 0; + while (true) { + HttpURLConnection conn = (HttpURLConnection) current.toURL().openConnection(); + conn.setConnectTimeout(props.getConnectTimeoutMs()); + conn.setReadTimeout(props.getReadTimeoutMs()); + conn.setInstanceFollowRedirects(false); + conn.setRequestProperty("User-Agent", "PartsInquiry/1.0"); + try { + conn.setRequestMethod("HEAD"); + } catch (ProtocolException ignored) { + // 一些实现不允许设置,忽略 + } + + int code; + try { + code = conn.getResponseCode(); + } catch (IOException ex) { + // 回退到GET Range + return getRange0(current, redirects); + } + + if (isRedirect(code)) { + if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) { + throw new IllegalArgumentException("重定向过多"); + } + String loc = conn.getHeaderField("Location"); + if (!StringUtils.hasText(loc)) { + throw new IllegalArgumentException("重定向无Location"); + } + URI next = current.resolve(loc); + if (!"http".equalsIgnoreCase(next.getScheme()) && !"https".equalsIgnoreCase(next.getScheme())) { + throw new IllegalArgumentException("非法重定向协议"); + } + // 重定向目标再做一次安全检查 + enforceAllowlist(next.getHost()); + if (props.isSsrfProtection()) enforceIpSafety(next.getHost()); + current = next; + redirects++; + continue; + } + + if (code >= 200 && code < 300) { + String ct = conn.getHeaderField("Content-Type"); + String cl = conn.getHeaderField("Content-Length"); + Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl)); + return new HttpResult(current, ct, len); + } + + // HEAD 不被允许时(405等)回退到 GET Range + if (code == HttpURLConnection.HTTP_BAD_METHOD || code == HttpURLConnection.HTTP_FORBIDDEN) { + return getRange0(current, redirects); + } + + throw new IllegalArgumentException("URL不可访问,HTTP " + code); + } + } catch (IOException e) { + throw new IllegalArgumentException("无法访问URL"); + } + } + + private HttpResult getRange0(URI uri, int redirects) throws IOException { + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setConnectTimeout(props.getConnectTimeoutMs()); + conn.setReadTimeout(props.getReadTimeoutMs()); + conn.setInstanceFollowRedirects(false); + conn.setRequestProperty("User-Agent", "PartsInquiry/1.0"); + conn.setRequestProperty("Range", "bytes=0-0"); + int code = conn.getResponseCode(); + if (isRedirect(code)) { + if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) { + throw new IllegalArgumentException("重定向过多"); + } + String loc = conn.getHeaderField("Location"); + if (!StringUtils.hasText(loc)) throw new IllegalArgumentException("重定向无Location"); + URI next = uri.resolve(loc); + enforceAllowlist(next.getHost()); + if (props.isSsrfProtection()) enforceIpSafety(next.getHost()); + return headCheckWithRedirects(next, props.getMaxRedirects() - redirects - 1); + } + if (code >= 200 && code < 300 || code == HttpURLConnection.HTTP_PARTIAL) { + String ct = conn.getHeaderField("Content-Type"); + String cl = conn.getHeaderField("Content-Length"); + Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl)); + return new HttpResult(uri, ct, len); + } + throw new IllegalArgumentException("URL不可访问,HTTP " + code); + } + + private boolean isRedirect(int code) { + return code == HttpURLConnection.HTTP_MOVED_PERM || code == HttpURLConnection.HTTP_MOVED_TEMP + || code == HttpURLConnection.HTTP_SEE_OTHER || code == 307 || code == 308; + } + + private String normalizeContentType(String ct) { + if (!StringUtils.hasText(ct)) return null; + int idx = ct.indexOf(';'); + String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT); + return base; + } + + private boolean isAllowedContentType(String ct) { + if (!StringUtils.hasText(ct)) return false; + for (String allowed : props.getAllowedContentTypes()) { + if (ct.equalsIgnoreCase(allowed)) return true; + } + return false; + } + + private String guessContentTypeFromPath(String path) { + if (path == null) return null; + String p = path.toLowerCase(Locale.ROOT); + if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg"; + if (p.endsWith(".png")) return "image/png"; + if (p.endsWith(".gif")) return "image/gif"; + if (p.endsWith(".webp")) return "image/webp"; + if (p.endsWith(".svg")) return "image/svg+xml"; + return null; + } + + private String totalFromContentRange(String contentRange, String fallbackLength) { + // Content-Range: bytes 0-0/12345 -> 12345 + if (contentRange != null) { + int slash = contentRange.lastIndexOf('/'); + if (slash > 0 && slash + 1 < contentRange.length()) { + String total = contentRange.substring(slash + 1).trim(); + if (!"*".equals(total)) return total; + } + } + return fallbackLength; + } + + private Long parseLongSafe(String v) { + if (!StringUtils.hasText(v)) return null; + try { return Long.parseLong(v.trim()); } catch (Exception e) { return null; } + } + + public record ValidationResult(String url, String contentType, Long contentLength) {} + + private record HttpResult(URI finalUri, String contentType, Long contentLength) {} +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthController.java new file mode 100644 index 0000000..d944d97 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthController.java @@ -0,0 +1,56 @@ +package com.example.demo.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@RestController +@RequestMapping("/api/auth/email") +public class EmailAuthController { + + private final EmailAuthService emailAuthService; + + public EmailAuthController(EmailAuthService emailAuthService) { + this.emailAuthService = emailAuthService; + } + + @PostMapping("/send") + public ResponseEntity send(@RequestBody EmailAuthService.SendCodeRequest req, + @RequestHeader(value = "X-Forwarded-For", required = false) String xff, + @RequestHeader(value = "X-Real-IP", required = false) String xri) { + String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp()); + EmailAuthService.SendCodeResponse resp = emailAuthService.sendCode(req, ip); + return ResponseEntity.ok(resp); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody EmailAuthService.LoginRequest req) { + EmailAuthService.LoginResponse resp = emailAuthService.login(req); + return ResponseEntity.ok(resp); + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody EmailAuthService.RegisterRequest req) { + EmailAuthService.LoginResponse resp = emailAuthService.register(req); + return ResponseEntity.ok(resp); + } + + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@RequestBody EmailAuthService.ResetPasswordRequest req) { + EmailAuthService.ResetPasswordResponse resp = emailAuthService.resetPassword(req); + return ResponseEntity.ok(resp); + } + + private String getClientIp() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes sra) { + var req = sra.getRequest(); + return req.getRemoteAddr(); + } + return ""; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthService.java new file mode 100644 index 0000000..35c8ab3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/EmailAuthService.java @@ -0,0 +1,318 @@ +package com.example.demo.auth; + +import com.example.demo.common.EmailSenderService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.sql.PreparedStatement; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class EmailAuthService { + + private final JdbcTemplate jdbcTemplate; + private final JwtService jwtService; + private final JwtProperties jwtProps; + private final com.example.demo.common.ShopDefaultsProperties shopDefaults; + private final EmailSenderService emailSender; + + private final com.example.demo.common.DefaultSeedService defaultSeedService; + + public EmailAuthService(JdbcTemplate jdbcTemplate, + JwtService jwtService, + JwtProperties jwtProps, + com.example.demo.common.ShopDefaultsProperties shopDefaults, + EmailSenderService emailSender, + com.example.demo.common.DefaultSeedService defaultSeedService) { + this.jdbcTemplate = jdbcTemplate; + this.jwtService = jwtService; + this.jwtProps = jwtProps; + this.shopDefaults = shopDefaults; + this.emailSender = emailSender; + this.defaultSeedService = defaultSeedService; + } + + public static class SendCodeRequest { public String email; public String scene; } + public static class SendCodeResponse { public boolean ok; public long cooldownSec; } + public static class LoginRequest { public String email; public String code; public String name; } + public static class RegisterRequest { public String email; public String code; public String name; public String password; } + public static class ResetPasswordRequest { public String email; public String code; public String newPassword; public String confirmPassword; } + public static class LoginResponse { public String token; public long expiresIn; public Map user; } + public static class ResetPasswordResponse { public boolean ok; } + + private String generateCode() { + SecureRandom rng = new SecureRandom(); + int n = 100000 + rng.nextInt(900000); + return String.valueOf(n); + } + + private static String hmacSha256Hex(String secret, String message) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(md.digest()); + } catch (Exception e) { throw new RuntimeException(e); } + } + + private void ensureEmailFormat(String email) { + if (email == null || email.isBlank()) throw new IllegalArgumentException("邮箱不能为空"); + String e = email.trim(); + if (!e.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) throw new IllegalArgumentException("邮箱格式不正确"); + } + + private static void ensurePasswordFormat(String password) { + if (password == null || password.isBlank()) throw new IllegalArgumentException("密码不能为空"); + if (password.length() < 6) throw new IllegalArgumentException("密码至少6位"); + } + + private Map fetchLatestCode(String email, String scene) { + String sc = (scene == null || scene.isBlank()) ? "login" : scene.trim(); + return jdbcTemplate.query( + con -> { + var ps = con.prepareStatement( + "SELECT id, code_hash, salt, expire_at, status, fail_count FROM email_codes WHERE email=? AND scene=? ORDER BY id DESC LIMIT 1"); + ps.setString(1, email); + ps.setString(2, sc); + return ps; + }, + rs -> { + if (rs.next()) { + Map m = new java.util.HashMap<>(); + m.put("id", rs.getLong(1)); + m.put("code_hash", rs.getString(2)); + m.put("salt", rs.getString(3)); + m.put("expire_at", rs.getTimestamp(4)); + m.put("status", rs.getInt(5)); + m.put("fail_count", rs.getInt(6)); + return m; + } + return null; + } + ); + } + + private void validateAndConsumeCode(String email, String scene, String code) { + if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空"); + String lowerEmail = email.trim().toLowerCase(); + Map row = fetchLatestCode(lowerEmail, scene); + if (row == null && scene != null && !scene.isBlank() && !"login".equals(scene)) { + row = fetchLatestCode(lowerEmail, "login"); + } + if (row == null) throw new IllegalArgumentException("CODE_INVALID"); + Long id = ((Number)row.get("id")).longValue(); + int status = ((Number)row.get("status")).intValue(); + if (status != 0) throw new IllegalArgumentException("CODE_INVALID"); + java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at"); + if (expireAt.before(new java.util.Date())) { + jdbcTemplate.update("UPDATE email_codes SET status=2 WHERE id=?", id); + throw new IllegalArgumentException("CODE_EXPIRED"); + } + int failCount = ((Number)row.get("fail_count")).intValue(); + if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS"); + String expect = (String) row.get("code_hash"); + String salt = (String) row.get("salt"); + String actual = hmacSha256Hex(salt, code); + if (!actual.equalsIgnoreCase(expect)) { + jdbcTemplate.update("UPDATE email_codes SET fail_count=fail_count+1 WHERE id=?", id); + throw new IllegalArgumentException("CODE_INVALID"); + } + jdbcTemplate.update("UPDATE email_codes SET status=1 WHERE id=?", id); + } + + @Transactional + public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) { + ensureEmailFormat(req.email); + String email = req.email.trim().toLowerCase(); + String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene.trim().toLowerCase(); + + Long cntRecent = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM email_codes WHERE email=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND", + Long.class, email, scene); + if (cntRecent != null && cntRecent > 0) { + SendCodeResponse out = new SendCodeResponse(); + out.ok = false; out.cooldownSec = 60; + return out; + } + + String code = generateCode(); + String salt = Long.toHexString(System.nanoTime()); + String codeHash = hmacSha256Hex(salt, code); + int ttl = 300; // 五分钟有效 + jdbcTemplate.update(con -> { + PreparedStatement ps = con.prepareStatement( + "INSERT INTO email_codes(email, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " + + "VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())"); + ps.setString(1, email); + ps.setString(2, scene); + ps.setString(3, codeHash); + ps.setString(4, salt); + ps.setInt(5, ttl); + ps.setString(6, clientIp); + return ps; + }); + + // 真实发信 + String subject = "邮箱验证码"; + String content = "您的验证码是 " + code + " ,5分钟内有效。如非本人操作请忽略。"; + emailSender.sendPlainText(email, subject, content); + + SendCodeResponse out = new SendCodeResponse(); + out.ok = true; out.cooldownSec = 60; + return out; + } + + @Transactional + public LoginResponse login(LoginRequest req) { + ensureEmailFormat(req.email); + String email = req.email.trim().toLowerCase(); + validateAndConsumeCode(email, "login", req.code); + + List existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email); + Long userId; + Long shopId; + if (!existing.isEmpty()) { + userId = existing.get(0); + List sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId); + shopId = sids.isEmpty() ? 1L : sids.get(0); + Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId); + if (st != null && st.intValue() != 1) { + throw new IllegalArgumentException("你已被管理员拉黑"); + } + } else { + String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim(); + String shopName = String.format(shopDefaults.getNamePattern(), displayName); + var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setString(1, shopName); + return ps; + }, shopKey); + Number shopGenId = shopKey.getKey(); + if (shopGenId == null) throw new IllegalStateException("创建店铺失败"); + shopId = shopGenId.longValue(); + + var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + final Long sid = shopId; + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, sid); + ps.setString(2, email); + ps.setString(3, displayName); + ps.setString(4, shopDefaults.getOwnerRole()); + return ps; + }, userKey); + Number userGenId = userKey.getKey(); + if (userGenId == null) throw new IllegalStateException("创建用户失败"); + userId = userGenId.longValue(); + + // 初始化默认客户/供应商(幂等) + defaultSeedService.initializeForShop(shopId, userId); + } + + String token = jwtService.signToken(userId, shopId, null, "email_otp", email); + LoginResponse out = new LoginResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + java.util.HashMap userMap = new java.util.HashMap<>(); + userMap.put("userId", userId); + userMap.put("shopId", shopId); + userMap.put("email", email); + out.user = userMap; + return out; + } + + @Transactional + public LoginResponse register(RegisterRequest req) { + ensureEmailFormat(req.email); + ensurePasswordFormat(req.password); + String email = req.email.trim().toLowerCase(); + validateAndConsumeCode(email, "register", req.code); + + List existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email); + if (!existing.isEmpty()) { + throw new IllegalArgumentException("邮箱已注册"); + } + + String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim(); + String shopName = String.format(shopDefaults.getNamePattern(), displayName); + + var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setString(1, shopName); + return ps; + }, shopKey); + Number shopGenId = shopKey.getKey(); + if (shopGenId == null) throw new IllegalStateException("创建店铺失败"); + Long shopId = shopGenId.longValue(); + + var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, shopId); + ps.setString(2, email); + ps.setString(3, displayName); + ps.setString(4, shopDefaults.getOwnerRole()); + return ps; + }, userKey); + Number userGenId = userKey.getKey(); + if (userGenId == null) throw new IllegalStateException("创建用户失败"); + Long userId = userGenId.longValue(); + + String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.password, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10)); + jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId); + + String token = jwtService.signToken(userId, shopId, null, "email_register", email); + LoginResponse out = new LoginResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + java.util.HashMap userMap = new java.util.HashMap<>(); + userMap.put("userId", userId); + userMap.put("shopId", shopId); + userMap.put("email", email); + out.user = userMap; + return out; + } + + @Transactional + public ResetPasswordResponse resetPassword(ResetPasswordRequest req) { + ensureEmailFormat(req.email); + if (req.newPassword == null || req.newPassword.isBlank()) throw new IllegalArgumentException("新密码不能为空"); + if (req.confirmPassword == null || !req.newPassword.equals(req.confirmPassword)) { + throw new IllegalArgumentException("两次密码不一致"); + } + ensurePasswordFormat(req.newPassword); + + String email = req.email.trim().toLowerCase(); + validateAndConsumeCode(email, "reset", req.code); + + List existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email); + if (existing.isEmpty()) throw new IllegalArgumentException("用户不存在"); + Long userId = existing.get(0); + + String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.newPassword, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10)); + jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId); + + ResetPasswordResponse resp = new ResetPasswordResponse(); + resp.ok = true; + return resp; + } + + private static String maskEmailForName(String email) { + String e = String.valueOf(email); + int at = e.indexOf('@'); + if (at > 1) { + String name = e.substring(0, at); + if (name.length() <= 2) return "用户" + name.charAt(0) + "*"; + return "用户" + name.substring(0, 2) + "***"; + } + return "邮箱用户"; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtProperties.java new file mode 100644 index 0000000..5060f5e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtProperties.java @@ -0,0 +1,24 @@ +package com.example.demo.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secret; + private String issuer = "parts-inquiry-api"; + private long ttlSeconds = 7200; + private long clockSkewSeconds = 60; + + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + public long getTtlSeconds() { return ttlSeconds; } + public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; } + public long getClockSkewSeconds() { return clockSkewSeconds; } + public void setClockSkewSeconds(long clockSkewSeconds) { this.clockSkewSeconds = clockSkewSeconds; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtService.java new file mode 100644 index 0000000..4ec5a22 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/JwtService.java @@ -0,0 +1,93 @@ +package com.example.demo.auth; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.JWTVerifier; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Service +public class JwtService { + private final JwtProperties props; + + public JwtService(JwtProperties props) { + this.props = props; + } + + public String signToken(Long userId, Long shopId, String phone, String provider) { + Instant now = Instant.now(); + Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret()); + var jwt = JWT.create() + .withIssuer(props.getIssuer()) + .withIssuedAt(java.util.Date.from(now)) + .withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds()))) + .withClaim("userId", userId) + .withClaim("shopId", shopId) + .withClaim("provider", provider); + if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone); + return jwt.sign(alg); + } + + public String signToken(Long userId, Long shopId, String phone, String provider, String email) { + Instant now = Instant.now(); + Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret()); + var jwt = JWT.create() + .withIssuer(props.getIssuer()) + .withIssuedAt(java.util.Date.from(now)) + .withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds()))) + .withClaim("userId", userId) + .withClaim("shopId", shopId) + .withClaim("provider", provider); + if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone); + if (email != null && !email.isBlank()) jwt.withClaim("email", email); + return jwt.sign(alg); + } + + public String signAdminToken(Long adminId, String username) { + Instant now = Instant.now(); + Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret()); + var jwt = JWT.create() + .withIssuer(props.getIssuer()) + .withIssuedAt(java.util.Date.from(now)) + .withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds()))) + .withClaim("adminId", adminId) + .withClaim("role", "admin"); + if (username != null && !username.isBlank()) jwt.withClaim("username", username); + return jwt.sign(alg); + } + + public Map parseClaims(String authorizationHeader) { + Map out = new HashMap<>(); + if (authorizationHeader == null || authorizationHeader.isBlank()) return out; + String prefix = "Bearer "; + if (!authorizationHeader.startsWith(prefix)) return out; + String token = authorizationHeader.substring(prefix.length()).trim(); + try { + Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret()); + JWTVerifier verifier = JWT.require(alg) + .withIssuer(props.getIssuer()) + .acceptLeeway(props.getClockSkewSeconds()) + .build(); + DecodedJWT jwt = verifier.verify(token); + Long userId = jwt.getClaim("userId").asLong(); + Long shopId = jwt.getClaim("shopId").asLong(); + String phone = jwt.getClaim("phone").asString(); + String email = jwt.getClaim("email").asString(); + Long adminId = jwt.getClaim("adminId").asLong(); + String role = jwt.getClaim("role").asString(); + if (userId != null) out.put("userId", userId); + if (shopId != null) out.put("shopId", shopId); + if (phone != null && !phone.isBlank()) out.put("phone", phone); + if (email != null && !email.isBlank()) out.put("email", email); + if (adminId != null) out.put("adminId", adminId); + if (role != null && !role.isBlank()) out.put("role", role); + } catch (Exception ignore) { } + return out; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/NormalAdminApplyController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/NormalAdminApplyController.java new file mode 100644 index 0000000..2999ca5 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/NormalAdminApplyController.java @@ -0,0 +1,124 @@ +package com.example.demo.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Objects; +import java.util.LinkedHashMap; + +@RestController +@RequestMapping("/api/normal-admin") +public class NormalAdminApplyController { + + private final JdbcTemplate jdbc; + + public NormalAdminApplyController(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @PostMapping("/apply") + public ResponseEntity apply(@RequestHeader(name = "X-User-Id") long userId, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestBody(required = false) Map body) { + final Long sidFinal; + if (shopId == null) { + Long sid = jdbc.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getLong(1): null); + if (sid == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found")); + sidFinal = sid; + } else { sidFinal = shopId; } + // 校验 VIP(根据配置可选) + boolean requireVip = true; // 默认要求VIP有效 + try { + String v = jdbc.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.requiredVipActive' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v != null) { v = v.trim(); if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1); requireVip = ("true".equalsIgnoreCase(v) || "1".equals(v)); } + } catch (Exception ignored) {} + Integer vipOk = jdbc.query( + "SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1", + ps -> { ps.setLong(1, userId); ps.setLong(2, sidFinal); }, + rs -> rs.next() ? rs.getInt(1) : 0 + ); + if (requireVip && (vipOk == null || vipOk != 1)) { + return ResponseEntity.status(403).body(Map.of("error", "vip required")); + } + + String remark = body == null ? null : Objects.toString(body.get("remark"), null); + jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,created_at) VALUES (?,?,?,?,NOW())", + ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "apply"); ps.setString(4, remark); }); + + // 是否自动通过 + boolean autoApprove = false; + try { + String v = jdbc.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.autoApprove' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v != null) { v = v.trim(); if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1); autoApprove = ("true".equalsIgnoreCase(v) || "1".equals(v)); } + } catch (Exception ignored) {} + if (autoApprove) { + // 将角色变更为 normal_admin 并写入 approve 审计 + String prev = jdbc.query("SELECT role FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null); + jdbc.update("UPDATE users SET role='normal_admin' WHERE id=?", ps -> ps.setLong(1, userId)); + jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) VALUES (?,?,?,?,NULL,?,?,NOW())", + ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "approve"); ps.setString(4, "auto"); ps.setString(5, prev); ps.setString(6, "normal_admin"); }); + } + + return ResponseEntity.ok(Map.of("ok", true)); + } + + @GetMapping("/application/status") + public ResponseEntity myApplicationStatus(@RequestHeader(name = "X-User-Id") long userId) { + try { + Map out = new LinkedHashMap<>(); + // 当前角色 + String role = null; + try { + role = jdbc.query("SELECT role FROM users WHERE id=? LIMIT 1", + ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getString(1) : null); + } catch (Exception ignored) {} + boolean isNormalAdmin = role != null && "normal_admin".equalsIgnoreCase(role.trim()); + + // 最近一次审计动作 + Map last = null; + try { + last = jdbc.query( + "SELECT action, created_at AS createdAt, remark FROM normal_admin_audits WHERE user_id=? ORDER BY created_at DESC LIMIT 1", + ps -> ps.setLong(1, userId), + rs -> { + if (!rs.next()) return null; + Map m = new LinkedHashMap<>(); + m.put("action", rs.getString("action")); + m.put("createdAt", rs.getTimestamp("createdAt")); + m.put("remark", rs.getString("remark")); + return m; + } + ); + } catch (Exception ignored) {} + + String applicationStatus = "none"; + if (isNormalAdmin) { + applicationStatus = "approved"; + } else if (last != null) { + String action = (String) last.get("action"); + if ("apply".equalsIgnoreCase(action)) applicationStatus = "pending"; + else if ("approve".equalsIgnoreCase(action)) applicationStatus = "approved"; + else if ("reject".equalsIgnoreCase(action)) applicationStatus = "rejected"; + else if ("revoke".equalsIgnoreCase(action)) applicationStatus = "revoked"; + } + + out.put("isNormalAdmin", isNormalAdmin); + out.put("applicationStatus", applicationStatus); + if (last != null) { + out.put("lastAction", last.get("action")); + out.put("lastActionAt", last.get("createdAt")); + out.put("lastRemark", last.get("remark")); + } + return ResponseEntity.ok(out); + } catch (Exception e) { + Map fallback = new LinkedHashMap<>(); + fallback.put("isNormalAdmin", false); + fallback.put("applicationStatus", "none"); + return ResponseEntity.ok(fallback); + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthController.java new file mode 100644 index 0000000..26c28cb --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthController.java @@ -0,0 +1,21 @@ +package com.example.demo.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +public class PasswordAuthController { + + private final PasswordAuthService service; + + public PasswordAuthController(PasswordAuthService service) { this.service = service; } + + @PostMapping("/password/login") + public ResponseEntity login(@RequestBody PasswordAuthService.LoginRequest req) { + var resp = service.login(req); + return ResponseEntity.ok(resp); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthService.java new file mode 100644 index 0000000..59bb370 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/PasswordAuthService.java @@ -0,0 +1,88 @@ +package com.example.demo.auth; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class PasswordAuthService { + + private final JdbcTemplate jdbcTemplate; + private final JwtService jwtService; + private final JwtProperties jwtProps; + + public PasswordAuthService(JdbcTemplate jdbcTemplate, + JwtService jwtService, + JwtProperties jwtProps) { + this.jdbcTemplate = jdbcTemplate; + this.jwtService = jwtService; + this.jwtProps = jwtProps; + } + + public static class LoginRequest { public String account; public String email; public String phone; public String password; } + public static class LoginResponse { public String token; public long expiresIn; public Map user; } + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest req) { + String account = firstNonBlank(req.account, req.email, req.phone); + if (account == null || account.isBlank()) throw new IllegalArgumentException("账号不能为空"); + if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空"); + + boolean byEmail = account.contains("@"); + Map row = jdbcTemplate.query( + con -> { + var ps = con.prepareStatement(byEmail + ? "SELECT id, shop_id, password_hash, status, email FROM users WHERE email=? LIMIT 1" + : "SELECT id, shop_id, password_hash, status, phone FROM users WHERE phone=? LIMIT 1"); + ps.setString(1, account.trim()); + return ps; + }, + rs -> { + if (rs.next()) { + Map m = new HashMap<>(); + m.put("id", rs.getLong(1)); + m.put("shop_id", rs.getLong(2)); + m.put("password_hash", rs.getString(3)); + m.put("status", rs.getInt(4)); + m.put("account", rs.getString(5)); + return m; + } + return null; + } + ); + if (row == null) throw new IllegalArgumentException("用户不存在"); + int status = ((Number)row.get("status")).intValue(); + if (status != 1) throw new IllegalArgumentException("你已被管理员拉黑"); + String hash = (String) row.get("password_hash"); + if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD"); + boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash); + if (!ok) throw new IllegalArgumentException("密码错误"); + + Long userId = ((Number)row.get("id")).longValue(); + Long shopId = ((Number)row.get("shop_id")).longValue(); + String accValue = String.valueOf(row.get("account")); + + String token = byEmail + ? jwtService.signToken(userId, shopId, null, "password", accValue) + : jwtService.signToken(userId, shopId, accValue, "password"); + LoginResponse out = new LoginResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + Map userMap = new HashMap<>(); + userMap.put("userId", userId); userMap.put("shopId", shopId); + if (byEmail) userMap.put("email", accValue); else userMap.put("phone", accValue); + out.user = userMap; + return out; + } + + private static String firstNonBlank(String... arr) { + if (arr == null) return null; + for (String s : arr) { if (s != null && !s.isBlank()) return s; } + return null; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterController.java new file mode 100644 index 0000000..8340dd3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterController.java @@ -0,0 +1,23 @@ +package com.example.demo.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +public class RegisterController { + + private final RegisterService registerService; + + public RegisterController(RegisterService registerService) { + this.registerService = registerService; + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterService.RegisterRequest req) { + var resp = registerService.register(req); + return ResponseEntity.ok(resp); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterService.java new file mode 100644 index 0000000..b1cf715 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/RegisterService.java @@ -0,0 +1,167 @@ +package com.example.demo.auth; + +import com.example.demo.common.AppDefaultsProperties; +import org.springframework.beans.factory.annotation.Autowired; +import com.example.demo.common.ShopDefaultsProperties; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.util.HashMap; +import java.util.Map; + +@Service +public class RegisterService { + + private final JdbcTemplate jdbcTemplate; + private final JwtService jwtService; + private final JwtProperties jwtProps; + private final ShopDefaultsProperties shopDefaults; + private AppDefaultsProperties appDefaults; + private com.example.demo.common.DefaultSeedService defaultSeedService; + + public RegisterService(JdbcTemplate jdbcTemplate, + JwtService jwtService, + JwtProperties jwtProps, + ShopDefaultsProperties shopDefaults) { + this.jdbcTemplate = jdbcTemplate; + this.jwtService = jwtService; + this.jwtProps = jwtProps; + this.shopDefaults = shopDefaults; + } + + @Autowired + public void setAppDefaults(AppDefaultsProperties appDefaults) { + this.appDefaults = appDefaults; + } + + @Autowired + public void setDefaultSeedService(com.example.demo.common.DefaultSeedService defaultSeedService) { + this.defaultSeedService = defaultSeedService; + } + + private String hashPassword(String raw) { + try { + return org.springframework.security.crypto.bcrypt.BCrypt.hashpw(raw, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10)); + } catch (Exception e) { + throw new IllegalStateException("密码加密失败", e); + } + } + + public static class RegisterRequest { + public String phone; // 必填:11位 + public String name; // 可选:默认用脱敏手机号 + public String password; // 可选:如提供则保存密码哈希 + } + + public static class RegisterResponse { + public String token; + public long expiresIn; + public Map user; + } + + @Transactional + public RegisterResponse register(RegisterRequest req) { + ensurePhoneFormat(req.phone); + String phone = req.phone.trim(); + String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim(); + + // 已存在则直接签发令牌 + var existing = jdbcTemplate.queryForList("SELECT id, shop_id FROM users WHERE phone=? LIMIT 1", phone); + Long userId; + Long shopId; + if (!existing.isEmpty()) { + Map row = existing.get(0); + userId = ((Number)row.get("id")).longValue(); + shopId = ((Number)row.get("shop_id")).longValue(); + } else { + // 1) 创建店铺 + String shopName = String.format(shopDefaults.getNamePattern(), displayName); + GeneratedKeyHolder shopKey = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + PreparedStatement ps = con.prepareStatement( + "INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", + java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setString(1, shopName); + return ps; + }, shopKey); + Number shopGenId = shopKey.getKey(); + if (shopGenId == null) throw new IllegalStateException("创建店铺失败"); + shopId = shopGenId.longValue(); + + // 2) 创建店主用户(owner) + GeneratedKeyHolder userKey = new GeneratedKeyHolder(); + final Long sid = shopId; + jdbcTemplate.update(con -> { + PreparedStatement ps = con.prepareStatement( + "INSERT INTO users(shop_id, phone, name, role, password_hash, status, is_owner, created_at, updated_at) " + + "VALUES (?,?,?,?,?,1,1,NOW(),NOW())", + java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, sid); + ps.setString(2, phone); + ps.setString(3, displayName); + ps.setString(4, shopDefaults.getOwnerRole()); + // 如提供密码,存入哈希;否则设为 NULL + String pwd = (req.password == null || req.password.isBlank()) ? null : hashPassword(req.password); + if (pwd != null) ps.setString(5, pwd); else ps.setNull(5, java.sql.Types.VARCHAR); + return ps; + }, userKey); + Number userGenId = userKey.getKey(); + if (userGenId == null) throw new IllegalStateException("创建用户失败"); + userId = userGenId.longValue(); + + // 3) 创建默认账户(现金/银行存款/微信) + createDefaultAccounts(shopId, userId); + + // 4) 初始化默认客户/供应商(幂等) + if (defaultSeedService != null) { + defaultSeedService.initializeForShop(shopId, userId); + } + } + + String token = jwtService.signToken(userId, shopId, phone, "register"); + RegisterResponse out = new RegisterResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + HashMap userMap = new HashMap<>(); + userMap.put("userId", userId); + userMap.put("shopId", shopId); + userMap.put("phone", phone); + out.user = userMap; + return out; + } + + private void createDefaultAccounts(Long shopId, Long userId) { + // 现金 + jdbcTemplate.update( + "INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " + + "SELECT ?, ?, ?, 'cash', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)", + shopId, userId, appDefaults.getAccountCashName(), shopId, appDefaults.getAccountCashName()); + // 银行存款 + jdbcTemplate.update( + "INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " + + "SELECT ?, ?, ?, 'bank', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)", + shopId, userId, appDefaults.getAccountBankName(), shopId, appDefaults.getAccountBankName()); + // 微信 + jdbcTemplate.update( + "INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " + + "SELECT ?, ?, ?, 'wechat', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)", + shopId, userId, appDefaults.getAccountWechatName(), shopId, appDefaults.getAccountWechatName()); + } + + private void ensurePhoneFormat(String phone) { + if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空"); + String p = phone.replaceAll("\\s+", ""); + if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确"); + } + + private static String maskPhoneForName(String phone) { + String p = String.valueOf(phone); + if (p.length() == 11) return "用户" + p.substring(0,3) + "****" + p.substring(7); + return "手机用户"; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthController.java new file mode 100644 index 0000000..9eed6ed --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthController.java @@ -0,0 +1,45 @@ +package com.example.demo.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@RestController +@RequestMapping("/api/auth/sms") +public class SmsAuthController { + + private final SmsAuthService smsAuthService; + + public SmsAuthController(SmsAuthService smsAuthService) { + this.smsAuthService = smsAuthService; + } + + @PostMapping("/send") + public ResponseEntity send(@RequestBody SmsAuthService.SendCodeRequest req, + @RequestHeader(value = "X-Forwarded-For", required = false) String xff, + @RequestHeader(value = "X-Real-IP", required = false) String xri, + @RequestHeader(value = "X-Shop-Id", required = false) Long shopId) { + String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp()); + SmsAuthService.SendCodeResponse resp = smsAuthService.sendCode(req, ip); + return ResponseEntity.ok(resp); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody SmsAuthService.LoginRequest req) { + SmsAuthService.LoginResponse resp = smsAuthService.login(req); + return ResponseEntity.ok(resp); + } + + private String getClientIp() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes sra) { + var req = sra.getRequest(); + return req.getRemoteAddr(); + } + return ""; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthService.java new file mode 100644 index 0000000..4a6511b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/auth/SmsAuthService.java @@ -0,0 +1,204 @@ +package com.example.demo.auth; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.sql.PreparedStatement; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class SmsAuthService { + + private final JdbcTemplate jdbcTemplate; + private final JwtService jwtService; + private final JwtProperties jwtProps; + private final com.example.demo.common.ShopDefaultsProperties shopDefaults; + + public SmsAuthService(JdbcTemplate jdbcTemplate, + JwtService jwtService, + JwtProperties jwtProps, + com.example.demo.common.ShopDefaultsProperties shopDefaults) { + this.jdbcTemplate = jdbcTemplate; + this.jwtService = jwtService; + this.jwtProps = jwtProps; + this.shopDefaults = shopDefaults; + } + + public static class SendCodeRequest { public String phone; public String scene; } + public static class SendCodeResponse { public boolean ok; public long cooldownSec; } + public static class LoginRequest { public String phone; public String code; public String name; } + public static class LoginResponse { public String token; public long expiresIn; public Map user; } + + private String generateCode() { + SecureRandom rng = new SecureRandom(); + int n = 100000 + rng.nextInt(900000); + return String.valueOf(n); + } + + private static String hmacSha256Hex(String secret, String message) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(md.digest()); + } catch (Exception e) { throw new RuntimeException(e); } + } + + private void ensurePhoneFormat(String phone) { + if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空"); + String p = phone.replaceAll("\\s+", ""); + if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确"); + } + + @Transactional + public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) { + ensurePhoneFormat(req.phone); + String phone = req.phone; + String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene; + + Long cntRecent = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sms_codes WHERE phone=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND", + Long.class, phone, scene); + if (cntRecent != null && cntRecent > 0) { + SendCodeResponse out = new SendCodeResponse(); + out.ok = false; out.cooldownSec = 60; + return out; + } + + String code = generateCode(); + String salt = Long.toHexString(System.nanoTime()); + String codeHash = hmacSha256Hex(salt, code); + int ttl = 300; // 五分钟有效 + jdbcTemplate.update(con -> { + PreparedStatement ps = con.prepareStatement( + "INSERT INTO sms_codes(phone, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " + + "VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())"); + ps.setString(1, phone); + ps.setString(2, scene); + ps.setString(3, codeHash); + ps.setString(4, salt); + ps.setInt(5, ttl); + ps.setString(6, clientIp); + return ps; + }); + + // TODO: 集成真实短信发送;当前仅存表,不外发 + + SendCodeResponse out = new SendCodeResponse(); + out.ok = true; out.cooldownSec = 60; + return out; + } + + @Transactional + public LoginResponse login(LoginRequest req) { + ensurePhoneFormat(req.phone); + if (req.code == null || req.code.isBlank()) throw new IllegalArgumentException("验证码不能为空"); + String phone = req.phone; + + Map row = jdbcTemplate.query( + con -> { + var ps = con.prepareStatement("SELECT id, code_hash, salt, expire_at, status, fail_count FROM sms_codes WHERE phone=? AND scene='login' ORDER BY id DESC LIMIT 1"); + ps.setString(1, phone); + return ps; + }, + rs -> { + if (rs.next()) { + java.util.HashMap m = new java.util.HashMap<>(); + m.put("id", rs.getLong(1)); + m.put("code_hash", rs.getString(2)); + m.put("salt", rs.getString(3)); + m.put("expire_at", rs.getTimestamp(4)); + m.put("status", rs.getInt(5)); + m.put("fail_count", rs.getInt(6)); + return m; + } + return null; + } + ); + if (row == null) throw new IllegalArgumentException("CODE_INVALID"); + int status = (Integer) row.get("status"); + if (status != 0) throw new IllegalArgumentException("CODE_INVALID"); + java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at"); + if (expireAt.before(new java.util.Date())) { + jdbcTemplate.update("UPDATE sms_codes SET status=2 WHERE id=?", (Long) row.get("id")); + throw new IllegalArgumentException("CODE_EXPIRED"); + } + int failCount = (Integer) row.get("fail_count"); + if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS"); + + String expect = (String) row.get("code_hash"); + String salt = (String) row.get("salt"); + String actual = hmacSha256Hex(salt, req.code); + if (!actual.equalsIgnoreCase(expect)) { + jdbcTemplate.update("UPDATE sms_codes SET fail_count=fail_count+1 WHERE id=?", (Long) row.get("id")); + throw new IllegalArgumentException("CODE_INVALID"); + } + + jdbcTemplate.update("UPDATE sms_codes SET status=1 WHERE id=?", (Long) row.get("id")); + + List existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE phone=? LIMIT 1", Long.class, phone); + Long userId; + Long shopId; + if (!existing.isEmpty()) { + userId = existing.get(0); + List sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId); + shopId = sids.isEmpty() ? 1L : sids.get(0); + // 拉黑校验:status 必须为 1 才允许登录 + Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId); + if (st != null && st.intValue() != 1) { + throw new IllegalArgumentException("你已被管理员拉黑"); + } + } else { + String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim(); + String shopName = String.format(shopDefaults.getNamePattern(), displayName); + var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setString(1, shopName); + return ps; + }, shopKey); + Number shopGenId = shopKey.getKey(); + if (shopGenId == null) throw new IllegalStateException("创建店铺失败"); + shopId = shopGenId.longValue(); + + var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder(); + final Long sid = shopId; + jdbcTemplate.update(con -> { + var ps = con.prepareStatement("INSERT INTO users(shop_id, phone, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, sid); + ps.setString(2, phone); + ps.setString(3, displayName); + ps.setString(4, shopDefaults.getOwnerRole()); + return ps; + }, userKey); + Number userGenId = userKey.getKey(); + if (userGenId == null) throw new IllegalStateException("创建用户失败"); + userId = userGenId.longValue(); + } + + String token = jwtService.signToken(userId, shopId, phone, "sms_otp"); + LoginResponse out = new LoginResponse(); + out.token = token; + out.expiresIn = jwtProps.getTtlSeconds(); + java.util.HashMap userMap = new java.util.HashMap<>(); + userMap.put("userId", userId); + userMap.put("shopId", shopId); + userMap.put("phone", phone); + out.user = userMap; + return out; + } + + private static String maskPhoneForName(String phone) { + String p = String.valueOf(phone); + if (p.length() == 11) { + return "用户" + p.substring(0,3) + "****" + p.substring(7); + } + return "手机用户"; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/BarcodeProxyController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/BarcodeProxyController.java new file mode 100644 index 0000000..c14b7f0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/BarcodeProxyController.java @@ -0,0 +1,126 @@ +package com.example.demo.barcode; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.Duration; + +@RestController +@RequestMapping("/api/barcode") +public class BarcodeProxyController { + + private final PythonBarcodeProperties properties; + private final RestTemplate restTemplate; + private static final Logger log = LoggerFactory.getLogger(BarcodeProxyController.class); + + public BarcodeProxyController(PythonBarcodeProperties properties) { + this.properties = properties; + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis()); + factory.setReadTimeout((int) Duration.ofSeconds(8).toMillis()); + this.restTemplate = new RestTemplate(factory); + } + + @PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity scan(@RequestPart("file") MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body("{\"success\":false,\"message\":\"文件为空\"}"); + } + long maxBytes = (long) properties.getMaxUploadMb() * 1024L * 1024L; + if (file.getSize() > maxBytes) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(String.format("{\"success\":false,\"message\":\"文件过大(> %dMB)\"}", properties.getMaxUploadMb())); + } + String url = String.format("http://%s:%d/api/barcode/scan", properties.getHost(), properties.getPort()); + if (log.isDebugEnabled()) { + log.debug("转发条码识别请求: url={} filename={} size={}B", url, file.getOriginalFilename(), file.getSize()); + } + + // 构建 multipart/form-data 请求转发 + MultiValueMap body = new LinkedMultiValueMap<>(); + HttpHeaders fileHeaders = new HttpHeaders(); + String contentType = file.getContentType(); + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + if (contentType != null && !contentType.isBlank()) { + try { + mediaType = MediaType.parseMediaType(contentType); + } catch (Exception ignored) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + } + fileHeaders.setContentType(mediaType); + String originalName = file.getOriginalFilename(); + if (originalName == null || originalName.isBlank()) { + originalName = file.getName(); + } + if (originalName == null || originalName.isBlank()) { + originalName = "upload.bin"; + } + fileHeaders.setContentDisposition(ContentDisposition.builder("form-data") + .name("file") + .filename(originalName) + .build()); + final String finalFilename = originalName; + ByteArrayResource resource = new ByteArrayResource(file.getBytes()) { + @Override + public String getFilename() { + return finalFilename; + } + }; + HttpEntity fileEntity = new HttpEntity<>(resource, fileHeaders); + body.add("file", fileEntity); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + HttpEntity> req = new HttpEntity<>(body, headers); + + try { + long t0 = System.currentTimeMillis(); + ResponseEntity resp = restTemplate.postForEntity(url, req, String.class); + long cost = System.currentTimeMillis() - t0; + if (log.isDebugEnabled()) { + String bodyStr = resp.getBody(); + if (bodyStr != null && bodyStr.length() > 500) { + bodyStr = bodyStr.substring(0, 500) + "..."; + } + log.debug("转发完成: status={} cost={}ms resp={}", resp.getStatusCodeValue(), cost, bodyStr); + } + return ResponseEntity.status(resp.getStatusCode()) + .contentType(MediaType.APPLICATION_JSON) + .body(resp.getBody()); + } catch (HttpStatusCodeException ex) { + String bodyStr = ex.getResponseBodyAsString(); + if (bodyStr != null && bodyStr.length() > 500) { + bodyStr = bodyStr.substring(0, 500) + "..."; + } + log.warn("Python 服务返回非 2xx: status={} body={}", ex.getStatusCode(), bodyStr); + MediaType respType = ex.getResponseHeaders() != null + ? ex.getResponseHeaders().getContentType() + : MediaType.APPLICATION_JSON; + if (respType == null) { + respType = MediaType.APPLICATION_JSON; + } + return ResponseEntity.status(ex.getStatusCode()) + .contentType(respType) + .body(ex.getResponseBodyAsString()); + } catch (Exception ex) { + // Python 服务不可用或超时等异常 + log.warn("转发到 Python 服务失败: {}:{} path=/api/barcode/scan, err={}", properties.getHost(), properties.getPort(), ex.toString()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"success\":false,\"message\":\"识别服务不可用,请稍后重试\"}"); + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java new file mode 100644 index 0000000..9d8a468 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java @@ -0,0 +1,42 @@ +package com.example.demo.barcode; + +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class PythonBarcodeAutoStarter implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(PythonBarcodeAutoStarter.class); + private final PythonBarcodeProcessManager manager; + private final PythonBarcodeProperties properties; + + public PythonBarcodeAutoStarter(PythonBarcodeProcessManager manager, PythonBarcodeProperties properties) { + this.manager = manager; + this.properties = properties; + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.isEnabled()) { + log.info("Python 条码识别服务未启用 (python.barcode.enabled=false)"); + return; + } + log.info("启动 Python 条码识别服务..."); + manager.startIfEnabled(); + log.info("Python 条码识别服务已就绪"); + } + + @PreDestroy + public void onShutdown() { + if (properties.isEnabled()) { + log.info("停止 Python 条码识别服务..."); + manager.stopIfRunning(); + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java new file mode 100644 index 0000000..62e18ed --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java @@ -0,0 +1,107 @@ +package com.example.demo.barcode; + +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Component +public class PythonBarcodeProcessManager { + + private final PythonBarcodeProperties properties; + private final RestTemplate restTemplate; + private Process process; + + public PythonBarcodeProcessManager(PythonBarcodeProperties properties) { + this.properties = properties; + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis()); + factory.setReadTimeout((int) Duration.ofSeconds(2).toMillis()); + this.restTemplate = new RestTemplate(factory); + } + + public synchronized void startIfEnabled() { + if (!properties.isEnabled()) { + return; + } + if (isAlive()) { + return; + } + + List cmd = new ArrayList<>(); + cmd.add(properties.getPython()); + if (properties.isUseModuleMain()) { + cmd.add("-m"); + cmd.add(properties.getAppModule()); + } else { + // 预留:可扩展为自定义脚本路径 + cmd.add("-m"); + cmd.add(properties.getAppModule()); + } + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(new File(properties.getWorkingDir())); + pb.redirectErrorStream(true); + if (StringUtils.hasText(properties.getLogFile())) { + pb.redirectOutput(new File(properties.getLogFile())); + } + + try { + process = pb.start(); + } catch (IOException e) { + throw new RuntimeException("启动 Python 条码服务失败: " + e.getMessage(), e); + } + + // 等待健康检查 + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(properties.getStartupTimeoutSec()); + while (System.currentTimeMillis() < deadline) { + if (checkHealth()) { + return; + } + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + throw new RuntimeException("Python 条码服务在超时时间内未就绪"); + } + + public synchronized void stopIfRunning() { + if (process != null) { + process.destroy(); + try { + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + public boolean isAlive() { + return process != null && process.isAlive(); + } + + public boolean checkHealth() { + String url = String.format("http://%s:%d%s", properties.getHost(), properties.getPort(), properties.getHealthPath()); + try { + restTemplate.getForObject(url, String.class); + return true; + } catch (RestClientException ex) { + return false; + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java new file mode 100644 index 0000000..bfd0b3c --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java @@ -0,0 +1,132 @@ +package com.example.demo.barcode; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "python.barcode") +public class PythonBarcodeProperties { + + /** 是否在后端启动时同时启动 Python 服务 */ + private boolean enabled = false; + + /** Python 服务运行目录(需包含 app 与 config 目录),相对后端工作目录 */ + private String workingDir = "./txm"; + + /** Python 解释器命令(如 python 或 python3 或 venv 路径)*/ + private String python = "python"; + + /** 以模块方式启动的模块名(例如 app.server.main)*/ + private String appModule = "app.server.main"; + + /** 是否使用 `python -m app.server.main` 启动(否则自行指定命令)*/ + private boolean useModuleMain = true; + + /** Python 服务监听地址(供 Java 代理转发与健康探测用)*/ + private String host = "127.0.0.1"; + + /** Python 服务监听端口 */ + private int port = 8000; + + /** 健康检查路径(GET),FastAPI 默认可用 openapi.json */ + private String healthPath = "/openapi.json"; + + /** 启动等待超时(秒)*/ + private int startupTimeoutSec = 20; + + /** 可选:将 Python 输出重定向到文件(为空则继承控制台)*/ + private String logFile = ""; + + /** 上传大小限制(MB),用于 Java 侧预校验,需与 Python 端配置保持一致 */ + private int maxUploadMb = 8; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getWorkingDir() { + return workingDir; + } + + public void setWorkingDir(String workingDir) { + this.workingDir = workingDir; + } + + public String getPython() { + return python; + } + + public void setPython(String python) { + this.python = python; + } + + public String getAppModule() { + return appModule; + } + + public void setAppModule(String appModule) { + this.appModule = appModule; + } + + public boolean isUseModuleMain() { + return useModuleMain; + } + + public void setUseModuleMain(boolean useModuleMain) { + this.useModuleMain = useModuleMain; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getHealthPath() { + return healthPath; + } + + public void setHealthPath(String healthPath) { + this.healthPath = healthPath; + } + + public int getStartupTimeoutSec() { + return startupTimeoutSec; + } + + public void setStartupTimeoutSec(int startupTimeoutSec) { + this.startupTimeoutSec = startupTimeoutSec; + } + + public String getLogFile() { + return logFile; + } + + public void setLogFile(String logFile) { + this.logFile = logFile; + } + + public int getMaxUploadMb() { + return maxUploadMb; + } + + public void setMaxUploadMb(int maxUploadMb) { + this.maxUploadMb = maxUploadMb; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AccountDefaultsProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AccountDefaultsProperties.java new file mode 100644 index 0000000..965af62 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AccountDefaultsProperties.java @@ -0,0 +1,25 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.account.defaults") +public class AccountDefaultsProperties { + + private String cashName = "现金"; + private String bankName = "银行存款"; + private String wechatName = "微信"; + private String alipayName = "支付宝"; + + public String getCashName() { return cashName; } + public void setCashName(String cashName) { this.cashName = cashName; } + public String getBankName() { return bankName; } + public void setBankName(String bankName) { this.bankName = bankName; } + public String getWechatName() { return wechatName; } + public void setWechatName(String wechatName) { this.wechatName = wechatName; } + public String getAlipayName() { return alipayName; } + public void setAlipayName(String alipayName) { this.alipayName = alipayName; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AdminAuthInterceptor.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AdminAuthInterceptor.java new file mode 100644 index 0000000..a79760e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AdminAuthInterceptor.java @@ -0,0 +1,90 @@ +package com.example.demo.common; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.lang.NonNull; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private final JdbcTemplate jdbcTemplate; + + @Value("${admin.auth.header-name:X-Admin-Id}") + private String adminHeaderName; + + public AdminAuthInterceptor(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception { + // 预检请求直接放行(由 CORS 处理器返回允许头) + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + // 允许登录端点无鉴权 + String path = request.getRequestURI(); + if (path != null && path.startsWith("/api/admin/auth/login")) { + return true; + } + + // 优先支持 Bearer Token + String authorization = request.getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + try { + com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils + .getRequiredWebApplicationContext(request.getServletContext()) + .getBean(com.example.demo.auth.JwtService.class); + java.util.Map claims = jwtSvc.parseClaims(authorization); + Object aid = claims.get("adminId"); + if (aid instanceof Long a) { + Integer status = jdbcTemplate.query( + "SELECT status FROM admins WHERE id=? LIMIT 1", + ps -> ps.setLong(1, a), + rs -> rs.next() ? rs.getInt(1) : null + ); + if (status != null && status == 1) return true; + } + } catch (Exception ignore) { } + } + + // 回退到兼容请求头 X-Admin-Id + String adminIdHeader = request.getHeader(adminHeaderName); + if (adminIdHeader == null || adminIdHeader.isBlank()) { + // 进一步兼容:若前端仍使用 X-User-Id,则尝试以其作为管理员ID进行校验 + String userIdHeader = request.getHeader("X-User-Id"); + if (userIdHeader != null && !userIdHeader.isBlank()) { + try { + Long maybeAdminId = Long.valueOf(userIdHeader); + Integer stat = jdbcTemplate.query( + "SELECT status FROM admins WHERE id=? LIMIT 1", + ps -> ps.setLong(1, maybeAdminId), + rs -> rs.next() ? rs.getInt(1) : null + ); + if (stat != null && stat == 1) return true; + } catch (Exception ignore) { } + } + response.sendError(401, "missing " + adminHeaderName); + return false; + } + Long adminId; + try { adminId = Long.valueOf(adminIdHeader); } catch (Exception e) { response.sendError(401, "invalid admin"); return false; } + Integer status = jdbcTemplate.query( + "SELECT status FROM admins WHERE id=? LIMIT 1", + ps -> ps.setLong(1, adminId), + rs -> rs.next() ? rs.getInt(1) : null + ); + if (status == null || status != 1) { + response.sendError(403, "forbidden"); + return false; + } + return true; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AppDefaultsProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AppDefaultsProperties.java new file mode 100644 index 0000000..402c50e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/AppDefaultsProperties.java @@ -0,0 +1,54 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.defaults") +public class AppDefaultsProperties { + + private Long shopId = 1L; + private Long userId = 2L; + // 字典(全局)使用的虚拟店铺ID,方案A:shop_id=0 代表全局共享 + private Long dictShopId = 0L; + + // 默认账户名称(可配置,避免硬编码) + private String accountCashName = "现金"; + private String accountBankName = "银行存款"; + private String accountWechatName = "微信"; + private String accountAlipayName = "支付宝"; + + // 默认往来单位名称(配置化,避免硬编码) + private String customerName = "散客"; + private String supplierName = "默认供应商"; + + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Long getDictShopId() { return dictShopId; } + public void setDictShopId(Long dictShopId) { this.dictShopId = dictShopId; } + + public String getAccountCashName() { return accountCashName; } + public void setAccountCashName(String accountCashName) { this.accountCashName = accountCashName; } + public String getAccountBankName() { return accountBankName; } + public void setAccountBankName(String accountBankName) { this.accountBankName = accountBankName; } + public String getAccountWechatName() { return accountWechatName; } + public void setAccountWechatName(String accountWechatName) { this.accountWechatName = accountWechatName; } + public String getAccountAlipayName() { return accountAlipayName; } + public void setAccountAlipayName(String accountAlipayName) { this.accountAlipayName = accountAlipayName; } + + public String getCustomerName() { return customerName; } + public void setCustomerName(String customerName) { this.customerName = customerName; } + + public String getSupplierName() { return supplierName; } + public void setSupplierName(String supplierName) { this.supplierName = supplierName; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/CorsConfig.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/CorsConfig.java new file mode 100644 index 0000000..def9876 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/CorsConfig.java @@ -0,0 +1,32 @@ +package com.example.demo.common; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Arrays; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Value("${app.cors.allowed-origins:*}") + private String allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + String[] origins = Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + registry.addMapping("/**") + .allowedOrigins(origins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("Content-Disposition") + .allowCredentials(false) + .maxAge(3600); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/DefaultSeedService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/DefaultSeedService.java new file mode 100644 index 0000000..f25264c --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/DefaultSeedService.java @@ -0,0 +1,41 @@ +package com.example.demo.common; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DefaultSeedService { + + private final JdbcTemplate jdbcTemplate; + private final AppDefaultsProperties appDefaults; + + public DefaultSeedService(JdbcTemplate jdbcTemplate, AppDefaultsProperties appDefaults) { + this.jdbcTemplate = jdbcTemplate; + this.appDefaults = appDefaults; + } + + /** + * 幂等初始化:为新店铺创建默认客户/供应商(若不存在)。 + */ + @Transactional + public void initializeForShop(Long shopId, Long userId) { + if (shopId == null || userId == null) return; + // 默认客户 + jdbcTemplate.update( + "INSERT INTO customers (shop_id,user_id,name,price_level,status,created_at,updated_at) " + + "SELECT ?, ?, ?, 'retail', 1, NOW(), NOW() FROM DUAL " + + "WHERE NOT EXISTS (SELECT 1 FROM customers WHERE shop_id=? AND name=?)", + shopId, userId, appDefaults.getCustomerName(), shopId, appDefaults.getCustomerName() + ); + // 默认供应商 + jdbcTemplate.update( + "INSERT INTO suppliers (shop_id,user_id,name,status,created_at,updated_at) " + + "SELECT ?, ?, ?, 1, NOW(), NOW() FROM DUAL " + + "WHERE NOT EXISTS (SELECT 1 FROM suppliers WHERE shop_id=? AND name=?)", + shopId, userId, appDefaults.getSupplierName(), shopId, appDefaults.getSupplierName() + ); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailProperties.java new file mode 100644 index 0000000..1843f5f --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailProperties.java @@ -0,0 +1,19 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.mail") +public class EmailProperties { + private String from; + private String subjectPrefix; + + public String getFrom() { return from; } + public void setFrom(String from) { this.from = from; } + + public String getSubjectPrefix() { return subjectPrefix; } + public void setSubjectPrefix(String subjectPrefix) { this.subjectPrefix = subjectPrefix; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailSenderService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailSenderService.java new file mode 100644 index 0000000..df5b115 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/EmailSenderService.java @@ -0,0 +1,57 @@ +package com.example.demo.common; + +import jakarta.mail.internet.MimeMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +@Service +public class EmailSenderService { + private final JavaMailSender mailSender; + private final EmailProperties props; + + @Value("${spring.mail.username:}") + private String mailUsername; + + public EmailSenderService(JavaMailSender mailSender, EmailProperties props) { + this.mailSender = mailSender; + this.props = props; + } + + public void sendPlainText(String to, String subject, String content) { + if (to == null || to.isBlank()) throw new IllegalArgumentException("收件人邮箱不能为空"); + if (subject == null) subject = ""; + if (content == null) content = ""; + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); + helper.setFrom(resolveFromAddress()); + helper.setTo(to.trim()); + helper.setSubject(composeSubject(subject)); + helper.setText(content, false); + mailSender.send(message); + } catch (IllegalStateException | IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("发送邮件失败: " + e.getMessage(), e); + } + } + + private String composeSubject(String subject) { + String prefix = props.getSubjectPrefix(); + if (prefix == null || prefix.isBlank()) return subject; + return prefix + " " + subject; + } + + private String resolveFromAddress() { + String from = props.getFrom(); + if (from == null || from.isBlank()) from = mailUsername; + if (from == null || from.isBlank()) { + throw new IllegalStateException("邮件服务未配置,请设置 MAIL_USERNAME/MAIL_PASSWORD 以及 MAIL_FROM 或 spring.mail.username"); + } + return from.trim(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceController.java new file mode 100644 index 0000000..38dc342 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceController.java @@ -0,0 +1,29 @@ +package com.example.demo.common; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class FinanceController { + + private final FinanceService financeService; + private final AppDefaultsProperties defaults; + + public FinanceController(FinanceService financeService, AppDefaultsProperties defaults) { + this.financeService = financeService; + this.defaults = defaults; + } + + @GetMapping("/api/finance/categories") + public ResponseEntity listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Map body = financeService.getCategories(sid); + return ResponseEntity.ok(body); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceDefaultsProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceDefaultsProperties.java new file mode 100644 index 0000000..38797dc --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceDefaultsProperties.java @@ -0,0 +1,21 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.finance") +public class FinanceDefaultsProperties { + + // 形如 key:label, key:label 用逗号分隔 + private String incomeCategories; + private String expenseCategories; + + public String getIncomeCategories() { return incomeCategories; } + public void setIncomeCategories(String incomeCategories) { this.incomeCategories = incomeCategories; } + + public String getExpenseCategories() { return expenseCategories; } + public void setExpenseCategories(String expenseCategories) { this.expenseCategories = expenseCategories; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceService.java new file mode 100644 index 0000000..f231755 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/FinanceService.java @@ -0,0 +1,129 @@ +package com.example.demo.common; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class FinanceService { + + private static final String PARAM_KEY = "finance.categories"; + + private final SystemParameterRepository systemParameterRepository; + private final FinanceDefaultsProperties financeDefaultsProperties; + private final javax.sql.DataSource dataSource; + private final AppDefaultsProperties appDefaults; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public FinanceService(SystemParameterRepository systemParameterRepository, FinanceDefaultsProperties financeDefaultsProperties, javax.sql.DataSource dataSource, AppDefaultsProperties appDefaults) { + this.systemParameterRepository = systemParameterRepository; + this.financeDefaultsProperties = financeDefaultsProperties; + this.dataSource = dataSource; + this.appDefaults = appDefaults; + } + + public Map getCategories(Long shopId) { + Map body = new HashMap<>(); + // 0) 优先从 finance_categories 表读取(避免中文乱码/统一排序) + List> income = queryCategoriesFromTable(shopId, "income"); + List> expense = queryCategoriesFromTable(shopId, "expense"); + + // 1) 回落读取 system_parameters + try { + if (income == null || income.isEmpty() || expense == null || expense.isEmpty()) { + Optional opt = systemParameterRepository.findByShopIdAndKey(shopId, PARAM_KEY); + if (opt.isPresent()) { + String json = opt.get().getValue(); + if (json != null && !json.isBlank()) { + JsonNode root = objectMapper.readTree(json); + if (income == null || income.isEmpty()) { + JsonNode incNode = root.get("income"); + if (incNode != null && incNode.isArray()) { + income = objectMapper.convertValue(incNode, new TypeReference>>(){}); + } + } + if (expense == null || expense.isEmpty()) { + JsonNode expNode = root.get("expense"); + if (expNode != null && expNode.isArray()) { + expense = objectMapper.convertValue(expNode, new TypeReference>>(){}); + } + } + } + } + } + } catch (Exception ignored) { + // 忽略异常,回落至默认配置 + } + + // 2) 回落:应用配置 app.finance.* + if (income == null || income.isEmpty()) { + income = parsePairs(financeDefaultsProperties.getIncomeCategories()); + } + if (expense == null || expense.isEmpty()) { + expense = parsePairs(financeDefaultsProperties.getExpenseCategories()); + } + + body.put("incomeCategories", income); + body.put("expenseCategories", expense); + return body; + } + + private List> parsePairs(String pairs) { + if (pairs == null || pairs.isBlank()) return Collections.emptyList(); + return Arrays.stream(pairs.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> { + int idx = s.indexOf(":"); + String key = idx > 0 ? s.substring(0, idx).trim() : s.trim(); + String label = idx > 0 ? s.substring(idx + 1).trim() : key; + Map m = new HashMap<>(); + m.put("key", key); + m.put("label", label); + return m; + }) + .collect(Collectors.toList()); + } + + private List> queryCategoriesFromTable(Long shopId, String type) { + Long fallbackShopId = appDefaults == null ? 1L : (appDefaults.getShopId() == null ? 1L : appDefaults.getShopId()); + Long dictShopId = appDefaults == null ? 1000L : (appDefaults.getDictShopId() == null ? 1000L : appDefaults.getDictShopId()); + try (java.sql.Connection c = dataSource.getConnection(); + java.sql.PreparedStatement ps = c.prepareStatement( + "SELECT shop_id, `key`, label FROM finance_categories WHERE shop_id IN (?, ?, ?) AND type=? AND status=1 " + + "ORDER BY CASE WHEN shop_id=? THEN 0 WHEN shop_id=? THEN 1 ELSE 2 END, sort_order, id")) { + ps.setLong(1, shopId); + ps.setLong(2, fallbackShopId); + ps.setLong(3, dictShopId); + ps.setString(4, type); + ps.setLong(5, shopId); + ps.setLong(6, dictShopId); + try (java.sql.ResultSet rs = ps.executeQuery()) { + java.util.Map firstByKey = new java.util.LinkedHashMap<>(); + while (rs.next()) { + String key = rs.getString(2); + String label = rs.getString(3); + if (!firstByKey.containsKey(key)) { + firstByKey.put(key, label); + } + } + java.util.List> list = new java.util.ArrayList<>(); + for (java.util.Map.Entry e : firstByKey.entrySet()) { + java.util.Map m = new java.util.HashMap<>(); + m.put("key", e.getKey()); + m.put("label", e.getValue()); + list.add(m); + } + return list; + } + } catch (Exception ignored) { + return java.util.Collections.emptyList(); + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/GlobalExceptionHandler.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..bf2175e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.example.demo.common; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private ResponseEntity> badRequest(String message) { + Map body = new HashMap<>(); + body.put("message", message); + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + log.warn("Bad request: {}", ex.getMessage()); + return badRequest(ex.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException ex) { + log.warn("Illegal state: {}", ex.getMessage()); + return badRequest(ex.getMessage()); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDataAccess(DataAccessException ex) { + log.error("DataAccessException", ex); + return badRequest("数据库操作失败"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAny(Exception ex) { + log.error("Unhandled exception", ex); + Map body = new HashMap<>(); + body.put("message", ex.getMessage() == null ? "Internal Server Error" : ex.getMessage()); + // 附加异常类型,便于前端调试 + body.put("error", ex.getClass().getSimpleName()); + return ResponseEntity.status(500).body(body); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/JsonUtils.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/JsonUtils.java new file mode 100644 index 0000000..f300c61 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/JsonUtils.java @@ -0,0 +1,39 @@ +package com.example.demo.common; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +public final class JsonUtils { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private JsonUtils() {} + + public static String toJson(Object value) { + if (value == null) return null; + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("JSON 序列化失败", e); + } + } + + public static T fromJson(String json, Class clazz) { + if (json == null || json.isBlank()) return null; + try { + return MAPPER.readValue(json, clazz); + } catch (Exception e) { + throw new IllegalArgumentException("JSON 解析失败", e); + } + } + + public static T fromJson(String json, TypeReference type) { + if (json == null || json.isBlank()) return null; + try { + return MAPPER.readValue(json, type); + } catch (Exception e) { + throw new IllegalArgumentException("JSON 解析失败", e); + } + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/NormalAdminAuthInterceptor.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/NormalAdminAuthInterceptor.java new file mode 100644 index 0000000..de33398 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/NormalAdminAuthInterceptor.java @@ -0,0 +1,89 @@ +package com.example.demo.common; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 普通管理端(admin-lite)鉴权拦截器 + * 要求: + * - 仅拦截 /api/normal-admin/parts/** + * - 通过 X-User-Id 校验 users.status=1 且 role='normal_admin' + * - 若要求 VIP 有效(NORMAL_ADMIN_REQUIRE_VIP_ACTIVE=true),校验 vip_users 有效期 + */ +@Component +public class NormalAdminAuthInterceptor implements HandlerInterceptor { + + private final JdbcTemplate jdbcTemplate; + + public NormalAdminAuthInterceptor(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + // 仅拦截 /api/normal-admin/parts/** + String path = request.getRequestURI(); + if (path == null || !path.startsWith("/api/normal-admin/parts/")) { + return true; + } + + String userIdHeader = request.getHeader("X-User-Id"); + if (userIdHeader == null || userIdHeader.isBlank()) { + response.sendError(401, "missing X-User-Id"); + return false; + } + long userId; + try { userId = Long.parseLong(userIdHeader); } catch (Exception e) { response.sendError(401, "invalid user"); return false; } + + // 校验普通管理员角色 + var row = jdbcTemplate.query( + "SELECT u.status,u.role,u.shop_id FROM users u WHERE u.id=? LIMIT 1", + ps -> ps.setLong(1, userId), + rs -> rs.next() ? new Object[]{ rs.getInt(1), rs.getString(2), rs.getLong(3) } : null + ); + if (row == null) { response.sendError(401, "user not found"); return false; } + int status = (int) row[0]; + String role = (String) row[1]; + long shopId = (long) row[2]; + if (status != 1 || role == null || !"normal_admin".equalsIgnoreCase(role.trim())) { + response.sendError(403, "forbidden"); + return false; + } + + // 可选校验:VIP 有效 + boolean requireVip; + try { + String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.requiredVipActive' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v == null) requireVip = true; else { + v = v.trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1); + requireVip = "true".equalsIgnoreCase(v) || "1".equals(v); + } + } catch (Exception e) { requireVip = true; } + if (requireVip) { + Integer vipOk = jdbcTemplate.query( + "SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1", + ps -> { ps.setLong(1, userId); ps.setLong(2, shopId); }, + rs -> rs.next() ? rs.getInt(1) : 0 + ); + if (vipOk == null || vipOk != 1) { + response.sendError(403, "vip expired or not active"); + return false; + } + } + + // 通过:允许进入控制器 + return true; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/RequestLoggingFilter.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/RequestLoggingFilter.java new file mode 100644 index 0000000..b3d5f3f --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/RequestLoggingFilter.java @@ -0,0 +1,63 @@ +package com.example.demo.common; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.lang.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + String query = request.getQueryString(); + String shopId = request.getHeader("X-Shop-Id"); + String userId = request.getHeader("X-User-Id"); + if ((shopId == null || shopId.isBlank()) && (userId == null || userId.isBlank())) { + String auth = request.getHeader("Authorization"); + try { + com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils + .getRequiredWebApplicationContext(request.getServletContext()) + .getBean(com.example.demo.auth.JwtService.class); + java.util.Map claims = jwtSvc.parseClaims(auth); + Object sid = claims.get("shopId"); + Object uid = claims.get("userId"); + if (sid != null) shopId = String.valueOf(sid); + if (uid != null) userId = String.valueOf(uid); + } catch (Exception ignore) { } + } + + try { + filterChain.doFilter(request, response); + } finally { + long cost = System.currentTimeMillis() - start; + int status = response.getStatus(); + if (log.isDebugEnabled()) { + log.debug("{} {}{} | status={} cost={}ms | shopId={} userId={}", + method, + uri, + (query == null ? "" : ("?" + query)), + status, + cost, + (shopId == null ? "" : shopId), + (userId == null ? "" : userId)); + } + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SearchFuzzyProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SearchFuzzyProperties.java new file mode 100644 index 0000000..b79ec5a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SearchFuzzyProperties.java @@ -0,0 +1,22 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +@ConfigurationProperties(prefix = "search.fuzzy") +public class SearchFuzzyProperties { + + private boolean enabled = true; + private BigDecimal defaultTolerance = new BigDecimal("1.0"); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public BigDecimal getDefaultTolerance() { return defaultTolerance; } + public void setDefaultTolerance(BigDecimal defaultTolerance) { this.defaultTolerance = defaultTolerance; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/ShopDefaultsProperties.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/ShopDefaultsProperties.java new file mode 100644 index 0000000..efe691a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/ShopDefaultsProperties.java @@ -0,0 +1,23 @@ +package com.example.demo.common; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.shop") +public class ShopDefaultsProperties { + + // 店铺名称规则,使用 String.format 占位:%s -> 用户名 + private String namePattern = "%s_1"; + + // 店主角色名称 + private String ownerRole = "owner"; + + public String getNamePattern() { return namePattern; } + public void setNamePattern(String namePattern) { this.namePattern = namePattern; } + + public String getOwnerRole() { return ownerRole; } + public void setOwnerRole(String ownerRole) { this.ownerRole = ownerRole; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameter.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameter.java new file mode 100644 index 0000000..7de9102 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameter.java @@ -0,0 +1,48 @@ +package com.example.demo.common; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "system_parameters") +public class SystemParameter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "`key`", nullable = false, length = 64) + private String key; + + @Column(name = "value", nullable = false, columnDefinition = "JSON") + private String value; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameterRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameterRepository.java new file mode 100644 index 0000000..5480f8b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/SystemParameterRepository.java @@ -0,0 +1,11 @@ +package com.example.demo.common; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SystemParameterRepository extends JpaRepository { + Optional findByShopIdAndKey(Long shopId, String key); +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/WebConfig.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/WebConfig.java new file mode 100644 index 0000000..1ac2ad5 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/common/WebConfig.java @@ -0,0 +1,48 @@ +package com.example.demo.common; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.lang.NonNull; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AdminAuthInterceptor adminAuthInterceptor; + private final NormalAdminAuthInterceptor normalAdminAuthInterceptor; + + public WebConfig(AdminAuthInterceptor adminAuthInterceptor, NormalAdminAuthInterceptor normalAdminAuthInterceptor) { + this.adminAuthInterceptor = adminAuthInterceptor; + this.normalAdminAuthInterceptor = normalAdminAuthInterceptor; + } + + @Override + public void addInterceptors(@NonNull InterceptorRegistry registry) { + // 注册管理端鉴权拦截器:保护 /api/admin/** + InterceptorRegistration r = registry.addInterceptor(adminAuthInterceptor); + r.addPathPatterns("/api/admin/**"); + // 放行登录接口 + r.excludePathPatterns("/api/admin/auth/login"); + + // 注册普通管理端拦截器:保护 /api/normal-admin/parts/** + InterceptorRegistration nr = registry.addInterceptor(normalAdminAuthInterceptor); + nr.addPathPatterns("/api/normal-admin/parts/**"); + } + + @Override + public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) { + // 将 /static/** 映射到前端静态资源目录(开发时)与 classpath 静态目录(部署时) + String userDir = System.getProperty("user.dir"); + String frontendStatic = userDir + java.io.File.separator + "frontend" + java.io.File.separator + "static" + java.io.File.separator; + registry.addResourceHandler("/static/**") + .addResourceLocations( + "file:" + frontendStatic, + "classpath:/static/", + "classpath:/public/" + ); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/consult/ConsultController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/consult/ConsultController.java new file mode 100644 index 0000000..b76db71 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/consult/ConsultController.java @@ -0,0 +1,150 @@ +package com.example.demo.consult; + +import com.example.demo.common.AppDefaultsProperties; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/consults") +public class ConsultController { + + private final JdbcTemplate jdbcTemplate; + private final AppDefaultsProperties defaults; + + public ConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) { + this.jdbcTemplate = jdbcTemplate; + this.defaults = defaults; + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody Map body) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + if (body == null) body = new HashMap<>(); + String topic = ""; // 主题字段已废弃 + String message = Optional.ofNullable(body.get("message")).map(String::valueOf).orElse(null); + if (message == null || message.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "message required")); + } + jdbcTemplate.update("INSERT INTO consults (shop_id,user_id,topic,message,status,created_at,updated_at) VALUES (?,?,?,?, 'open', NOW(), NOW())", + sid, uid, topic, message); + Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + Map resp = new HashMap<>(); + resp.put("id", id); + return ResponseEntity.ok(resp); + } + + @GetMapping("/latest") + public ResponseEntity latest(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + List> list = jdbcTemplate.query( + "SELECT id, topic, message, status, created_at FROM consults WHERE shop_id=? AND user_id=? ORDER BY id DESC LIMIT 1", + ps -> { ps.setLong(1, sid); ps.setLong(2, uid); }, + (rs, i) -> { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("topic", rs.getString("topic")); + m.put("message", rs.getString("message")); + m.put("status", rs.getString("status")); + m.put("createdAt", rs.getTimestamp("created_at")); + return m; + } + ); + if (list.isEmpty()) { + return ResponseEntity.ok(Collections.emptyMap()); + } + Map latest = list.get(0); + Object idObj = latest.get("id"); + Long consultId = (idObj instanceof Number) ? ((Number) idObj).longValue() : Long.valueOf(String.valueOf(idObj)); + Map reply = jdbcTemplate.query( + "SELECT content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id DESC LIMIT 1", + rs -> { + if (rs.next()) { + Map r = new HashMap<>(); + r.put("content", rs.getString("content")); + r.put("createdAt", rs.getTimestamp("created_at")); + return r; + } + return null; + }, consultId + ); + latest.put("replied", Objects.equals("resolved", String.valueOf(latest.get("status")))); + if (reply != null) { + latest.put("latestReply", reply.get("content")); + latest.put("latestReplyAt", reply.get("createdAt")); + } + return ResponseEntity.ok(latest); + } + + // 兼容:GET /api/consults 等同于 /api/consults/latest + @GetMapping + public ResponseEntity latestAlias(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + return latest(shopId, userId); + } + + // 用户确认已读:查看过管理员回复后,将状态回到 open + @PutMapping("/{id}/ack") + public ResponseEntity ack(@PathVariable("id") Long id, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + // 若该咨询属于该用户,则把状态改回 open(仅在当前为 resolved 时) + int updated = jdbcTemplate.update( + "UPDATE consults SET status='open', updated_at=NOW() WHERE id=? AND user_id=? AND status='resolved'", + id, (userId == null ? defaults.getUserId() : userId)); + Map body = new java.util.HashMap<>(); + body.put("updated", updated); + return ResponseEntity.ok(body); + } + + @GetMapping("/{id}") + public ResponseEntity detail(@PathVariable("id") Long id, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + Map consult = jdbcTemplate.query( + "SELECT id, shop_id AS shopId, user_id AS userId, topic, message, status, created_at FROM consults WHERE id=?", + rs -> { + if (rs.next()) { + Map m = new LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("shopId", rs.getLong("shopId")); + m.put("userId", rs.getLong("userId")); + m.put("topic", rs.getString("topic")); + m.put("message", rs.getString("message")); + m.put("status", rs.getString("status")); + m.put("createdAt", rs.getTimestamp("created_at")); + return m; + } + return null; + }, id); + if (consult == null) return ResponseEntity.notFound().build(); + if (userId != null) { + Object ownerObj = consult.get("userId"); + Long ownerId = (ownerObj instanceof Number) ? ((Number) ownerObj).longValue() : Long.valueOf(String.valueOf(ownerObj)); + if (!Objects.equals(ownerId, userId)) { + return ResponseEntity.status(403).body(Map.of("message", "forbidden")); + } + } + List> replies = jdbcTemplate.query( + "SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC", + (rs, i) -> { + Map r = new LinkedHashMap<>(); + r.put("id", rs.getLong("id")); + r.put("userId", rs.getLong("userId")); + r.put("content", rs.getString("content")); + r.put("createdAt", rs.getTimestamp("created_at")); + return r; + }, id); + Map body = new LinkedHashMap<>(); + body.putAll(consult); + body.put("replies", replies); + return ResponseEntity.ok(body); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/controller/CustomerController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/controller/CustomerController.java new file mode 100644 index 0000000..ee49cc7 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/controller/CustomerController.java @@ -0,0 +1,77 @@ +package com.example.demo.customer.controller; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.customer.dto.CustomerDtos; +import com.example.demo.customer.entity.Customer; +import com.example.demo.customer.service.CustomerService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/customers") +public class CustomerController { + + private final CustomerService customerService; + private final AppDefaultsProperties defaults; + + public CustomerController(CustomerService customerService, AppDefaultsProperties defaults) { + this.customerService = customerService; + this.defaults = defaults; + } + + @GetMapping + public ResponseEntity search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "debtOnly", required = false, defaultValue = "false") boolean debtOnly, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "50") int size) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(customerService.search(sid, kw, debtOnly, Math.max(0, page-1), size)); + } + + @GetMapping("/{id}") + public ResponseEntity detail(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId) { + java.util.Optional oc = customerService.findById(id); + if (oc.isEmpty()) return ResponseEntity.notFound().build(); + Customer c = oc.get(); + java.util.Map body = new java.util.HashMap<>(); + body.put("id", c.getId()); + body.put("name", c.getName()); + body.put("contactName", c.getContactName()); + body.put("mobile", c.getMobile()); + body.put("phone", c.getPhone()); + body.put("priceLevel", c.getPriceLevel()); + body.put("remark", c.getRemark()); + body.put("address", c.getAddress()); + body.put("arOpening", c.getArOpening()); + Long sid = (shopId == null ? null : shopId); + if (sid != null) { + body.put("receivable", customerService.computeReceivable(sid, id)); + } + return ResponseEntity.ok(body); + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody CustomerDtos.CreateOrUpdateCustomerRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + Long id = customerService.create(sid, uid, req); + java.util.Map body = new java.util.HashMap<>(); body.put("id", id); return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody CustomerDtos.CreateOrUpdateCustomerRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + customerService.update(id, sid, uid, req); + return ResponseEntity.ok().build(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/dto/CustomerDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/dto/CustomerDtos.java new file mode 100644 index 0000000..b811e9a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/dto/CustomerDtos.java @@ -0,0 +1,34 @@ +package com.example.demo.customer.dto; + +import java.math.BigDecimal; + +public class CustomerDtos { + + public static class CustomerListItem { + public Long id; + public String name; + public String contactName; + public String mobile; + public String phone; + public String priceLevel; + public String remark; + public BigDecimal receivable; + public CustomerListItem() {} + public CustomerListItem(Long id, String name, String contactName, String mobile, String phone, String priceLevel, String remark, BigDecimal receivable) { + this.id = id; this.name = name; this.contactName = contactName; this.mobile = mobile; this.phone = phone; this.priceLevel = priceLevel; this.remark = remark; this.receivable = receivable; + } + } + + public static class CreateOrUpdateCustomerRequest { + public String name; + public String priceLevel; + public String contactName; + public String mobile; + public String phone; + public String address; + public java.math.BigDecimal arOpening; + public String remark; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/entity/Customer.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/entity/Customer.java new file mode 100644 index 0000000..9431f80 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/entity/Customer.java @@ -0,0 +1,88 @@ +package com.example.demo.customer.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "customers") +public class Customer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "name", nullable = false, length = 120) + private String name; + + @Column(name = "phone", length = 32) + private String phone; + + @Column(name = "mobile", length = 32) + private String mobile; + + @Column(name = "address", length = 255) + private String address; + + @Column(name = "contact_name", length = 64) + private String contactName; + + @Column(name = "price_level", nullable = false, length = 32) + private String priceLevel; + + @Column(name = "status", nullable = false) + private Integer status; + + @Column(name = "ar_opening", precision = 18, scale = 2, nullable = false) + private BigDecimal arOpening; + + @Column(name = "remark", length = 255) + private String remark; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getMobile() { return mobile; } + public void setMobile(String mobile) { this.mobile = mobile; } + public String getAddress() { return address; } + public void setAddress(String address) { this.address = address; } + public String getContactName() { return contactName; } + public void setContactName(String contactName) { this.contactName = contactName; } + public String getPriceLevel() { return priceLevel; } + public void setPriceLevel(String priceLevel) { this.priceLevel = priceLevel; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + public BigDecimal getArOpening() { return arOpening; } + public void setArOpening(BigDecimal arOpening) { this.arOpening = arOpening; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/repo/CustomerRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/repo/CustomerRepository.java new file mode 100644 index 0000000..eb834a9 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/repo/CustomerRepository.java @@ -0,0 +1,16 @@ +package com.example.demo.customer.repo; + +import com.example.demo.customer.entity.Customer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CustomerRepository extends JpaRepository { + + @Query("SELECT c FROM Customer c WHERE c.shopId=:shopId AND c.deletedAt IS NULL AND ( :kw IS NULL OR c.name LIKE CONCAT('%',:kw,'%') OR c.mobile LIKE CONCAT('%',:kw,'%') OR c.phone LIKE CONCAT('%',:kw,'%')) ORDER BY c.id DESC") + List search(@Param("shopId") Long shopId, @Param("kw") String kw, org.springframework.data.domain.Pageable pageable); +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/service/CustomerService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/service/CustomerService.java new file mode 100644 index 0000000..a3db21a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/customer/service/CustomerService.java @@ -0,0 +1,98 @@ +package com.example.demo.customer.service; + +import com.example.demo.customer.dto.CustomerDtos; +import com.example.demo.customer.entity.Customer; +import com.example.demo.customer.repo.CustomerRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class CustomerService { + + private final CustomerRepository customerRepository; + private final JdbcTemplate jdbcTemplate; + + public CustomerService(CustomerRepository customerRepository, JdbcTemplate jdbcTemplate) { + this.customerRepository = customerRepository; + this.jdbcTemplate = jdbcTemplate; + } + + public java.util.Map search(Long shopId, String kw, boolean debtOnly, int page, int size) { + List list = customerRepository.search(shopId, kw, PageRequest.of(page, size)); + List items = list.stream().map(c -> new CustomerDtos.CustomerListItem( + c.getId(), c.getName(), c.getContactName(), c.getMobile(), c.getPhone(), c.getPriceLevel(), c.getRemark(), calcReceivable(shopId, c.getId(), c.getArOpening()) + )).collect(Collectors.toList()); + if (debtOnly) { + items = items.stream().filter(it -> it.receivable != null && it.receivable.compareTo(BigDecimal.ZERO) > 0).collect(Collectors.toList()); + } + java.util.Map resp = new java.util.HashMap<>(); + resp.put("list", items); + return resp; + } + + public Optional findById(Long id) { + return customerRepository.findById(id); + } + + @Transactional + public Long create(Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) { + Customer c = new Customer(); + c.setShopId(shopId); c.setUserId(userId); + c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel)); + c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address); + c.setStatus(1); + c.setArOpening(req.arOpening == null ? BigDecimal.ZERO : req.arOpening); + c.setRemark(req.remark); + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + c.setCreatedAt(now); + c.setUpdatedAt(now); + return customerRepository.save(c).getId(); + } + + @Transactional + public void update(Long id, Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) { + Customer c = customerRepository.findById(id).orElseThrow(); + if (!c.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺修改"); + c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel)); + c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address); + if (req.arOpening != null) c.setArOpening(req.arOpening); + c.setRemark(req.remark); + c.setUpdatedAt(java.time.LocalDateTime.now()); + customerRepository.save(c); + } + + private String cnPriceLevelOrDefault(String v) { + if (v == null || v.isBlank()) return "零售价"; + // 兼容历史英文值 + if ("retail".equalsIgnoreCase(v)) return "零售价"; + if ("wholesale".equalsIgnoreCase(v) || "distribution".equalsIgnoreCase(v)) return "批发价"; + if ("big_client".equalsIgnoreCase(v)) return "大单报价"; + return v; // 已是中文 + } + + public BigDecimal computeReceivable(Long shopId, Long customerId) { + Optional oc = customerRepository.findById(customerId); + BigDecimal opening = oc.map(Customer::getArOpening).orElse(BigDecimal.ZERO); + return calcReceivable(shopId, customerId, opening); + } + + private BigDecimal calcReceivable(Long shopId, Long customerId, BigDecimal opening) { + BigDecimal open = opening == null ? BigDecimal.ZERO : opening; + BigDecimal sale = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount - paid_amount),0) FROM sales_orders WHERE shop_id=? AND customer_id=? AND status='approved'", BigDecimal.class, shopId, customerId)); + BigDecimal saleRet = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount - paid_amount),0) FROM sales_return_orders WHERE shop_id=? AND customer_id=? AND status='approved'", BigDecimal.class, shopId, customerId)); + BigDecimal otherIn = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM other_transactions WHERE shop_id=? AND counterparty_type='customer' AND counterparty_id=? AND `type`='income'", BigDecimal.class, shopId, customerId)); + BigDecimal otherOut = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM other_transactions WHERE shop_id=? AND counterparty_type='customer' AND counterparty_id=? AND `type`='expense'", BigDecimal.class, shopId, customerId)); + return open.add(sale).subtract(saleRet).add(otherIn).subtract(otherOut).setScale(2, java.math.RoundingMode.HALF_UP); + } + + private static BigDecimal n(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardController.java new file mode 100644 index 0000000..757747a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardController.java @@ -0,0 +1,29 @@ +package com.example.demo.dashboard; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/dashboard") +public class DashboardController { + + private final DashboardService dashboardService; + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + @GetMapping("/overview") + public ResponseEntity overview( + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId + ) { + return ResponseEntity.ok(dashboardService.getOverviewByUserOrShop(userId, shopId)); + } +} + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java new file mode 100644 index 0000000..37c4f7f --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java @@ -0,0 +1,36 @@ +package com.example.demo.dashboard; + +import java.math.BigDecimal; + +public class DashboardOverviewResponse { + private BigDecimal todaySalesAmount; + private BigDecimal monthSalesAmount; + private BigDecimal monthGrossProfit; + private BigDecimal stockTotalQuantity; + + public DashboardOverviewResponse(BigDecimal todaySalesAmount, BigDecimal monthSalesAmount, BigDecimal monthGrossProfit, BigDecimal stockTotalQuantity) { + this.todaySalesAmount = todaySalesAmount; + this.monthSalesAmount = monthSalesAmount; + this.monthGrossProfit = monthGrossProfit; + this.stockTotalQuantity = stockTotalQuantity; + } + + public BigDecimal getTodaySalesAmount() { + return todaySalesAmount; + } + + public BigDecimal getMonthGrossProfit() { + return monthGrossProfit; + } + + public BigDecimal getStockTotalQuantity() { + return stockTotalQuantity; + } + + public BigDecimal getMonthSalesAmount() { + return monthSalesAmount; + } +} + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardRepository.java new file mode 100644 index 0000000..2084e84 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/dashboard/DashboardRepository.java @@ -0,0 +1,76 @@ +package com.example.demo.dashboard; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; + +@Repository +public class DashboardRepository { + + @PersistenceContext + private EntityManager entityManager; + + public BigDecimal sumTodaySalesOrders(Long shopId) { + Object result = entityManager.createNativeQuery( + "SELECT COALESCE((SELECT SUM(amount) FROM sales_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=CURRENT_DATE() AND order_time=CURRENT_DATE() AND order_time=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND so.order_time=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND sro.order_time=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time> list() { + return ResponseEntity.ok(noticeService.listActive()); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeRepository.java new file mode 100644 index 0000000..b8245a1 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeRepository.java @@ -0,0 +1,17 @@ +package com.example.demo.notice; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + @Query("SELECT n FROM Notice n WHERE n.status = :status " + + "AND (n.startsAt IS NULL OR n.startsAt <= CURRENT_TIMESTAMP) AND (n.endsAt IS NULL OR n.endsAt >= CURRENT_TIMESTAMP) " + + "ORDER BY n.pinned DESC, n.createdAt DESC") + List findActiveNotices(@Param("status") NoticeStatus status); +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeService.java new file mode 100644 index 0000000..c53ca0f --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeService.java @@ -0,0 +1,20 @@ +package com.example.demo.notice; + +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class NoticeService { + private final NoticeRepository noticeRepository; + + public NoticeService(NoticeRepository noticeRepository) { + this.noticeRepository = noticeRepository; + } + + public List listActive() { + return noticeRepository.findActiveNotices(NoticeStatus.PUBLISHED); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatus.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatus.java new file mode 100644 index 0000000..bbf2b41 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatus.java @@ -0,0 +1,12 @@ +package com.example.demo.notice; + +/** + * 公告状态。 + */ +public enum NoticeStatus { + DRAFT, + PUBLISHED, + OFFLINE +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatusConverter.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatusConverter.java new file mode 100644 index 0000000..e968d4c --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/notice/NoticeStatusConverter.java @@ -0,0 +1,39 @@ +package com.example.demo.notice; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = false) +public class NoticeStatusConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(NoticeStatus attribute) { + if (attribute == null) return null; + switch (attribute) { + case DRAFT: + return "draft"; + case PUBLISHED: + return "published"; + case OFFLINE: + return "offline"; + default: + return "published"; + } + } + + @Override + public NoticeStatus convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + switch (dbData) { + case "draft": + return NoticeStatus.DRAFT; + case "published": + return NoticeStatus.PUBLISHED; + case "offline": + return NoticeStatus.OFFLINE; + default: + return NoticeStatus.PUBLISHED; + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderController.java new file mode 100644 index 0000000..34010a0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderController.java @@ -0,0 +1,151 @@ +package com.example.demo.order; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.order.dto.OrderDtos; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +public class OrderController { + + private final OrderService orderService; + private final AppDefaultsProperties defaults; + + public OrderController(OrderService orderService, AppDefaultsProperties defaults) { + this.orderService = orderService; + this.defaults = defaults; + } + + @PostMapping("/orders") + public ResponseEntity createOrder(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody OrderDtos.CreateOrderRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.create(sid, uid, req)); + } + + @PostMapping("/payments/{biz}") + public ResponseEntity createPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @PathVariable("biz") String biz, + @RequestBody java.util.List req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.createPayments(sid, uid, req, biz)); + } + + @PostMapping("/orders/{id}/void") + public ResponseEntity voidOrder(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @PathVariable("id") Long id, + @RequestParam("type") String type) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + orderService.voidOrder(sid, uid, id, type); + return ResponseEntity.ok().build(); + } + + @GetMapping("/orders") + public ResponseEntity list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "biz", required = false) String biz, + @RequestParam(name = "type", required = false) String type, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.list(sid, uid, biz, type, kw, Math.max(0, page-1), size, startDate, endDate)); + } + + @GetMapping("/orders/{id}") + public ResponseEntity getOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @PathVariable("id") Long id) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(orderService.getSalesOrderDetail(sid, id)); + } + + // 兼容前端直接调用 /api/purchase-orders + @GetMapping("/purchase-orders") + public ResponseEntity purchaseOrders(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + String type = ("returned".equalsIgnoreCase(status) ? "purchase.return" : "purchase.in"); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.list(sid, uid, "purchase", type, kw, Math.max(0, page-1), size, startDate, endDate)); + } + + @GetMapping("/purchase-orders/{id}") + public ResponseEntity getPurchaseOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @PathVariable("id") Long id) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(orderService.getPurchaseOrderDetail(sid, id)); + } + + @GetMapping("/payments") + public ResponseEntity listPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "direction", required = false) String direction, + @RequestParam(name = "bizType", required = false) String bizType, + @RequestParam(name = "accountId", required = false) Long accountId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.listPayments(sid, uid, direction, bizType, accountId, kw, Math.max(0, page-1), size, startDate, endDate)); + } + + @GetMapping("/other-transactions") + public ResponseEntity listOtherTransactions(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "type", required = false) String type, + @RequestParam(name = "accountId", required = false) Long accountId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.listOtherTransactions(sid, uid, type, accountId, kw, Math.max(0, page-1), size, startDate, endDate)); + } + + @PostMapping("/other-transactions") + public ResponseEntity createOtherTransaction(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody OrderDtos.CreateOtherTransactionRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.createOtherTransaction(sid, uid, req)); + } + + @GetMapping("/inventories/logs") + public ResponseEntity listInventoryLogs(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "productId", required = false) Long productId, + @RequestParam(name = "reason", required = false) String reason, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + return ResponseEntity.ok(orderService.listInventoryLogs(sid, uid, productId, reason, kw, Math.max(0, page-1), size, startDate, endDate)); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderNumberGenerator.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderNumberGenerator.java new file mode 100644 index 0000000..a04f95e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderNumberGenerator.java @@ -0,0 +1,24 @@ +package com.example.demo.order; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 简单的进程内单号生成器:前缀 + yyyyMMdd + 4位流水 + * 说明:演示用,生产建议使用数据库序列或 Redis 自增确保多实例唯一。 + */ +public class OrderNumberGenerator { + private static final ConcurrentHashMap dateCounters = new ConcurrentHashMap<>(); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public static String next(String prefix) { + String day = LocalDateTime.now().format(DATE); + String key = prefix + day; + int seq = dateCounters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet(); + return prefix + day + String.format("%04d", seq); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderService.java new file mode 100644 index 0000000..1bec6ff --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/OrderService.java @@ -0,0 +1,690 @@ +package com.example.demo.order; + +import com.example.demo.common.AccountDefaultsProperties; +import com.example.demo.order.dto.OrderDtos; +import com.example.demo.product.entity.Inventory; +import com.example.demo.product.entity.ProductPrice; +import com.example.demo.product.repo.InventoryRepository; +import com.example.demo.product.repo.ProductPriceRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class OrderService { + + private final InventoryRepository inventoryRepository; + private final AccountDefaultsProperties accountDefaults; + private final JdbcTemplate jdbcTemplate; + private final ProductPriceRepository productPriceRepository; + + private final com.example.demo.common.AppDefaultsProperties appDefaults; + + public OrderService(InventoryRepository inventoryRepository, + JdbcTemplate jdbcTemplate, + AccountDefaultsProperties accountDefaults, + ProductPriceRepository productPriceRepository, + com.example.demo.common.AppDefaultsProperties appDefaults) { + this.inventoryRepository = inventoryRepository; + this.jdbcTemplate = jdbcTemplate; + this.accountDefaults = accountDefaults; + this.productPriceRepository = productPriceRepository; + this.appDefaults = appDefaults; + } + + @Transactional + public Object create(Long shopId, Long userId, OrderDtos.CreateOrderRequest req) { + String type = req.type == null ? "" : req.type; + boolean isSaleOut = "sale.out".equals(type) || "out".equals(type) || "sale".equals(type); + boolean isPurchaseIn = "purchase.in".equals(type) || "in".equals(type); + boolean isSaleReturn = "sale.return".equals(type); + boolean isPurchaseReturn = "purchase.return".equals(type); + boolean isSaleCollect = "sale.collect".equals(type); + boolean isPurchasePay = "purchase.pay".equals(type); + + if (isSaleCollect || isPurchasePay) { + java.util.List payments = req.payments == null ? java.util.List.of() : req.payments; + return createPayments(shopId, userId, payments, isSaleCollect ? "sale" : "purchase"); + } + + if (!(isSaleOut || isPurchaseIn || isSaleReturn || isPurchaseReturn)) throw new IllegalArgumentException("不支持的type"); + if (req.items == null || req.items.isEmpty()) throw new IllegalArgumentException("明细为空"); + + // 后端重算金额 + final BigDecimal[] totalRef = new BigDecimal[]{BigDecimal.ZERO}; + for (OrderDtos.Item it : req.items) { + BigDecimal qty = n(it.quantity); + BigDecimal price = n(it.unitPrice); + BigDecimal dr = n(it.discountRate); + BigDecimal line = qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100")))); + totalRef[0] = totalRef[0].add(scale2(line)); + } + + // 预取成本价格(仅销售出库/退货需要) + Map costPriceCache = new HashMap<>(); + if (isSaleOut || isSaleReturn) { + for (OrderDtos.Item it : req.items) { + Long pid = it.productId; + if (pid == null || costPriceCache.containsKey(pid)) continue; + costPriceCache.put(pid, resolveProductCostPrice(pid, shopId)); + } + } + + // 库存变动(保存即 approved) + LocalDateTime now = nowUtc(); + for (OrderDtos.Item it : req.items) { + Long pid = it.productId; + Inventory inv = inventoryRepository.findById(pid).orElseGet(Inventory::new); + inv.setProductId(pid); + inv.setShopId(shopId); + inv.setUserId(userId); + BigDecimal cur = n(inv.getQuantity()); + BigDecimal delta = BigDecimal.ZERO; + if (isSaleOut) delta = n(it.quantity).negate(); + if (isPurchaseIn) delta = n(it.quantity); + if (isSaleReturn) delta = n(it.quantity); // 退货入库 + if (isPurchaseReturn) delta = n(it.quantity).negate(); // 退货出库 + BigDecimal next = cur.add(delta); + if (isSaleOut && next.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("库存不足"); + } + inv.setQuantity(next); + inv.setUpdatedAt(now); + inventoryRepository.save(inv); + + // 写入库存流水(可选金额) + String imSql = "INSERT INTO inventory_movements (shop_id,user_id,product_id,source_type,source_id,qty_delta,amount_delta,cost_price,cost_amount,reason,tx_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW())"; + String sourceType = isSaleOut ? "sale" : (isPurchaseIn ? "purchase" : (isSaleReturn ? "sale_return" : "purchase_return")); + BigDecimal costPrice = null; + BigDecimal costAmount = null; + if (isSaleOut || isSaleReturn) { + costPrice = costPriceCache.getOrDefault(pid, BigDecimal.ZERO); + costAmount = scale2(costPrice.multiply(delta)); + } + jdbcTemplate.update(imSql, shopId, userId, pid, sourceType, null, delta, null, costPrice, costAmount, + null, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant()), null); + } + + String prefix = isSaleOut || isSaleReturn ? (isSaleReturn ? "SR" : "SO") : (isPurchaseReturn ? "PR" : "PO"); + String orderNo = OrderNumberGenerator.next(prefix); + + // 持久化订单头与明细(简化使用 JDBC) + String headTable = isSaleOut ? "sales_orders" : (isPurchaseIn ? "purchase_orders" : (isSaleReturn ? "sales_return_orders" : "purchase_return_orders")); + String itemTable = isSaleOut ? "sales_order_items" : (isPurchaseIn ? "purchase_order_items" : (isSaleReturn ? "sales_return_order_items" : "purchase_return_order_items")); + + // insert head(按表结构分别处理 sales*/purchase* 的 customer_id/supplier_id 列) + KeyHolder kh = new GeneratedKeyHolder(); + boolean isSalesHead = headTable.startsWith("sales"); + boolean isPurchaseHead = headTable.startsWith("purchase"); + String headSql; + if (isSalesHead) { + headSql = "INSERT INTO " + headTable + " (shop_id,user_id,customer_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " + + "VALUES (?,?,?,?,?, 'approved', ?, 0, ?, NOW(), NOW())"; + } else if (isPurchaseHead) { + headSql = "INSERT INTO " + headTable + " (shop_id,user_id,supplier_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " + + "VALUES (?,?,?,?,?, 'approved', ?, 0, ?, NOW(), NOW())"; + } else { + // 理论不会到这里,兜底为 sales 结构 + headSql = "INSERT INTO " + headTable + " (shop_id,user_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " + + "VALUES (?,?,?,?,'approved', ?, 0, ?, NOW(), NOW())"; + } + + final Long customerId = (isSalesHead && req.customerId == null) + ? resolveOrCreateDefaultCustomer(shopId, userId) + : req.customerId; + final Long supplierId = (isPurchaseHead && req.supplierId == null) + ? resolveOrCreateDefaultSupplier(shopId, userId) + : req.supplierId; + jdbcTemplate.update(con -> { + java.sql.PreparedStatement ps = con.prepareStatement(headSql, new String[]{"id"}); + int idx = 1; + ps.setLong(idx++, shopId); + ps.setLong(idx++, userId); + if (isSalesHead) { + ps.setObject(idx++, customerId, java.sql.Types.BIGINT); + } else if (isPurchaseHead) { + ps.setObject(idx++, supplierId, java.sql.Types.BIGINT); + } + ps.setString(idx++, orderNo); + ps.setTimestamp(idx++, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant())); + ps.setBigDecimal(idx++, scale2(totalRef[0])); + ps.setString(idx, req.remark); + return ps; + }, kh); + Number orderKey = kh.getKey(); + Long orderId = (orderKey == null ? null : orderKey.longValue()); + + // insert items(销售类有折扣列,采购类无折扣列) + boolean itemsHasDiscount = isSaleOut || isSaleReturn; + String itemSql = itemsHasDiscount + ? ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,discount_rate,cost_price,cost_amount,amount) VALUES (?,?,?,?,?,?,?,?)") + : ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,amount) VALUES (?,?,?,?,?)"); + for (OrderDtos.Item it : req.items) { + BigDecimal qty = n(it.quantity); + BigDecimal price = n(it.unitPrice); + BigDecimal dr = n(it.discountRate); + BigDecimal line = scale2(qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100"))))); + BigDecimal costPrice = BigDecimal.ZERO; + BigDecimal costAmount = BigDecimal.ZERO; + if (itemsHasDiscount) { + costPrice = costPriceCache.getOrDefault(it.productId, BigDecimal.ZERO); + costAmount = scale2(qty.multiply(costPrice)); + } + if (itemsHasDiscount) { + jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, dr, costPrice, costAmount, line); + } else { + jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, line); + } + } + + // 维护供应商应付:采购入库增加,应付=+amount;采购退货减少,应付=-amount + if (isPurchaseHead && supplierId != null) { + java.math.BigDecimal delta = scale2(totalRef[0]); + if (isPurchaseReturn) delta = delta.negate(); + adjustSupplierPayable(supplierId, delta); + } + + return new OrderDtos.CreateOrderResponse(orderId, orderNo); + } + + private Long resolveOrCreateDefaultCustomer(Long shopId, Long userId) { + String name = appDefaults.getCustomerName(); + java.util.List ids = jdbcTemplate.query("SELECT id FROM customers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!ids.isEmpty()) return ids.get(0); + jdbcTemplate.update("INSERT INTO customers (shop_id,user_id,name,price_level,status,created_at,updated_at) VALUES (?,?,?,'retail',1,NOW(),NOW())", + shopId, userId, name); + ids = jdbcTemplate.query("SELECT id FROM customers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!ids.isEmpty()) return ids.get(0); + throw new IllegalStateException("默认客户创建失败"); + } + + private Long resolveOrCreateDefaultSupplier(Long shopId, Long userId) { + String name = appDefaults.getSupplierName(); + java.util.List ids = jdbcTemplate.query("SELECT id FROM suppliers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!ids.isEmpty()) return ids.get(0); + jdbcTemplate.update("INSERT INTO suppliers (shop_id,user_id,name,status,created_at,updated_at) VALUES (?,?,?,1,NOW(),NOW())", + shopId, userId, name); + ids = jdbcTemplate.query("SELECT id FROM suppliers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!ids.isEmpty()) return ids.get(0); + throw new IllegalStateException("默认供应商创建失败"); + } + + private BigDecimal resolveProductCostPrice(Long productId, Long shopId) { + return productPriceRepository.findById(productId) + .filter(price -> price.getShopId().equals(shopId)) + .map(ProductPrice::getPurchasePrice) + .map(OrderService::scale2) + .orElse(BigDecimal.ZERO); + } + + @Transactional + public OrderDtos.CreatePaymentsResponse createPayments(Long shopId, Long userId, java.util.List req, String bizType) { + ensureDefaultAccounts(shopId, userId); + List ids = new ArrayList<>(); + if (req == null) return new OrderDtos.CreatePaymentsResponse(ids); + String direction = "sale".equals(bizType) ? "in" : "out"; + for (OrderDtos.PaymentItem p : req) { + // 收/付款必须绑定订单(资金类不在此接口中处理) + if (("sale".equals(bizType) || "purchase".equals(bizType)) && p.orderId == null) { + throw new IllegalArgumentException("收/付款必须绑定订单"); + } + Long accountId = resolveAccountId(shopId, userId, p.method); + KeyHolder kh = new GeneratedKeyHolder(); + String sql = "INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) " + + "VALUES (?,?,?,?,?,?,?,NOW(),NULL,NOW())"; + jdbcTemplate.update(con -> { + java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"}); + ps.setLong(1, shopId); + ps.setLong(2, userId); + ps.setString(3, bizType); + if (p.orderId == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, p.orderId); + ps.setLong(5, accountId); + ps.setString(6, direction); + ps.setBigDecimal(7, n(p.amount)); + return ps; + }, kh); + Number payKey = kh.getKey(); + Long pid = (payKey == null ? null : payKey.longValue()); + if (pid != null) ids.add(pid); + + // 若挂单,累加已付 + if (p.orderId != null) { + String table = "sale".equals(bizType) ? "sales_orders" : "purchase_orders"; + jdbcTemplate.update("UPDATE " + table + " SET paid_amount = paid_amount + ? WHERE id = ?", n(p.amount), p.orderId); + + // 采购付款联动应付:应付 -= 付款金额 + if ("purchase".equals(bizType)) { + java.util.List sids = jdbcTemplate.query("SELECT supplier_id FROM purchase_orders WHERE id=?", (rs,rn)-> rs.getLong(1), p.orderId); + if (!sids.isEmpty() && sids.get(0) != null) { + adjustSupplierPayable(sids.get(0), n(p.amount).negate()); + } + } + } + + // 联动账户余额:收款加,付款减 + java.math.BigDecimal delta = "in".equals(direction) ? n(p.amount) : n(p.amount).negate(); + jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", + scale2(delta), accountId, shopId); + } + return new OrderDtos.CreatePaymentsResponse(ids); + } + + @Transactional + public void voidOrder(Long shopId, Long userId, Long id, String type) { + // type: sale.out / purchase.in / sale.return / purchase.return + String headTable; + String itemTable; + boolean revertIncrease; // true 表示作废时库存应减少(原先增加),false 表示应增加(原先减少) + if ("sale.out".equals(type)) { headTable = "sales_orders"; itemTable = "sales_order_items"; revertIncrease = false; } + else if ("purchase.in".equals(type)) { headTable = "purchase_orders"; itemTable = "purchase_order_items"; revertIncrease = true; } + else if ("sale.return".equals(type)) { headTable = "sales_return_orders"; itemTable = "sales_return_order_items"; revertIncrease = true; } + else if ("purchase.return".equals(type)) { headTable = "purchase_return_orders"; itemTable = "purchase_return_order_items"; revertIncrease = false; } + else throw new IllegalArgumentException("不支持的type"); + + // 查询明细 + List> rows = jdbcTemplate.queryForList("SELECT product_id, quantity FROM " + itemTable + " WHERE order_id = ?", id); + // 回滚库存 + LocalDateTime now = nowUtc(); + for (java.util.Map r : rows) { + Long pid = ((Number)r.get("product_id")).longValue(); + java.math.BigDecimal qty = new java.math.BigDecimal(r.get("quantity").toString()); + Inventory inv = inventoryRepository.findById(pid).orElseGet(Inventory::new); + inv.setProductId(pid); + inv.setShopId(shopId); + inv.setUserId(userId); + java.math.BigDecimal delta = revertIncrease ? qty.negate() : qty; // 与创建时相反 + inv.setQuantity(n(inv.getQuantity()).add(delta)); + inv.setUpdatedAt(now); + inventoryRepository.save(inv); + } + // 更新状态 + jdbcTemplate.update("UPDATE " + headTable + " SET status='void' WHERE id = ?", id); + + // 采购单作废回滚应付 + if ("purchase.out".equals(type) || "purchase.in".equals(type) || "purchase.return".equals(type)) { + boolean purchaseIn = "purchase.in".equals(type); + boolean purchaseRet = "purchase.return".equals(type); + if (purchaseIn || purchaseRet) { + java.util.List> h = jdbcTemplate.queryForList("SELECT supplier_id, amount FROM " + headTable + " WHERE id=?", id); + if (!h.isEmpty()) { + Long sid = ((Number)h.get(0).get("supplier_id")).longValue(); + java.math.BigDecimal amt = new java.math.BigDecimal(h.get(0).get("amount").toString()); + java.math.BigDecimal delta = purchaseIn ? amt.negate() : amt; // 入库作废应付减少;退货作废应付增加 + adjustSupplierPayable(sid, delta); + } + } + } + } + + public java.util.Map list(Long shopId, Long userId, String biz, String type, String kw, int page, int size, String startDate, String endDate) { + StringBuilder sql = new StringBuilder(); + java.util.List ps = new java.util.ArrayList<>(); + ps.add(shopId); + + if ("purchase".equals(biz)) { + // 进货单(含退货:并入 purchase_return_orders) + sql.append("SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.amount, s.name AS supplierName, 'purchase.in' AS docType FROM purchase_orders po LEFT JOIN suppliers s ON s.id = po.supplier_id WHERE po.shop_id=?"); + if (kw != null && !kw.isBlank()) { sql.append(" AND (po.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "po.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND po.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND po.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + if (!("in".equalsIgnoreCase(type) || "purchase.in".equalsIgnoreCase(type))) { + // 仅退货 + sql = new StringBuilder("SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?"); + ps = new java.util.ArrayList<>(); ps.add(shopId); + if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "pro.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + } else { + // 合并退货 + sql.append(" UNION ALL SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?"); + ps.add(shopId); + if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "pro.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + } + sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?"); + } else { // 销售 + sql.append("SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.amount, c.name AS customerName, 'sale.out' AS docType FROM sales_orders so LEFT JOIN customers c ON c.id = so.customer_id WHERE so.shop_id=?"); + if (kw != null && !kw.isBlank()) { sql.append(" AND (so.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "so.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND so.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND so.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + if (!("out".equalsIgnoreCase(type) || "sale.out".equalsIgnoreCase(type))) { + sql = new StringBuilder("SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?"); + ps = new java.util.ArrayList<>(); ps.add(shopId); + if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "sro.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + } else { + sql.append(" UNION ALL SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?"); + ps.add(shopId); + if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); } + applyNonVipWindow(sql, ps, userId, "sro.order_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + } + sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?"); + } + + ps.add(size); + ps.add(page * size); + java.util.List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + + // 汇总:净额(主单 - 退货) + java.math.BigDecimal total; + if ("purchase".equals(biz)) { + java.math.BigDecimal inSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId)); + java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_return_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId)); + total = inSum.subtract(retSum); + } else { + java.math.BigDecimal outSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId)); + java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_return_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId)); + total = outSum.subtract(retSum); + } + java.util.Map resp = new java.util.HashMap<>(); + resp.put("list", list); + resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); + return resp; + } + + public java.util.Map listPayments(Long shopId, Long userId, String direction, String bizType, Long accountId, String kw, int page, int size, String startDate, String endDate) { + StringBuilder sql = new StringBuilder("SELECT p.id, p.biz_type AS bizType, p.account_id, a.name AS accountName, p.direction, p.amount, p.pay_time AS orderTime, p.category AS category,\n" + + "CASE \n" + + " WHEN p.biz_type='sale' AND p.direction='in' THEN 'sale.collect' \n" + + " WHEN p.biz_type='purchase' AND p.direction='out' THEN 'purchase.pay' \n" + + " WHEN p.biz_type='other' AND p.direction='in' THEN 'other.income' \n" + + " WHEN p.biz_type='other' AND p.direction='out' THEN 'other.expense' \n" + + " ELSE CONCAT(p.biz_type, '.', p.direction) END AS docType\n" + + "FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.shop_id=?"); + java.util.List ps = new java.util.ArrayList<>(); + ps.add(shopId); + if (direction != null && !direction.isBlank()) { sql.append(" AND p.direction=?"); ps.add(direction); } + if (bizType != null && !bizType.isBlank()) { sql.append(" AND p.biz_type=?"); ps.add(bizType); } + if (accountId != null) { sql.append(" AND p.account_id=?"); ps.add(accountId); } + if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); } + applyNonVipWindow(sql, ps, userId, "p.pay_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND p.pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND p.pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + sql.append(" ORDER BY p.pay_time DESC LIMIT ? OFFSET ?"); + ps.add(size); ps.add(page * size); + java.util.List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(p.amount),0) FROM payments p WHERE p.shop_id=?"); + java.util.List sumPs = new java.util.ArrayList<>(); sumPs.add(shopId); + if (direction != null && !direction.isBlank()) { sumSql.append(" AND p.direction=?"); sumPs.add(direction); } + if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND p.biz_type=?"); sumPs.add(bizType); } + if (accountId != null) { sumSql.append(" AND p.account_id=?"); sumPs.add(accountId); } + if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); } + applyNonVipWindow(sumSql, sumPs, userId, "pay_time"); + if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND p.pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND p.pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray()); + java.util.Map resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp; + } + + public java.util.Map listOtherTransactions(Long shopId, Long userId, String type, Long accountId, String kw, int page, int size, String startDate, String endDate) { + StringBuilder sql = new StringBuilder("SELECT ot.id, ot.`type`, CONCAT('other.', ot.`type`) AS docType, ot.account_id, a.name AS accountName, ot.amount, ot.tx_time AS txTime, ot.remark FROM other_transactions ot LEFT JOIN accounts a ON a.id=ot.account_id WHERE ot.shop_id=?"); + java.util.List ps = new java.util.ArrayList<>(); ps.add(shopId); + if (type != null && !type.isBlank()) { sql.append(" AND ot.`type`=?"); ps.add(type); } + if (accountId != null) { sql.append(" AND ot.account_id=?"); ps.add(accountId); } + if (kw != null && !kw.isBlank()) { sql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); } + applyNonVipWindow(sql, ps, userId, "ot.tx_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND ot.tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND ot.tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + sql.append(" ORDER BY ot.tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size); + java.util.List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(ot.amount),0) FROM other_transactions ot WHERE ot.shop_id=?"); + java.util.List sumPs = new java.util.ArrayList<>(); sumPs.add(shopId); + if (type != null && !type.isBlank()) { sumSql.append(" AND ot.`type`=?"); sumPs.add(type); } + if (accountId != null) { sumSql.append(" AND ot.account_id=?"); sumPs.add(accountId); } + if (kw != null && !kw.isBlank()) { sumSql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); } + applyNonVipWindow(sumSql, sumPs, userId, "tx_time"); + if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND ot.tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND ot.tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray()); + java.util.Map resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp; + } + + @org.springframework.transaction.annotation.Transactional + public java.util.Map createOtherTransaction(Long shopId, Long userId, OrderDtos.CreateOtherTransactionRequest req) { + if (req == null) throw new IllegalArgumentException("请求为空"); + String type = req.type == null ? null : req.type.trim().toLowerCase(); + if (!"income".equals(type) && !"expense".equals(type)) throw new IllegalArgumentException("type 仅支持 income/expense"); + if (req.accountId == null) throw new IllegalArgumentException("账户必选"); + java.math.BigDecimal amt = n(req.amount); + if (amt.compareTo(java.math.BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("金额需大于0"); + java.time.LocalDateTime when; + if (req.txTime == null || req.txTime.isBlank()) when = nowUtc(); + else { + // 允许 yyyy-MM-dd 或完整时间 + try { + if (req.txTime.length() == 10) when = java.time.LocalDate.parse(req.txTime).atStartOfDay(); + else when = java.time.LocalDateTime.parse(req.txTime); + } catch (Exception e) { when = nowUtc(); } + } + final java.sql.Timestamp whenTs = java.sql.Timestamp.from(when.atZone(java.time.ZoneOffset.UTC).toInstant()); + + // 插入 other_transactions + org.springframework.jdbc.support.GeneratedKeyHolder kh = new org.springframework.jdbc.support.GeneratedKeyHolder(); + String sql = "INSERT INTO other_transactions (shop_id,user_id,`type`,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?, ?, NOW(), NOW())"; + jdbcTemplate.update(con -> { + java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"}); + int i = 1; + ps.setLong(i++, shopId); + ps.setLong(i++, userId); + ps.setString(i++, type); + ps.setString(i++, req.category); + if (req.counterpartyType == null) ps.setNull(i++, java.sql.Types.VARCHAR); else ps.setString(i++, req.counterpartyType); + if (req.counterpartyId == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, req.counterpartyId); + ps.setLong(i++, req.accountId); + ps.setBigDecimal(i++, scale2(amt)); + ps.setTimestamp(i++, whenTs); + ps.setString(i, req.remark); + return ps; + }, kh); + Number key = kh.getKey(); + Long id = key == null ? null : key.longValue(); + + // 写支付流水,联动账户余额 + String direction = "income".equals(type) ? "in" : "out"; + org.springframework.jdbc.support.GeneratedKeyHolder payKh = new org.springframework.jdbc.support.GeneratedKeyHolder(); + final Long idForPayment = id; + jdbcTemplate.update(con -> { + java.sql.PreparedStatement ps = con.prepareStatement( + "INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,category,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW())", + new String[]{"id"} + ); + int i = 1; + ps.setLong(i++, shopId); + ps.setLong(i++, userId); + ps.setString(i++, "other"); + if (idForPayment == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, idForPayment); + ps.setLong(i++, req.accountId); + ps.setString(i++, direction); + ps.setBigDecimal(i++, scale2(amt)); + ps.setTimestamp(i++, whenTs); + ps.setString(i++, req.remark); + ps.setString(i, req.category); + return ps; + }, payKh); + + // 联动账户余额:收入加,支出减 + java.math.BigDecimal delta = "income".equals(type) ? amt : amt.negate(); + jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", scale2(delta), req.accountId, shopId); + + java.util.Map resp = new java.util.HashMap<>(); + resp.put("id", id); + return resp; + } + + public java.util.Map listInventoryLogs(Long shopId, Long userId, Long productId, String reason, String kw, int page, int size, String startDate, String endDate) { + StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, COALESCE(amount_delta,0) AS amount, reason, tx_time AS txTime, remark FROM inventory_movements WHERE shop_id=?"); + java.util.List ps = new java.util.ArrayList<>(); ps.add(shopId); + if (productId != null) { sql.append(" AND product_id=?"); ps.add(productId); } + if (reason != null && !reason.isBlank()) { sql.append(" AND reason=?"); ps.add(reason); } + if (kw != null && !kw.isBlank()) { sql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); } + applyNonVipWindow(sql, ps, userId, "tx_time"); + if (startDate != null && !startDate.isBlank()) { sql.append(" AND tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sql.append(" AND tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + sql.append(" ORDER BY tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size); + java.util.List> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(COALESCE(amount_delta,0)),0) FROM inventory_movements WHERE shop_id=?"); + java.util.List sumPs = new java.util.ArrayList<>(); sumPs.add(shopId); + if (productId != null) { sumSql.append(" AND product_id=?"); sumPs.add(productId); } + if (reason != null && !reason.isBlank()) { sumSql.append(" AND reason=?"); sumPs.add(reason); } + if (kw != null && !kw.isBlank()) { sumSql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); } + applyNonVipWindow(sumSql, sumPs, userId, "tx_time"); + if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); } + if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); } + java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray()); + java.util.Map resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp; + } + + // 非VIP时间窗拼接:默认60天;VIP用户不加限制 + private void applyNonVipWindow(StringBuilder sql, java.util.List ps, Long userId, String column) { + if (userId == null) return; // 无法判定用户,避免误拦截 + boolean vip = isVipActive(userId); + if (vip) return; + int days = readNonVipRetentionDaysOrDefault(60); + if (days <= 0) return; + sql.append(" AND ").append(column).append(">= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)"); + ps.add(days); + } + + private boolean isVipActive(Long userId) { + try { + java.util.List> rows = jdbcTemplate.queryForList("SELECT is_vip,status,expire_at FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1", userId); + if (rows.isEmpty()) return false; + java.util.Map r = rows.get(0); + int isVip = ((Number) r.getOrDefault("is_vip", 0)).intValue(); + int status = ((Number) r.getOrDefault("status", 0)).intValue(); + java.sql.Timestamp exp = (java.sql.Timestamp) r.get("expire_at"); + boolean notExpired = (exp == null) || !exp.toInstant().isBefore(java.time.Instant.now()); + return isVip == 1 && status == 1 && notExpired; + } catch (Exception e) { + return false; + } + } + + private int readNonVipRetentionDaysOrDefault(int dft) { + try { + String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.dataRetentionDaysForNonVip' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v == null) return dft; + v = v.trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + return Integer.parseInt(v); + } catch (Exception ignored) { + return dft; + } + } + + // 详情:销售单 + public java.util.Map getSalesOrderDetail(Long shopId, Long id) { + java.util.List> heads = jdbcTemplate.queryForList( + "SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.status, so.amount, so.paid_amount AS paidAmount, so.customer_id AS customerId, c.name AS customerName, so.remark\n" + + "FROM sales_orders so LEFT JOIN customers c ON c.id=so.customer_id WHERE so.shop_id=? AND so.id=?", + shopId, id); + if (heads.isEmpty()) return java.util.Map.of(); + java.util.Map head = heads.get(0); + java.util.List> items = jdbcTemplate.queryForList( + "SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.discount_rate AS discountRate, i.cost_price AS costPrice, i.cost_amount AS costAmount, i.amount\n" + + "FROM sales_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?", + id); + java.util.List> pays = jdbcTemplate.queryForList( + "SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" + + "FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='sale' AND p.biz_id=?", + id); + java.util.Map resp = new java.util.HashMap<>(head); + resp.put("items", items); + resp.put("payments", pays); + return resp; + } + + // 详情:进货单 + public java.util.Map getPurchaseOrderDetail(Long shopId, Long id) { + java.util.List> heads = jdbcTemplate.queryForList( + "SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.status, po.amount, po.paid_amount AS paidAmount, po.supplier_id AS supplierId, s.name AS supplierName, po.remark\n" + + "FROM purchase_orders po LEFT JOIN suppliers s ON s.id=po.supplier_id WHERE po.shop_id=? AND po.id=?", + shopId, id); + if (heads.isEmpty()) return java.util.Map.of(); + java.util.Map head = heads.get(0); + java.util.List> items = jdbcTemplate.queryForList( + "SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.amount\n" + + "FROM purchase_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?", + id); + java.util.List> pays = jdbcTemplate.queryForList( + "SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" + + "FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='purchase' AND p.biz_id=?", + id); + java.util.Map resp = new java.util.HashMap<>(head); + resp.put("items", items); + resp.put("payments", pays); + return resp; + } + + private static BigDecimal n(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; } + private static BigDecimal scale2(BigDecimal v) { return v.setScale(2, java.math.RoundingMode.HALF_UP); } + private static LocalDateTime nowUtc() { return LocalDateTime.now(java.time.Clock.systemUTC()); } + + private void adjustSupplierPayable(Long supplierId, java.math.BigDecimal delta) { + if (supplierId == null || delta == null) return; + jdbcTemplate.update("UPDATE suppliers SET ap_payable = ap_payable + ? WHERE id = ?", delta, supplierId); + } + + private void ensureDefaultAccounts(Long shopId, Long userId) { + // 为 cash/bank/wechat/alipay 分别确保存在一条账户记录;按 type→name 顺序检查,避免同名唯一冲突 + ensureAccount(shopId, userId, "cash", accountDefaults.getCashName()); + ensureAccount(shopId, userId, "bank", accountDefaults.getBankName()); + ensureAccount(shopId, userId, "wechat", accountDefaults.getWechatName()); + ensureAccount(shopId, userId, "alipay", accountDefaults.getAlipayName()); + } + + private void ensureAccount(Long shopId, Long userId, String type, String name) { + List byType = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type); + if (!byType.isEmpty()) return; + List byName = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!byName.isEmpty()) return; // 已有同名则直接复用,无需再插 + jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,balance,status,created_at,updated_at) VALUES (?,?,?,?,0,1,NOW(),NOW())", + shopId, userId, name, type); + } + + private Long resolveAccountId(Long shopId, Long userId, String method) { + String type = "cash"; + if ("bank".equalsIgnoreCase(method)) type = "bank"; + if ("wechat".equalsIgnoreCase(method)) type = "wechat"; + if ("alipay".equalsIgnoreCase(method)) type = "alipay"; + String name = accountDefaults.getCashName(); + if ("bank".equals(type)) name = accountDefaults.getBankName(); + else if ("wechat".equals(type)) name = accountDefaults.getWechatName(); + else if ("alipay".equals(type)) name = accountDefaults.getAlipayName(); + // 先按 type 查 + List byType = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type); + if (!byType.isEmpty()) return byType.get(0); + // 再按 name 查,避免同名唯一冲突 + List byName = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name); + if (!byName.isEmpty()) return byName.get(0); + // 都没有再插入 + jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,balance,status,created_at,updated_at) VALUES (?,?,?,?,0,1,NOW(),NOW())", + shopId, userId, name, type); + // 插入后按 type 读取 + List recheck = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type); + if (!recheck.isEmpty()) return recheck.get(0); + throw new IllegalStateException("账户映射失败: " + method); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/dto/OrderDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/dto/OrderDtos.java new file mode 100644 index 0000000..056ff49 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/order/dto/OrderDtos.java @@ -0,0 +1,55 @@ +package com.example.demo.order.dto; + +import java.math.BigDecimal; +import java.util.List; + +public class OrderDtos { + + public static class CreateOrderRequest { + public String type; // sale.out / sale.return / sale.collect / purchase.in / purchase.return / purchase.pay + public String orderTime; // ISO8601 或 yyyy-MM-dd + public Long customerId; // 可空 + public Long supplierId; // 可空 + public List items; // 出入库时必填 + public List payments; // 收款/付款时必填 + public BigDecimal amount; // 前端提供,后端将重算覆盖 + public String remark; + } + + public static class Item { + public Long productId; + public BigDecimal quantity; + public BigDecimal unitPrice; + public BigDecimal discountRate; // 可空,缺省 0 + } + + public static class PaymentItem { + public String method; // cash/bank/wechat + public BigDecimal amount; + public Long orderId; // 可选:若挂单则带上 + } + + public static class CreateOrderResponse { + public Long id; + public String orderNo; + public CreateOrderResponse(Long id, String orderNo) { this.id = id; this.orderNo = orderNo; } + } + + public static class CreatePaymentsResponse { + public java.util.List paymentIds; + public CreatePaymentsResponse(java.util.List ids) { this.paymentIds = ids; } + } + + public static class CreateOtherTransactionRequest { + public String type; // income | expense + public String category; // 分类key + public String counterpartyType; // customer | supplier | other + public Long counterpartyId; // 可空 + public Long accountId; // 必填 + public java.math.BigDecimal amount; // 必填,>0 + public String txTime; // yyyy-MM-dd 或 ISO8601 + public String remark; // 可空 + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/MetadataController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/MetadataController.java new file mode 100644 index 0000000..54a9488 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/MetadataController.java @@ -0,0 +1,91 @@ +package com.example.demo.product.controller; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.product.repo.CategoryRepository; +import com.example.demo.product.repo.UnitRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import com.example.demo.product.repo.PartTemplateRepository; +import com.example.demo.product.repo.PartTemplateParamRepository; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class MetadataController { + + private final UnitRepository unitRepository; + private final CategoryRepository categoryRepository; + private final AppDefaultsProperties defaults; + private final PartTemplateRepository templateRepository; + private final PartTemplateParamRepository paramRepository; + + public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults, + PartTemplateRepository templateRepository, PartTemplateParamRepository paramRepository) { + this.unitRepository = unitRepository; + this.categoryRepository = categoryRepository; + this.defaults = defaults; + this.templateRepository = templateRepository; + this.paramRepository = paramRepository; + } + + @GetMapping("/api/product-units") + public ResponseEntity listUnits(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) { + Map body = new HashMap<>(); + body.put("list", unitRepository.listByShop(defaults.getDictShopId())); + return ResponseEntity.ok(body); + } + + @GetMapping("/api/product-categories") + public ResponseEntity listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) { + Map body = new HashMap<>(); + body.put("list", categoryRepository.listByShop(defaults.getDictShopId())); + return ResponseEntity.ok(body); + } + + @GetMapping("/api/product-templates") + public ResponseEntity listTemplates(@RequestParam(name = "categoryId", required = false) Long categoryId) { + Map body = new HashMap<>(); + // 排除已软删模板;仍要求 status=1 才可见 + java.util.List list = + (categoryId == null) + ? templateRepository.findByStatusOrderByIdDesc(1) + : templateRepository.findByStatusAndCategoryIdOrderByIdDesc(1, categoryId); + java.util.List> out = new java.util.ArrayList<>(); + for (com.example.demo.product.entity.PartTemplate t : list) { + try { if (t.getDeletedAt() != null) continue; } catch (Exception ignore) {} + java.util.Map m = new java.util.HashMap<>(); + m.put("id", t.getId()); + m.put("categoryId", t.getCategoryId()); + m.put("name", t.getName()); + m.put("modelRule", t.getModelRule()); + m.put("status", t.getStatus()); + java.util.List params = + paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(t.getId()); + java.util.List> ps = new java.util.ArrayList<>(); + for (com.example.demo.product.entity.PartTemplateParam p : params) { + java.util.Map pm = new java.util.HashMap<>(); + pm.put("fieldKey", p.getFieldKey()); + pm.put("fieldLabel", p.getFieldLabel()); + pm.put("type", p.getType()); + pm.put("required", p.getRequired()); + pm.put("unit", p.getUnit()); + java.util.List enums = com.example.demo.common.JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference>(){}); + pm.put("enumOptions", enums); + pm.put("searchable", p.getSearchable()); + // 不再暴露 dedupeParticipate + pm.put("sortOrder", p.getSortOrder()); + ps.add(pm); + } + m.put("params", ps); + out.add(m); + } + body.put("list", out); + return ResponseEntity.ok(body); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/NormalAdminSubmissionController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/NormalAdminSubmissionController.java new file mode 100644 index 0000000..fc396c4 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/NormalAdminSubmissionController.java @@ -0,0 +1,78 @@ +package com.example.demo.product.controller; + +import com.example.demo.product.service.ProductSubmissionService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/normal-admin/parts") +public class NormalAdminSubmissionController { + + private final ProductSubmissionService submissionService; + private final JdbcTemplate jdbc; + + public NormalAdminSubmissionController(ProductSubmissionService submissionService, + JdbcTemplate jdbc) { + this.submissionService = submissionService; + this.jdbc = jdbc; + } + + private Long findShopIdByUser(Long userId) { + if (userId == null) return null; + return jdbc.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getLong(1): null); + } + + // 代理现有管理端接口,但范围限定为当前用户所属店铺 + @GetMapping("/submissions") + public ResponseEntity list(@RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + Long shopId = findShopIdByUser(userId); + return ResponseEntity.ok(submissionService.listAdmin(status, kw, shopId, null, null, null, page, size)); + } + + @GetMapping("/submissions/{id}") + public ResponseEntity detail(@PathVariable("id") Long id) { + return submissionService.findDetail(id) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PutMapping("/submissions/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestBody com.example.demo.product.dto.ProductSubmissionDtos.UpdateRequest req) { + submissionService.updateSubmission(id, req); + return ResponseEntity.ok(java.util.Map.of("ok", true)); + } + + @PostMapping("/submissions/{id}/approve") + public ResponseEntity approve(@PathVariable("id") Long id, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody(required = false) com.example.demo.product.dto.ProductSubmissionDtos.ApproveRequest req) { + var resp = submissionService.approve(id, userId, req); + return ResponseEntity.ok(resp); + } + + @PostMapping("/submissions/{id}/reject") + public ResponseEntity reject(@PathVariable("id") Long id, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody com.example.demo.product.dto.ProductSubmissionDtos.RejectRequest req) { + submissionService.reject(id, userId, req); + return ResponseEntity.ok(java.util.Map.of("ok", true)); + } + + @GetMapping("/submissions/export") + public void export(@RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + HttpServletResponse response) { + Long shopId = findShopIdByUser(userId); + submissionService.export(status, kw, shopId, null, null, null, response); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/PartTemplateController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/PartTemplateController.java new file mode 100644 index 0000000..ef1bf47 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/PartTemplateController.java @@ -0,0 +1,52 @@ +package com.example.demo.product.controller; + +import com.example.demo.product.dto.PartTemplateDtos; +import com.example.demo.product.service.PartTemplateService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/part-templates") +public class PartTemplateController { + + private final PartTemplateService templateService; + + public PartTemplateController(PartTemplateService templateService) { + this.templateService = templateService; + } + + @PostMapping + public ResponseEntity create(@RequestBody PartTemplateDtos.CreateRequest req, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId) { + Long id = templateService.create(req, adminId); + return ResponseEntity.ok(java.util.Map.of("id", id)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestBody PartTemplateDtos.UpdateRequest req) { + templateService.update(id, req); + return ResponseEntity.ok(java.util.Map.of("ok", true)); + } + + @GetMapping("/{id}") + public ResponseEntity detail(@PathVariable("id") Long id) { + return ResponseEntity.ok(templateService.detail(id)); + } + + @GetMapping + public ResponseEntity list() { + return ResponseEntity.ok(templateService.list()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable("id") Long id, + @RequestParam(value = "force", required = false) Boolean force) { + templateService.delete(id, Boolean.TRUE.equals(force)); + return ResponseEntity.ok(java.util.Map.of("ok", true)); + } + + // 分类级联软删将在 AdminDictController 中触发;此处保持模板单体删除逻辑 +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductController.java new file mode 100644 index 0000000..698759b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductController.java @@ -0,0 +1,86 @@ +package com.example.demo.product.controller; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.product.dto.ProductDtos; +import com.example.demo.product.service.ProductService; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/products") +public class ProductController { + + private final ProductService productService; + private final AppDefaultsProperties defaults; + + public ProductController(ProductService productService, AppDefaultsProperties defaults) { + this.productService = productService; + this.defaults = defaults; + } + + @GetMapping + public ResponseEntity search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "categoryId", required = false) Long categoryId, + @RequestParam(name = "templateId", required = false) Long templateId, + @RequestParam java.util.Map requestParams, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "50") int size) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + java.util.Map paramFilters = new java.util.HashMap<>(); + for (java.util.Map.Entry e : requestParams.entrySet()) { + String k = e.getKey(); + if (k != null && k.startsWith("param_") && e.getValue() != null && !e.getValue().isBlank()) { + String key = k.substring(6); + if (!key.isBlank()) paramFilters.put(key, e.getValue()); + } + } + Page result = productService.search(sid, kw, categoryId, templateId, paramFilters, Math.max(page - 1, 0), size); + java.util.Map body = new java.util.HashMap<>(); + body.put("list", result.getContent()); + return ResponseEntity.ok(body); + } + + @GetMapping("/{id}") + public ResponseEntity detail(@PathVariable("id") Long id, + @RequestParam(name = "includeDeleted", required = false, defaultValue = "false") boolean includeDeleted) { + return productService.findDetail(id, includeDeleted) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody ProductDtos.CreateOrUpdateProductRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + Long id = productService.create(sid, uid, req); + java.util.Map body = new java.util.HashMap<>(); + body.put("id", id); + return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody ProductDtos.CreateOrUpdateProductRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + productService.update(id, sid, uid, req); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + productService.delete(id, sid); + return ResponseEntity.ok().build(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductSubmissionController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductSubmissionController.java new file mode 100644 index 0000000..40abf2b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/controller/ProductSubmissionController.java @@ -0,0 +1,119 @@ +package com.example.demo.product.controller; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.product.dto.ProductSubmissionDtos; +import com.example.demo.product.service.ProductSubmissionService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping +public class ProductSubmissionController { + + private final ProductSubmissionService submissionService; + private final AppDefaultsProperties defaults; + + public ProductSubmissionController(ProductSubmissionService submissionService, + AppDefaultsProperties defaults) { + this.submissionService = submissionService; + this.defaults = defaults; + } + + @PostMapping("/api/products/submissions") + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody ProductSubmissionDtos.CreateRequest req) { + Long sid = shopId == null ? defaults.getShopId() : shopId; + Long uid = userId == null ? defaults.getUserId() : userId; + Long id = submissionService.createSubmission(sid, uid, req); + return ResponseEntity.ok(Map.of("id", id, "status", "pending")); + } + + @PostMapping("/api/products/submissions/check-model") + public ResponseEntity checkModel(@RequestBody ProductSubmissionDtos.CheckModelRequest req) { + ProductSubmissionDtos.CheckModelResponse resp = submissionService.checkModel(req == null ? null : req.model, + req == null ? null : req.templateId, + req == null ? null : req.name); + return ResponseEntity.ok(resp); + } + + @GetMapping("/api/products/submissions") + public ResponseEntity listMine(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestParam(name = "status", required = false) String status, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + Long sid = shopId == null ? defaults.getShopId() : shopId; + Long uid = userId == null ? defaults.getUserId() : userId; + return ResponseEntity.ok(submissionService.listMine(sid, uid, status, page, size)); + } + + @GetMapping("/api/products/submissions/{id}") + public ResponseEntity detailMine(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId) { + Long sid = shopId == null ? defaults.getShopId() : shopId; + Long uid = userId == null ? defaults.getUserId() : userId; + return submissionService.findMineDetail(id, sid, uid) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/api/admin/parts/submissions") + public ResponseEntity listAdmin(@RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "reviewerId", required = false) Long reviewerId, + @RequestParam(name = "startAt", required = false) String startAt, + @RequestParam(name = "endAt", required = false) String endAt, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + return ResponseEntity.ok(submissionService.listAdmin(status, kw, shopId, reviewerId, startAt, endAt, page, size)); + } + + @GetMapping("/api/admin/parts/submissions/export") + public void export(@RequestParam(name = "status", required = false) String status, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "shopId", required = false) Long shopId, + @RequestParam(name = "reviewerId", required = false) Long reviewerId, + @RequestParam(name = "startAt", required = false) String startAt, + @RequestParam(name = "endAt", required = false) String endAt, + HttpServletResponse response) { + submissionService.export(status, kw, shopId, reviewerId, startAt, endAt, response); + } + + @GetMapping("/api/admin/parts/submissions/{id}") + public ResponseEntity detail(@PathVariable("id") Long id) { + return submissionService.findDetail(id) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PutMapping("/api/admin/parts/submissions/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestBody ProductSubmissionDtos.UpdateRequest req) { + submissionService.updateSubmission(id, req); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/api/admin/parts/submissions/{id}/approve") + public ResponseEntity approve(@PathVariable("id") Long id, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody(required = false) ProductSubmissionDtos.ApproveRequest req) { + ProductSubmissionDtos.ApproveResponse resp = submissionService.approve(id, adminId, req); + return ResponseEntity.ok(resp); + } + + @PostMapping("/api/admin/parts/submissions/{id}/reject") + public ResponseEntity reject(@PathVariable("id") Long id, + @RequestHeader(name = "X-Admin-Id", required = false) Long adminId, + @RequestBody ProductSubmissionDtos.RejectRequest req) { + submissionService.reject(id, adminId, req); + return ResponseEntity.ok(Map.of("ok", true)); + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/PartTemplateDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/PartTemplateDtos.java new file mode 100644 index 0000000..9572493 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/PartTemplateDtos.java @@ -0,0 +1,55 @@ +package com.example.demo.product.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public class PartTemplateDtos { + + public static class ParamDef { + public String fieldKey; + public String fieldLabel; + public String type; // string/number/boolean/enum/date + public boolean required; + public String unit; // 自定义单位文本 + public List enumOptions; // type=enum 时可用 + public boolean searchable; // 默认参与搜索;前端不再展示开关 + public boolean fuzzySearchable; // 仅 type=number 生效 + public java.math.BigDecimal fuzzyTolerance; // 可空=使用默认 + public boolean cardDisplay; // 是否在用户端货品卡片展示 + // public boolean dedupeParticipate; // 已废弃,后端忽略 + public int sortOrder; + } + + public static class CreateRequest { + public Long categoryId; + public String name; + public String modelRule; // 可空 + public Integer status; // 1/0 + public List params; + } + + public static class UpdateRequest { + public Long categoryId; + public String name; + public String modelRule; + public Integer status; + public List params; // 覆盖式更新 + public boolean deleteAllRelatedProductsAndSubmissions; // 开关:按你的规则默认true + } + + public static class TemplateItem { + public Long id; + public Long categoryId; + public String name; + public String modelRule; + public Integer status; + public LocalDateTime createdAt; + public LocalDateTime updatedAt; + } + + public static class TemplateDetail extends TemplateItem { + public List params; + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductDtos.java new file mode 100644 index 0000000..2f76564 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductDtos.java @@ -0,0 +1,85 @@ +package com.example.demo.product.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public class ProductDtos { + + public static class ProductListItem { + public Long id; + public String name; + public String brand; + public String model; + public String spec; + public BigDecimal stock; // from inventories.quantity + public BigDecimal retailPrice; // from product_prices + public String cover; // first image url + public Boolean deleted; // derived from deleted_at + public java.util.Map cardParams; // 货品卡片展示的参数(最多4个,label->value) + } + + public static class ProductDetail { + public Long id; + public String name; + public String barcode; + public String brand; + public String model; + public String spec; + public String origin; + public Long categoryId; + // 单位字段已移除 + public Long templateId; + public BigDecimal safeMin; + public BigDecimal safeMax; + public BigDecimal stock; + public BigDecimal purchasePrice; + public BigDecimal retailPrice; + public BigDecimal wholesalePrice; + public BigDecimal bigClientPrice; + public List images; + public Map parameters; + public Long sourceSubmissionId; + public String externalCode; + public Boolean deleted; + } + + public static class Image { + public String url; + } + + public static class CreateOrUpdateProductRequest { + public Long templateId; + public String name; + public String barcode; + public String brand; + public String model; + public String spec; + public String origin; + public Long categoryId; + public String dedupeKey; + public BigDecimal safeMin; + public BigDecimal safeMax; + public Prices prices; + public BigDecimal stock; + public List images; + public String remark; // map to products.description + public Long sourceSubmissionId; + public Long globalSkuId; + public Map parameters; + } + + public static class Prices { + public BigDecimal purchasePrice; + public BigDecimal retailPrice; + public BigDecimal wholesalePrice; + public BigDecimal bigClientPrice; + } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductSubmissionDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductSubmissionDtos.java new file mode 100644 index 0000000..7a923bc --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/dto/ProductSubmissionDtos.java @@ -0,0 +1,117 @@ +package com.example.demo.product.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public class ProductSubmissionDtos { + + public static class CreateRequest { + public Long templateId; + public String externalCode; // 外部编号 + public String name; + public String model; + public String brand; + public String spec; + public String origin; + // 单位字段已移除 + public Long categoryId; + public Map parameters; + public List images; + public String remark; + public String barcode; + public java.math.BigDecimal safeMin; + public java.math.BigDecimal safeMax; + } + + public static class UpdateRequest { + public Long templateId; + public String externalCode; // 外部编号 + public String name; + public String brand; + public String spec; + public String origin; + // 单位字段已移除 + public Long categoryId; + public Map parameters; + public List images; + public String remark; + public String barcode; + public java.math.BigDecimal safeMin; + public java.math.BigDecimal safeMax; + } + + public static class ApproveRequest { + public String remark; + public Long assignGlobalSkuId; + public boolean createGlobalSku; + } + + public static class ApproveResponse { + public boolean ok; + public Long productId; + public Long globalSkuId; + } + + public static class RejectRequest { + public String remark; + } + + public static class SubmissionItem { + public Long id; + public String name; + public String model; + public String brand; + public String status; + public String submitter; + public Long shopId; + public LocalDateTime createdAt; + public LocalDateTime reviewedAt; + } + + public static class SubmissionDetail { + public Long id; + public Long shopId; + public Long userId; + public Long templateId; + public String externalCode; + public String name; + public String model; + public String brand; + public String spec; + public String origin; + // 单位字段已移除 + public Long categoryId; + public Map parameters; + public List images; + public String remark; + public String barcode; + public java.math.BigDecimal safeMin; + public java.math.BigDecimal safeMax; + public String status; + public Long reviewerId; + public String reviewRemark; + public LocalDateTime reviewedAt; + public LocalDateTime createdAt; + public String dedupeKey; + } + + public static class PageResult { + public List list; + public long total; + public int page; + public int size; + } + + public static class CheckModelRequest { + public Long templateId; + public String name; + public String model; + } + + public static class CheckModelResponse { + public boolean available; + public String model; + public int similarAcrossTemplates; // 跨模板同名同型号命中数量(提示用) + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Inventory.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Inventory.java new file mode 100644 index 0000000..e218f10 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Inventory.java @@ -0,0 +1,44 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "inventories") +public class Inventory { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "quantity", precision = 18, scale = 3, nullable = false) + private BigDecimal quantity = BigDecimal.ZERO; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplate.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplate.java new file mode 100644 index 0000000..399cba0 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplate.java @@ -0,0 +1,57 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "part_templates") +public class PartTemplate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "category_id", nullable = false) + private Long categoryId; + + @Column(name = "name", nullable = false, length = 120) + private String name; + + @Column(name = "model_rule") + private String modelRule; + + @Column(name = "status", nullable = false) + private Integer status; + + @Column(name = "created_by_admin_id") + private Long createdByAdminId; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { return id; } + public Long getCategoryId() { return categoryId; } + public void setCategoryId(Long categoryId) { this.categoryId = categoryId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getModelRule() { return modelRule; } + public void setModelRule(String modelRule) { this.modelRule = modelRule; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + public Long getCreatedByAdminId() { return createdByAdminId; } + public void setCreatedByAdminId(Long createdByAdminId) { this.createdByAdminId = createdByAdminId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplateParam.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplateParam.java new file mode 100644 index 0000000..7490edc --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/PartTemplateParam.java @@ -0,0 +1,93 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "part_template_params") +public class PartTemplateParam { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false) + private Long templateId; + + @Column(name = "field_key", nullable = false, length = 64) + private String fieldKey; + + @Column(name = "field_label", nullable = false, length = 120) + private String fieldLabel; + + @Column(name = "type", nullable = false, length = 16) + private String type; + + @Column(name = "required", nullable = false) + private Boolean required; + + @Column(name = "unit", length = 32) + private String unit; + + @Column(name = "enum_options", columnDefinition = "json") + private String enumOptionsJson; + + @Column(name = "searchable", nullable = false) + private Boolean searchable; + + @Column(name = "dedupe_participate", nullable = false) + private Boolean dedupeParticipate = false; + + @Column(name = "fuzzy_searchable", nullable = false) + private Boolean fuzzySearchable = false; + + @Column(name = "fuzzy_tolerance", precision = 18, scale = 6) + private BigDecimal fuzzyTolerance; + + @Column(name = "card_display", nullable = false) + private Boolean cardDisplay = false; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public Long getTemplateId() { return templateId; } + public void setTemplateId(Long templateId) { this.templateId = templateId; } + public String getFieldKey() { return fieldKey; } + public void setFieldKey(String fieldKey) { this.fieldKey = fieldKey; } + public String getFieldLabel() { return fieldLabel; } + public void setFieldLabel(String fieldLabel) { this.fieldLabel = fieldLabel; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public Boolean getRequired() { return required; } + public void setRequired(Boolean required) { this.required = required; } + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + public String getEnumOptionsJson() { return enumOptionsJson; } + public void setEnumOptionsJson(String enumOptionsJson) { this.enumOptionsJson = enumOptionsJson; } + public Boolean getSearchable() { return searchable; } + public void setSearchable(Boolean searchable) { this.searchable = searchable; } + public Boolean getDedupeParticipate() { return dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate; } + public void setDedupeParticipate(Boolean dedupeParticipate) { this.dedupeParticipate = (dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate); } + public Boolean getFuzzySearchable() { return fuzzySearchable; } + public void setFuzzySearchable(Boolean fuzzySearchable) { this.fuzzySearchable = fuzzySearchable; } + public BigDecimal getFuzzyTolerance() { return fuzzyTolerance; } + public void setFuzzyTolerance(BigDecimal fuzzyTolerance) { this.fuzzyTolerance = fuzzyTolerance; } + public Boolean getCardDisplay() { return cardDisplay == null ? Boolean.FALSE : cardDisplay; } + public void setCardDisplay(Boolean cardDisplay) { this.cardDisplay = (cardDisplay == null ? Boolean.FALSE : cardDisplay); } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Product.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Product.java new file mode 100644 index 0000000..343fc69 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/Product.java @@ -0,0 +1,123 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "products") +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "name", nullable = false, length = 120) + private String name; + + @Column(name = "category_id") + private Long categoryId; + + @Column(name = "template_id") + private Long templateId; + + @Column(name = "brand", length = 64) + private String brand; + + @Column(name = "model", length = 64) + private String model; + + @Column(name = "spec", length = 128) + private String spec; + + @Column(name = "origin", length = 64) + private String origin; + + @Column(name = "barcode", length = 32) + private String barcode; + + @Column(name = "dedupe_key", length = 512) + private String dedupeKey; + + @Column(name = "description") + private String description; + + @Column(name = "safe_min", precision = 18, scale = 3) + private BigDecimal safeMin; + + @Column(name = "safe_max", precision = 18, scale = 3) + private BigDecimal safeMax; + + @Column(name = "global_sku_id") + private Long globalSkuId; + + @Column(name = "source_submission_id") + private Long sourceSubmissionId; + + @Column(name = "attributes_json", columnDefinition = "json") + private String attributesJson; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Long getCategoryId() { return categoryId; } + public void setCategoryId(Long categoryId) { this.categoryId = categoryId; } + public Long getTemplateId() { return templateId; } + public void setTemplateId(Long templateId) { this.templateId = templateId; } + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + public String getSpec() { return spec; } + public void setSpec(String spec) { this.spec = spec; } + public String getOrigin() { return origin; } + public void setOrigin(String origin) { this.origin = origin; } + public String getBarcode() { return barcode; } + public void setBarcode(String barcode) { this.barcode = barcode; } + public String getDedupeKey() { return dedupeKey; } + public void setDedupeKey(String dedupeKey) { this.dedupeKey = dedupeKey; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public BigDecimal getSafeMin() { return safeMin; } + public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; } + public BigDecimal getSafeMax() { return safeMax; } + public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; } + public Long getGlobalSkuId() { return globalSkuId; } + public void setGlobalSkuId(Long globalSkuId) { this.globalSkuId = globalSkuId; } + public Long getSourceSubmissionId() { return sourceSubmissionId; } + public void setSourceSubmissionId(Long sourceSubmissionId) { this.sourceSubmissionId = sourceSubmissionId; } + public String getAttributesJson() { return attributesJson; } + public void setAttributesJson(String attributesJson) { this.attributesJson = attributesJson; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductCategory.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductCategory.java new file mode 100644 index 0000000..ed6e3b4 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductCategory.java @@ -0,0 +1,61 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "product_categories") +public class ProductCategory { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "name", nullable = false, length = 64) + private String name; + + @Column(name = "parent_id") + private Long parentId; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Long getParentId() { return parentId; } + public void setParentId(Long parentId) { this.parentId = parentId; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductEntityImportsPlaceholder.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductEntityImportsPlaceholder.java new file mode 100644 index 0000000..e69de29 diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductImage.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductImage.java new file mode 100644 index 0000000..7033e4d --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductImage.java @@ -0,0 +1,46 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "product_images") +public class ProductImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "url", nullable = false, length = 512) + private String url; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductPrice.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductPrice.java new file mode 100644 index 0000000..ef07df3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductPrice.java @@ -0,0 +1,59 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "product_prices") +public class ProductPrice { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "purchase_price", precision = 18, scale = 2, nullable = false) + private BigDecimal purchasePrice = BigDecimal.ZERO; + + @Column(name = "retail_price", precision = 18, scale = 2, nullable = false) + private BigDecimal retailPrice = BigDecimal.ZERO; + + @Column(name = "wholesale_price", precision = 18, scale = 2, nullable = false) + private BigDecimal wholesalePrice = BigDecimal.ZERO; + + @Column(name = "big_client_price", precision = 18, scale = 2, nullable = false) + private BigDecimal bigClientPrice = BigDecimal.ZERO; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public BigDecimal getPurchasePrice() { return purchasePrice; } + public void setPurchasePrice(BigDecimal purchasePrice) { this.purchasePrice = purchasePrice; } + public BigDecimal getRetailPrice() { return retailPrice; } + public void setRetailPrice(BigDecimal retailPrice) { this.retailPrice = retailPrice; } + public BigDecimal getWholesalePrice() { return wholesalePrice; } + public void setWholesalePrice(BigDecimal wholesalePrice) { this.wholesalePrice = wholesalePrice; } + public BigDecimal getBigClientPrice() { return bigClientPrice; } + public void setBigClientPrice(BigDecimal bigClientPrice) { this.bigClientPrice = bigClientPrice; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductSubmission.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductSubmission.java new file mode 100644 index 0000000..d6b248e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductSubmission.java @@ -0,0 +1,166 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "part_submissions") +public class ProductSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "name") + private String name; + + @Column(name = "external_code") + private String externalCode; + + @Column(name = "model_unique", nullable = false) + private String modelUnique; + + private String brand; + private String spec; + + @Transient + private String origin; + + @Column(name = "unit_id") + private Long unitId; + + @Column(name = "category_id") + private Long categoryId; + + @Column(name = "template_id") + private Long templateId; + + @Column(name = "attributes") + private String attributesJson; + + @Column(name = "images") + private String imagesJson; + + @Transient + private java.math.BigDecimal safeMin; + + @Transient + private java.math.BigDecimal safeMax; + + private String size; + private String aperture; + + @Column(name = "compatible") + private String compatibleText; + + private String barcode; + + @Column(name = "dedupe_key") + private String dedupeKey; + + @Column(name = "remark") + private String remarkText; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column(name = "reviewer_id") + private Long reviewerId; + + @Column(name = "product_id") + private Long productId; + + @Column(name = "global_sku_id") + private Long globalSkuId; + + @Column(name = "reviewed_at") + private LocalDateTime reviewedAt; + + @Column(name = "review_remark") + private String reviewRemark; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public enum Status { + pending, + approved, + rejected + } + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getExternalCode() { return externalCode; } + public void setExternalCode(String externalCode) { this.externalCode = externalCode; } + public String getModelUnique() { return modelUnique; } + public void setModelUnique(String modelUnique) { this.modelUnique = modelUnique; } + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + public String getSpec() { return spec; } + public void setSpec(String spec) { this.spec = spec; } + public String getOrigin() { return origin; } + public void setOrigin(String origin) { this.origin = origin; } + public Long getUnitId() { return unitId; } + public void setUnitId(Long unitId) { this.unitId = unitId; } + public Long getCategoryId() { return categoryId; } + public void setCategoryId(Long categoryId) { this.categoryId = categoryId; } + public Long getTemplateId() { return templateId; } + public void setTemplateId(Long templateId) { this.templateId = templateId; } + public String getAttributesJson() { return attributesJson; } + public void setAttributesJson(String attributesJson) { this.attributesJson = attributesJson; } + public String getImagesJson() { return imagesJson; } + public void setImagesJson(String imagesJson) { this.imagesJson = imagesJson; } + public java.math.BigDecimal getSafeMin() { return safeMin; } + public void setSafeMin(java.math.BigDecimal safeMin) { this.safeMin = safeMin; } + public java.math.BigDecimal getSafeMax() { return safeMax; } + public void setSafeMax(java.math.BigDecimal safeMax) { this.safeMax = safeMax; } + public String getSize() { return size; } + public void setSize(String size) { this.size = size; } + public String getAperture() { return aperture; } + public void setAperture(String aperture) { this.aperture = aperture; } + public String getCompatibleText() { return compatibleText; } + public void setCompatibleText(String compatibleText) { this.compatibleText = compatibleText; } + public String getBarcode() { return barcode; } + public void setBarcode(String barcode) { this.barcode = barcode; } + public String getDedupeKey() { return dedupeKey; } + public void setDedupeKey(String dedupeKey) { this.dedupeKey = dedupeKey; } + public String getRemarkText() { return remarkText; } + public void setRemarkText(String remarkText) { this.remarkText = remarkText; } + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + public Long getReviewerId() { return reviewerId; } + public void setReviewerId(Long reviewerId) { this.reviewerId = reviewerId; } + public Long getProductId() { return productId; } + public void setProductId(Long productId) { this.productId = productId; } + public Long getGlobalSkuId() { return globalSkuId; } + public void setGlobalSkuId(Long globalSkuId) { this.globalSkuId = globalSkuId; } + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } + public String getReviewRemark() { return reviewRemark; } + public void setReviewRemark(String reviewRemark) { this.reviewRemark = reviewRemark; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductUnit.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductUnit.java new file mode 100644 index 0000000..1992af1 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/entity/ProductUnit.java @@ -0,0 +1,51 @@ +package com.example.demo.product.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "product_units") +public class ProductUnit { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "shop_id", nullable = false) + private Long shopId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "name", nullable = false, length = 16) + private String name; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { return id; } + public Long getShopId() { return shopId; } + public void setShopId(Long shopId) { this.shopId = shopId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public LocalDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/CategoryRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/CategoryRepository.java new file mode 100644 index 0000000..6dd4603 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/CategoryRepository.java @@ -0,0 +1,22 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.ProductCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CategoryRepository extends JpaRepository { + @Query("SELECT c FROM ProductCategory c WHERE c.shopId = :shopId AND c.deletedAt IS NULL ORDER BY c.sortOrder ASC, c.id DESC") + List listByShop(@Param("shopId") Long shopId); + + boolean existsByShopIdAndName(Long shopId, String name); +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/InventoryRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/InventoryRepository.java new file mode 100644 index 0000000..37470b5 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/InventoryRepository.java @@ -0,0 +1,14 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.Inventory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InventoryRepository extends JpaRepository { +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateParamRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateParamRepository.java new file mode 100644 index 0000000..43f7f98 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateParamRepository.java @@ -0,0 +1,12 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.PartTemplateParam; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PartTemplateParamRepository extends JpaRepository { + List findByTemplateIdOrderBySortOrderAscIdAsc(Long templateId); +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateRepository.java new file mode 100644 index 0000000..3fe6ef8 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/PartTemplateRepository.java @@ -0,0 +1,12 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.PartTemplate; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PartTemplateRepository extends JpaRepository { + java.util.List findByStatusOrderByIdDesc(Integer status); + java.util.List findByStatusAndCategoryIdOrderByIdDesc(Integer status, Long categoryId); + java.util.List findByDeletedAtIsNullOrderByIdDesc(); +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductImageRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductImageRepository.java new file mode 100644 index 0000000..6f150e6 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductImageRepository.java @@ -0,0 +1,18 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.ProductImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductImageRepository extends JpaRepository { + List findByProductIdOrderBySortOrderAscIdAsc(Long productId); + void deleteByProductId(Long productId); +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java new file mode 100644 index 0000000..4e3c23b --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java @@ -0,0 +1,14 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.ProductPrice; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductPriceRepository extends JpaRepository { +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductRepository.java new file mode 100644 index 0000000..8909908 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductRepository.java @@ -0,0 +1,35 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.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 { + + @Query("SELECT p FROM Product p WHERE p.shopId = :shopId AND (p.deletedAt IS NULL) AND " + + "(:kw IS NULL OR :kw = '' OR p.name LIKE CONCAT('%', :kw, '%') OR p.brand LIKE CONCAT('%', :kw, '%') OR p.model LIKE CONCAT('%', :kw, '%') OR p.spec LIKE CONCAT('%', :kw, '%') OR p.barcode LIKE CONCAT('%', :kw, '%')) AND " + + "(:categoryId IS NULL OR p.categoryId = :categoryId) AND (:templateId IS NULL OR p.templateId = :templateId) ORDER BY p.id DESC") + Page search(@Param("shopId") Long shopId, + @Param("kw") String kw, + @Param("categoryId") Long categoryId, + @Param("templateId") Long templateId, + Pageable pageable); + + boolean existsByShopIdAndBarcode(Long shopId, String barcode); + + long countByShopIdAndBarcodeAndIdNot(Long shopId, String barcode, Long id); + + java.util.Optional findByShopIdAndModelAndDeletedAtIsNull(Long shopId, String model); + + long countByTemplateIdAndNameAndModelAndDeletedAtIsNull(Long templateId, String name, String model); +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductSubmissionRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductSubmissionRepository.java new file mode 100644 index 0000000..e48c861 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/ProductSubmissionRepository.java @@ -0,0 +1,42 @@ +package com.example.demo.product.repo; + +import com.example.demo.product.entity.ProductSubmission; +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; + +import java.util.List; +import java.util.Optional; + +public interface ProductSubmissionRepository extends JpaRepository { + + Page findByShopIdAndUserIdAndStatusIn(Long shopId, Long userId, List statuses, Pageable pageable); + + Page findByShopIdAndUserIdAndStatusInAndDeletedAtIsNull(Long shopId, Long userId, List statuses, Pageable pageable); + + @Query("SELECT ps FROM ProductSubmission ps WHERE ps.deletedAt IS NULL AND (:statusList IS NULL OR ps.status IN :statusList) " + + "AND (:kw IS NULL OR ps.modelUnique LIKE :kw OR ps.name LIKE :kw OR ps.brand LIKE :kw) " + + "AND (:shopId IS NULL OR ps.shopId = :shopId) " + + "AND (:reviewerId IS NULL OR ps.reviewerId = :reviewerId) " + + "AND (:startAt IS NULL OR ps.createdAt >= :startAt) " + + "AND (:endAt IS NULL OR ps.createdAt <= :endAt)") + Page searchAdmin(@Param("statusList") List statusList, + @Param("kw") String kw, + @Param("shopId") Long shopId, + @Param("reviewerId") Long reviewerId, + @Param("startAt") java.time.LocalDateTime startAt, + @Param("endAt") java.time.LocalDateTime endAt, + Pageable pageable); + + Optional findByModelUnique(String modelUnique); + + boolean existsByModelUnique(String modelUnique); + + boolean existsByTemplateIdAndNameAndModelUnique(Long templateId, String name, String modelUnique); + + long countByNameAndModelUniqueAndTemplateIdNot(String name, String modelUnique, Long templateId); + + Optional findByIdAndShopIdAndUserId(Long id, Long shopId, Long userId); +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/UnitRepository.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/UnitRepository.java new file mode 100644 index 0000000..6816ffc --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/repo/UnitRepository.java @@ -0,0 +1,21 @@ +package com.example.demo.product.repo; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface UnitRepository extends JpaRepository { + @Query("SELECT u FROM ProductUnit u WHERE u.shopId = :shopId AND u.deletedAt IS NULL ORDER BY u.id DESC") + List listByShop(@Param("shopId") Long shopId); + + boolean existsByShopIdAndName(Long shopId, String name); +} + + + + + + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/PartTemplateService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/PartTemplateService.java new file mode 100644 index 0000000..8a4b15e --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/PartTemplateService.java @@ -0,0 +1,240 @@ +package com.example.demo.product.service; + +import com.example.demo.common.JsonUtils; +import com.example.demo.product.dto.PartTemplateDtos; +import com.example.demo.product.entity.PartTemplate; +import com.example.demo.product.entity.PartTemplateParam; +import com.example.demo.product.repo.PartTemplateParamRepository; +import com.example.demo.product.repo.PartTemplateRepository; +import com.example.demo.product.repo.ProductRepository; +import com.example.demo.product.repo.ProductSubmissionRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +public class PartTemplateService { + + private final PartTemplateRepository templateRepository; + private final PartTemplateParamRepository paramRepository; + private final ProductRepository productRepository; + private final ProductSubmissionRepository submissionRepository; + private final JdbcTemplate jdbcTemplate; + + public PartTemplateService(PartTemplateRepository templateRepository, + PartTemplateParamRepository paramRepository, + ProductRepository productRepository, + ProductSubmissionRepository submissionRepository, + JdbcTemplate jdbcTemplate) { + this.templateRepository = templateRepository; + this.paramRepository = paramRepository; + this.productRepository = productRepository; + this.submissionRepository = submissionRepository; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public Long create(PartTemplateDtos.CreateRequest req, Long adminId) { + validate(req); + validateParamDefs(req.params); + LocalDateTime now = LocalDateTime.now(); + PartTemplate t = new PartTemplate(); + t.setCategoryId(req.categoryId); + t.setName(req.name); + t.setModelRule(req.modelRule); + t.setStatus(req.status == null ? 1 : req.status); + t.setCreatedByAdminId(adminId); + t.setCreatedAt(now); + t.setUpdatedAt(now); + templateRepository.save(t); + + upsertParams(t.getId(), req.params, now); + return t.getId(); + } + + @Transactional + public void update(Long id, PartTemplateDtos.UpdateRequest req) { + PartTemplate t = templateRepository.findById(id).orElseThrow(); + if (req.categoryId != null) t.setCategoryId(req.categoryId); + if (req.name != null) t.setName(req.name); + if (req.modelRule != null) t.setModelRule(req.modelRule); + if (req.status != null) t.setStatus(req.status); + t.setUpdatedAt(LocalDateTime.now()); + try { + // 若模板已被软删,不允许通过 update 将其“启用”,需运维恢复 + java.lang.reflect.Field f = t.getClass().getDeclaredField("deletedAt"); + f.setAccessible(true); + Object v = f.get(t); + if (v != null && (req.status != null && req.status == 1)) { + throw new IllegalStateException("模板已删除,无法启用。请联系平台管理员"); + } + } catch (NoSuchFieldException ignore) { } catch (IllegalAccessException ignore) { } + templateRepository.save(t); + + if (req.params != null) { + validateParamDefs(req.params); + // 覆盖式更新 + paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(id).forEach(p -> paramRepository.deleteById(p.getId())); + upsertParams(id, req.params, LocalDateTime.now()); + } + + if (req.deleteAllRelatedProductsAndSubmissions) { + // 全删关联 products + 其价格、库存、图片 + submissions(软删) + // 软删 products + jdbcTemplate.update("UPDATE products SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + // 物理清空商品图片/价格/库存 + jdbcTemplate.update("DELETE FROM product_images WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + jdbcTemplate.update("DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + jdbcTemplate.update("DELETE FROM inventories WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + // 软删 submissions + jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + } + } + + public PartTemplateDtos.TemplateDetail detail(Long id) { + PartTemplate t = templateRepository.findById(id).orElseThrow(); + PartTemplateDtos.TemplateDetail d = new PartTemplateDtos.TemplateDetail(); + d.id = t.getId(); + d.categoryId = t.getCategoryId(); + d.name = t.getName(); + d.modelRule = t.getModelRule(); + d.status = t.getStatus(); + d.createdAt = t.getCreatedAt(); + d.updatedAt = t.getUpdatedAt(); + d.params = toParamDtos(paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(id)); + return d; + } + + public List list() { + List list = templateRepository.findAll(); + List out = new ArrayList<>(); + for (PartTemplate t : list) { + PartTemplateDtos.TemplateItem it = new PartTemplateDtos.TemplateItem(); + it.id = t.getId(); + it.categoryId = t.getCategoryId(); + it.name = t.getName(); + it.modelRule = t.getModelRule(); + it.status = t.getStatus(); + it.createdAt = t.getCreatedAt(); + it.updatedAt = t.getUpdatedAt(); + out.add(it); + } + return out; + } + + @Transactional + public void delete(Long id, boolean force) { + if (!force) { + // 软删除:隐藏模板并级联软删该模板下商品 + PartTemplate t = templateRepository.findById(id).orElseThrow(); + t.setStatus(0); + t.setUpdatedAt(LocalDateTime.now()); + // 统一软删标记:写入 deleted_at + try { jdbcTemplate.update("UPDATE part_templates SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL", id); } catch (Exception ignore) {} + templateRepository.save(t); + // 级联软删商品与配件提交 + jdbcTemplate.update("UPDATE products SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + return; + } + + // 永久删除:删除参数与模板,并清理关联数据 + paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(id).forEach(p -> paramRepository.deleteById(p.getId())); + jdbcTemplate.update("UPDATE products SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + jdbcTemplate.update("DELETE FROM product_images WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + jdbcTemplate.update("DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + jdbcTemplate.update("DELETE FROM inventories WHERE product_id IN (SELECT id FROM products WHERE template_id=? )", id); + jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL", id); + templateRepository.deleteById(id); + } + + private void upsertParams(Long templateId, List params, LocalDateTime now) { + if (params == null) return; + int idx = 0; + for (PartTemplateDtos.ParamDef def : params) { + PartTemplateParam p = new PartTemplateParam(); + p.setTemplateId(templateId); + p.setFieldKey(def.fieldKey); + p.setFieldLabel(def.fieldLabel); + p.setType(def.type); + p.setRequired(def.required); + p.setUnit(def.unit); + p.setEnumOptionsJson(def.enumOptions == null ? null : JsonUtils.toJson(def.enumOptions)); + // 搜索默认参与:若前端未传,置为 true + p.setSearchable(def.searchable || true); + p.setFuzzySearchable(def.fuzzySearchable); + p.setFuzzyTolerance(def.fuzzyTolerance); + p.setCardDisplay(def.cardDisplay); + // 已忽略 dedupeParticipate(统一置 false 以满足非空约束) + p.setDedupeParticipate(false); + p.setSortOrder(def.sortOrder == 0 ? idx : def.sortOrder); + p.setCreatedAt(now); + p.setUpdatedAt(now); + paramRepository.save(p); + idx++; + } + } + + private List toParamDtos(List list) { + List out = new ArrayList<>(); + for (PartTemplateParam p : list) { + PartTemplateDtos.ParamDef d = new PartTemplateDtos.ParamDef(); + d.fieldKey = p.getFieldKey(); + d.fieldLabel = p.getFieldLabel(); + d.type = p.getType(); + d.required = Boolean.TRUE.equals(p.getRequired()); + d.unit = p.getUnit(); + d.enumOptions = JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference>(){}); + d.searchable = Boolean.TRUE.equals(p.getSearchable()); + d.fuzzySearchable = Boolean.TRUE.equals(p.getFuzzySearchable()); + d.fuzzyTolerance = p.getFuzzyTolerance(); + d.cardDisplay = Boolean.TRUE.equals(p.getCardDisplay()); + // 不再回传 dedupeParticipate + d.sortOrder = p.getSortOrder() == null ? 0 : p.getSortOrder(); + out.add(d); + } + return out; + } + + private void validate(PartTemplateDtos.CreateRequest req) { + if (req == null) throw new IllegalArgumentException("请求不能为空"); + if (req.categoryId == null) throw new IllegalArgumentException("categoryId必填"); + if (req.name == null || req.name.isBlank()) throw new IllegalArgumentException("name必填"); + } + + private void validateParamDefs(java.util.List params) { + if (params == null) return; + java.util.Set keys = new java.util.HashSet<>(); + for (PartTemplateDtos.ParamDef def : params) { + if (def == null) throw new IllegalArgumentException("参数定义不能为空"); + String key = def.fieldKey == null ? null : def.fieldKey.trim(); + String label = def.fieldLabel == null ? null : def.fieldLabel.trim(); + def.fieldKey = key; + def.fieldLabel = label; + if (key == null || key.isEmpty()) throw new IllegalArgumentException("参数键(fieldKey)不能为空"); + if (!key.matches("[A-Za-z_][A-Za-z0-9_]*")) throw new IllegalArgumentException("参数键仅支持字母/数字/下划线,且不能以数字开头: " + key); + if (label == null || label.isEmpty()) throw new IllegalArgumentException("参数名称(fieldLabel)不能为空"); + String type = def.type == null ? "" : def.type; + if (!("string".equals(type) || "number".equals(type) || "boolean".equals(type) || "enum".equals(type) || "date".equals(type))) { + throw new IllegalArgumentException("不支持的参数类型: " + type); + } + // fuzzy 校验:仅 number 类型允许;启用时容差可留空(用默认)或为正数 + if (Boolean.TRUE.equals(def.fuzzySearchable) && !"number".equals(type)) { + throw new IllegalArgumentException("仅 number 类型参数允许开启可模糊查询: " + key); + } + if ("number".equals(type) && Boolean.TRUE.equals(def.fuzzySearchable) && def.fuzzyTolerance != null) { + if (def.fuzzyTolerance.compareTo(java.math.BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("容差需为正数: " + key); + } + } + if (keys.contains(key)) throw new IllegalArgumentException("参数键重复: " + key); + keys.add(key); + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductService.java new file mode 100644 index 0000000..e27a7d6 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductService.java @@ -0,0 +1,443 @@ +package com.example.demo.product.service; + +import com.example.demo.common.JsonUtils; +import com.example.demo.product.dto.ProductDtos; +import com.example.demo.product.entity.Inventory; +import com.example.demo.product.entity.Product; +import com.example.demo.product.entity.ProductImage; +import com.example.demo.product.entity.ProductPrice; +import com.example.demo.product.entity.ProductSubmission; +import com.example.demo.product.repo.InventoryRepository; +import com.example.demo.product.repo.ProductImageRepository; +import com.example.demo.product.repo.ProductPriceRepository; +import com.example.demo.product.repo.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +public class ProductService { + + private final ProductRepository productRepository; + private final ProductPriceRepository priceRepository; + private final InventoryRepository inventoryRepository; + private final ProductImageRepository imageRepository; + private final JdbcTemplate jdbcTemplate; + private final com.example.demo.common.SearchFuzzyProperties fuzzyProps; + + public ProductService(ProductRepository productRepository, + ProductPriceRepository priceRepository, + InventoryRepository inventoryRepository, + ProductImageRepository imageRepository, + JdbcTemplate jdbcTemplate, + com.example.demo.common.SearchFuzzyProperties fuzzyProps) { + this.productRepository = productRepository; + this.priceRepository = priceRepository; + this.inventoryRepository = inventoryRepository; + this.imageRepository = imageRepository; + this.jdbcTemplate = jdbcTemplate; + this.fuzzyProps = fuzzyProps; + } + + @Transactional + public void bindSubmission(Long productId, Long submissionId) { + productRepository.findById(productId).ifPresent(p -> { + p.setSourceSubmissionId(submissionId); + productRepository.save(p); + }); + } + + @Transactional + public void updateGlobalSku(Long productId, Long globalSkuId) { + productRepository.findById(productId).ifPresent(p -> { + p.setGlobalSkuId(globalSkuId); + productRepository.save(p); + }); + } + + public Long findProductByModel(Long shopId, String model) { + if (model == null || model.isBlank()) return null; + Optional existed = productRepository.findByShopIdAndModelAndDeletedAtIsNull(shopId, model); + return existed.map(Product::getId).orElse(null); + } + + @Transactional + public void updateProductFromSubmission(Long productId, ProductSubmission submission, Long globalSkuId) { + Product product = productRepository.findById(productId).orElse(null); + if (product == null) return; + boolean changed = false; + if (submission.getTemplateId() != null && (product.getTemplateId() == null || !submission.getTemplateId().equals(product.getTemplateId()))) { + product.setTemplateId(submission.getTemplateId()); + changed = true; + } + if (submission.getName() != null && !submission.getName().isBlank()) { + product.setName(submission.getName()); + changed = true; + } + if (submission.getBrand() != null && !submission.getBrand().isBlank()) { + product.setBrand(submission.getBrand()); + changed = true; + } + if (submission.getSpec() != null && !submission.getSpec().isBlank()) { + product.setSpec(submission.getSpec()); + changed = true; + } + if (submission.getBarcode() != null && !submission.getBarcode().isBlank()) { + product.setBarcode(submission.getBarcode()); + changed = true; + } + if (submission.getCategoryId() != null && !submission.getCategoryId().equals(product.getCategoryId())) { + product.setCategoryId(submission.getCategoryId()); + changed = true; + } + // 单位字段已移除 + if (submission.getRemarkText() != null && !submission.getRemarkText().isBlank()) { + product.setDescription(submission.getRemarkText()); + changed = true; + } + if (submission.getOrigin() != null && !submission.getOrigin().isBlank()) { + product.setOrigin(submission.getOrigin()); + changed = true; + } + if (submission.getSafeMin() != null && submission.getSafeMin().compareTo(BigDecimal.ZERO) >= 0) { + product.setSafeMin(submission.getSafeMin()); + changed = true; + } + if (submission.getSafeMax() != null && submission.getSafeMax().compareTo(BigDecimal.ZERO) >= 0) { + product.setSafeMax(submission.getSafeMax()); + changed = true; + } + if (globalSkuId != null) { + product.setGlobalSkuId(globalSkuId); + changed = true; + } + if (submission.getAttributesJson() != null && !submission.getAttributesJson().isBlank()) { + product.setAttributesJson(submission.getAttributesJson()); + changed = true; + } + // 忽略 dedupeKey + if (changed) { + product.setUpdatedAt(LocalDateTime.now()); + productRepository.save(product); + } + + List images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + syncImages(submission.getUserId(), productId, product.getShopId(), images); + } + + public Page search(Long shopId, String kw, Long categoryId, Long templateId, java.util.Map paramFilters, int page, int size) { + // 直接使用 JDBC 支持 JSON_EXTRACT 过滤(MySQL) + StringBuilder sql = new StringBuilder("SELECT p.id,p.name,p.brand,p.model,p.spec,\n" + + "(SELECT i.quantity FROM inventories i WHERE i.product_id=p.id) AS stock,\n" + + "(SELECT pr.retail_price FROM product_prices pr WHERE pr.product_id=p.id) AS retail_price,\n" + + "(SELECT img.url FROM product_images img WHERE img.product_id=p.id ORDER BY img.sort_order, img.id LIMIT 1) AS cover,\n" + + "(p.deleted_at IS NOT NULL) AS deleted\n" + + "FROM products p WHERE p.shop_id=? AND p.deleted_at IS NULL"); + List ps = new ArrayList<>(); + ps.add(shopId); + if (kw != null && !kw.isBlank()) { sql.append(" AND (p.name LIKE ? OR p.brand LIKE ? OR p.model LIKE ? OR p.spec LIKE ? OR p.barcode LIKE ?)"); + String like = "%" + kw + "%"; ps.add(like); ps.add(like); ps.add(like); ps.add(like); ps.add(like); } + if (categoryId != null) { sql.append(" AND p.category_id=?"); ps.add(categoryId); } + if (templateId != null) { sql.append(" AND p.template_id=?"); ps.add(templateId); } + if (paramFilters != null && !paramFilters.isEmpty()) { + java.util.Map keyFuzzyCache = new java.util.HashMap<>(); + for (java.util.Map.Entry ent : paramFilters.entrySet()) { + String key = ent.getKey(); String val = ent.getValue(); + if (key == null || key.isBlank() || val == null || val.isBlank()) continue; + + boolean fuzzyEnabled = fuzzyProps.isEnabled() && isKeyFuzzyEnabled(key, keyFuzzyCache); + java.math.BigDecimal valNum = null; + boolean numericOk = false; + if (fuzzyEnabled) { + try { + valNum = new java.math.BigDecimal(val.trim()); + numericOk = true; + } catch (Exception ignore) { numericOk = false; } + } + + if (fuzzyEnabled && numericOk) { + // 行级模糊:存在 fuzzy 定义则按区间,否则按等值(NOT EXISTS 分支) + sql.append(" AND ( ") + .append("EXISTS (SELECT 1 FROM part_template_params ptp WHERE ptp.template_id=p.template_id AND ptp.field_key=? AND ptp.type='number' AND ptp.fuzzy_searchable=1 ") + .append("AND CAST(JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) AS DECIMAL(18,6)) BETWEEN GREATEST(0, ? - COALESCE(ptp.fuzzy_tolerance, ?)) AND (? + COALESCE(ptp.fuzzy_tolerance, ?)) ) ") + .append(" OR (NOT EXISTS (SELECT 1 FROM part_template_params ptp2 WHERE ptp2.template_id=p.template_id AND ptp2.field_key=? AND ptp2.type='number' AND ptp2.fuzzy_searchable=1) ") + .append(" AND JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) = ? ) )"); + + ps.add(key); // ptp.field_key + ps.add("$." + key); // json path + ps.add(valNum); // v for lower + ps.add(fuzzyProps.getDefaultTolerance()); // default tol + ps.add(valNum); // v for upper + ps.add(fuzzyProps.getDefaultTolerance()); // default tol + ps.add(key); // ptp2.field_key for NOT EXISTS + ps.add("$." + key); // json path for equality + ps.add(val.trim()); // equality value + } else { + // 直接等值匹配(包括:未启用全局模糊、该字段不在任何模板中启用模糊、或值非数字) + sql.append(" AND JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) = ?"); + ps.add("$." + key); + ps.add(val.trim()); + } + } + } + sql.append(" ORDER BY p.id DESC LIMIT ? OFFSET ?"); + ps.add(size); ps.add(page * size); + List list = jdbcTemplate.query(sql.toString(), (rs,rn) -> { + ProductDtos.ProductListItem it = new ProductDtos.ProductListItem(); + it.id = rs.getLong("id"); + it.name = rs.getString("name"); + it.brand = rs.getString("brand"); + it.model = rs.getString("model"); + it.spec = rs.getString("spec"); + java.math.BigDecimal st = (java.math.BigDecimal) rs.getObject("stock"); + it.stock = st; + java.math.BigDecimal rp = (java.math.BigDecimal) rs.getObject("retail_price"); + it.retailPrice = rp; + it.cover = rs.getString("cover"); + it.deleted = rs.getBoolean("deleted"); + // 取卡片展示参数:根据模板定义 card_display=1,最多4个 + try { + String json = jdbcTemplate.queryForObject( + "SELECT p.attributes_json FROM products p WHERE p.id=?", String.class, it.id); + java.util.Map params = com.example.demo.common.JsonUtils.fromJson(json, new com.fasterxml.jackson.core.type.TypeReference>() {}); + java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + java.util.List> defs = jdbcTemplate.query( + "SELECT field_key, field_label, unit FROM part_template_params WHERE template_id=(SELECT template_id FROM products WHERE id=?) AND card_display=1 ORDER BY sort_order, id LIMIT 4", + ps2 -> ps2.setLong(1, it.id), + (r2, rn2) -> { + java.util.Map m = new java.util.HashMap<>(); + m.put("key", r2.getString("field_key")); + m.put("label", r2.getString("field_label")); + m.put("unit", r2.getString("unit")); + return m; + } + ); + for (java.util.Map d : defs) { + String k = (String)d.get("key"); + String label = (String)d.get("label"); + String unit = (String)d.get("unit"); + Object v = params == null ? null : params.get(k); + if (v == null) continue; + String val = String.valueOf(v) + (unit==null||unit.isBlank()?"":"" ); + map.put(label + (unit==null||unit.isBlank()?"":"("+unit+")"), val); + } + it.cardParams = map; + } catch (Exception ignore) {} + return it; + }, ps.toArray()); + return new PageImpl<>(list, PageRequest.of(page, size), list.size()); + } + + private boolean isKeyFuzzyEnabled(String fieldKey, java.util.Map cache) { + if (cache.containsKey(fieldKey)) return cache.get(fieldKey); + Integer cnt = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM part_template_params WHERE field_key=? AND type='number' AND fuzzy_searchable=1", + Integer.class, fieldKey); + boolean enabled = cnt != null && cnt > 0; + cache.put(fieldKey, enabled); + return enabled; + } + + public Optional findDetail(Long id, boolean includeDeleted) { + Optional op = productRepository.findById(id); + if (op.isEmpty()) return Optional.empty(); + Product p = op.get(); + if (p.getDeletedAt() != null && !includeDeleted) return Optional.empty(); + ProductDtos.ProductDetail d = new ProductDtos.ProductDetail(); + d.id = p.getId(); + d.name = p.getName(); + d.barcode = p.getBarcode(); + d.brand = p.getBrand(); + d.model = p.getModel(); + d.spec = p.getSpec(); + d.origin = p.getOrigin(); + d.categoryId = p.getCategoryId(); + d.templateId = p.getTemplateId(); + d.safeMin = p.getSafeMin(); + d.safeMax = p.getSafeMax(); + inventoryRepository.findById(p.getId()).ifPresent(inv -> d.stock = inv.getQuantity()); + priceRepository.findById(p.getId()).ifPresent(pr -> { + d.purchasePrice = pr.getPurchasePrice(); + d.retailPrice = pr.getRetailPrice(); + d.wholesalePrice = pr.getWholesalePrice(); + d.bigClientPrice = pr.getBigClientPrice(); + }); + List imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(p.getId()); + List list = new ArrayList<>(); + for (ProductImage img : imgs) { + ProductDtos.Image i = new ProductDtos.Image(); + i.url = img.getUrl(); + list.add(i); + } + d.images = list; + d.parameters = JsonUtils.fromJson(p.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + d.sourceSubmissionId = p.getSourceSubmissionId(); + // deleted 标志供前端展示 + d.deleted = (p.getDeletedAt() != null); + // externalCode 来自 submission,若来源存在可透传(此处留空,由前端兼容) + return Optional.of(d); + } + + @Transactional + public Long create(Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) { + validate(shopId, req); + LocalDateTime now = LocalDateTime.now(); + + Product p = new Product(); + p.setShopId(shopId); + p.setUserId(userId); + p.setTemplateId(req.templateId); + p.setName(req.name); + p.setBarcode(emptyToNull(req.barcode)); + p.setBrand(emptyToNull(req.brand)); + p.setModel(emptyToNull(req.model)); + p.setSpec(emptyToNull(req.spec)); + p.setOrigin(emptyToNull(req.origin)); + p.setCategoryId(req.categoryId); + // 单位字段已移除 + // 忽略 dedupeKey + p.setSafeMin(req.safeMin); + p.setSafeMax(req.safeMax); + p.setDescription(emptyToNull(req.remark)); + p.setSourceSubmissionId(req.sourceSubmissionId); + p.setGlobalSkuId(req.globalSkuId); + if (req.parameters != null && !req.parameters.isEmpty()) { + p.setAttributesJson(JsonUtils.toJson(req.parameters)); + } + p.setCreatedAt(now); + p.setUpdatedAt(now); + productRepository.save(p); + + upsertPrice(userId, now, p.getId(), shopId, req.prices); + upsertInventory(userId, now, p.getId(), shopId, req.stock); + syncImages(userId, p.getId(), shopId, req.images); + + return p.getId(); + } + + @Transactional + public Long createFromSubmission(ProductSubmission submission, ProductDtos.CreateOrUpdateProductRequest req) { + Long productId = create(submission.getShopId(), submission.getUserId(), req); + productRepository.findById(productId).ifPresent(p -> { + p.setSourceSubmissionId(submission.getId()); + if (req.globalSkuId != null) { + p.setGlobalSkuId(req.globalSkuId); + } + p.setUpdatedAt(LocalDateTime.now()); + productRepository.save(p); + }); + return productId; + } + + @Transactional + public void update(Long id, Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) { + validate(shopId, req); + Product p = productRepository.findById(id).orElseThrow(); + if (!p.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺数据"); + // 条码唯一性:允许与自身相同,但不允许与其他商品重复 + if (req.barcode != null && !req.barcode.isBlank()) { + if (productRepository.countByShopIdAndBarcodeAndIdNot(shopId, req.barcode, id) > 0) { + throw new IllegalArgumentException("条码已存在"); + } + } + p.setUserId(userId); + p.setTemplateId(req.templateId); + p.setName(req.name); + p.setBarcode(emptyToNull(req.barcode)); + p.setBrand(emptyToNull(req.brand)); + p.setModel(emptyToNull(req.model)); + p.setSpec(emptyToNull(req.spec)); + p.setOrigin(emptyToNull(req.origin)); + p.setCategoryId(req.categoryId); + // 单位字段已移除 + // 忽略 dedupeKey + p.setSafeMin(req.safeMin); + p.setSafeMax(req.safeMax); + p.setDescription(emptyToNull(req.remark)); + p.setUpdatedAt(LocalDateTime.now()); + productRepository.save(p); + + LocalDateTime now = LocalDateTime.now(); + upsertPrice(userId, now, p.getId(), shopId, req.prices); + upsertInventory(userId, now, p.getId(), shopId, req.stock); + syncImages(userId, p.getId(), shopId, req.images); + } + + private void validate(Long shopId, ProductDtos.CreateOrUpdateProductRequest req) { + if (req.name == null || req.name.isBlank()) throw new IllegalArgumentException("name必填"); + // 不再要求 unitId + if (req.safeMin != null && req.safeMax != null) { + if (req.safeMin.compareTo(req.safeMax) > 0) throw new IllegalArgumentException("安全库存区间不合法"); + } + if (req.barcode != null && !req.barcode.isBlank()) { + if (productRepository.existsByShopIdAndBarcode(shopId, req.barcode)) { + // 更新时允许自己相同:由Controller层在调用前判定并跳过;简化此处逻辑 + } + } + } + + private void upsertPrice(Long userId, LocalDateTime now, Long productId, Long shopId, ProductDtos.Prices prices) { + if (prices == null) prices = new ProductDtos.Prices(); + java.util.Optional existed = priceRepository.findById(productId); + ProductPrice pr = existed.orElseGet(ProductPrice::new); + pr.setProductId(productId); + pr.setShopId(shopId); + pr.setUserId(userId); + pr.setPurchasePrice(nvl(prices.purchasePrice, BigDecimal.ZERO)); + pr.setRetailPrice(nvl(prices.retailPrice, BigDecimal.ZERO)); + pr.setWholesalePrice(nvl(prices.wholesalePrice, BigDecimal.ZERO)); + pr.setBigClientPrice(nvl(prices.bigClientPrice, BigDecimal.ZERO)); + pr.setUpdatedAt(now); + priceRepository.save(pr); + } + + private void upsertInventory(Long userId, LocalDateTime now, Long productId, Long shopId, BigDecimal stock) { + Inventory inv = inventoryRepository.findById(productId).orElseGet(Inventory::new); + inv.setProductId(productId); + inv.setShopId(shopId); + inv.setUserId(userId); + inv.setQuantity(nvl(stock, BigDecimal.ZERO)); + inv.setUpdatedAt(now); + inventoryRepository.save(inv); + } + + private void syncImages(Long userId, Long productId, Long shopId, java.util.List images) { + imageRepository.deleteByProductId(productId); + if (images == null) return; + int idx = 0; + for (String url : images) { + if (url == null || url.isBlank()) continue; + ProductImage img = new ProductImage(); + img.setShopId(shopId); + img.setUserId(userId); + img.setProductId(productId); + img.setUrl(url); + img.setSortOrder(idx++); + imageRepository.save(img); + } + } + + private static T nvl(T v, T def) { return v != null ? v : def; } + private static String emptyToNull(String s) { return (s == null || s.isBlank()) ? null : s; } + + @Transactional + public void delete(Long id, Long shopId) { + Product p = productRepository.findById(id).orElse(null); + if (p == null) return; + if (!p.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺数据"); + p.setDeletedAt(LocalDateTime.now()); + productRepository.save(p); + // 关联数据:价格/库存采用 ON DELETE CASCADE 不触发;软删仅标记主表 + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductSubmissionService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductSubmissionService.java new file mode 100644 index 0000000..9ca9c1a --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/product/service/ProductSubmissionService.java @@ -0,0 +1,519 @@ +package com.example.demo.product.service; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.common.JsonUtils; +import com.example.demo.product.dto.ProductDtos; +import com.example.demo.product.dto.ProductSubmissionDtos; +import com.example.demo.product.entity.ProductSubmission; +import com.example.demo.product.repo.ProductSubmissionRepository; +import com.example.demo.product.repo.PartTemplateParamRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CreationHelper; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +@Service +public class ProductSubmissionService { + + private static final Logger log = LoggerFactory.getLogger(ProductSubmissionService.class); + + private final ProductSubmissionRepository submissionRepository; + private final ProductService productService; + private final PartTemplateParamRepository templateParamRepository; + private final AppDefaultsProperties defaults; + private final JdbcTemplate jdbcTemplate; + + public ProductSubmissionService(ProductSubmissionRepository submissionRepository, + ProductService productService, + AppDefaultsProperties defaults, + PartTemplateParamRepository templateParamRepository, + JdbcTemplate jdbcTemplate) { + this.submissionRepository = submissionRepository; + this.productService = productService; + this.defaults = defaults; + this.templateParamRepository = templateParamRepository; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public Long createSubmission(Long shopId, Long userId, ProductSubmissionDtos.CreateRequest req) { + validateCreate(shopId, userId, req); + ProductSubmission submission = new ProductSubmission(); + submission.setShopId(shopId); + submission.setUserId(userId); + submission.setTemplateId(req.templateId); + if (req.externalCode != null && !req.externalCode.isBlank()) submission.setExternalCode(req.externalCode.trim()); + submission.setName(req.name); + submission.setModelUnique(normalizeModel(req.model)); + submission.setBrand(req.brand); + submission.setSpec(req.spec); + submission.setOrigin(req.origin); + // 单位字段已移除 + submission.setCategoryId(req.categoryId); + submission.setAttributesJson(JsonUtils.toJson(req.parameters)); + submission.setImagesJson(JsonUtils.toJson(req.images)); + submission.setRemarkText(req.remark); + submission.setBarcode(req.barcode); + submission.setSafeMin(req.safeMin); + submission.setSafeMax(req.safeMax); + // 按“前端隐藏 + 后端忽略”方案:不再计算/使用 dedupeKey(兼容历史字段保留) + submission.setStatus(ProductSubmission.Status.pending); + submission.setCreatedAt(LocalDateTime.now()); + submission.setUpdatedAt(LocalDateTime.now()); + submissionRepository.save(submission); + return submission.getId(); + } + + public ProductSubmissionDtos.PageResult listMine(Long shopId, Long userId, String status, int page, int size) { + String normalizedStatus = (status == null || status.isBlank() || "undefined".equalsIgnoreCase(status)) ? null : status; + Page result = submissionRepository.findByShopIdAndUserIdAndStatusInAndDeletedAtIsNull( + shopId, userId, resolveStatuses(normalizedStatus), PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "createdAt"))); + return toPageResult(result); + } + + public ProductSubmissionDtos.PageResult listAdmin(String status, String kw, Long shopId, + Long reviewerId, String startAt, String endAt, + int page, int size) { + List statuses = resolveStatuses(status); + String kwLike = (kw == null || kw.isBlank()) ? null : "%" + kw.trim() + "%"; + LocalDateTime start = parseDate(startAt); + LocalDateTime endTime = parseDate(endAt); + Page result = submissionRepository.searchAdmin(statuses.isEmpty() ? null : statuses, + kwLike, shopId, reviewerId, start, endTime, PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "createdAt"))); + return toPageResult(result); + } + + public void export(String status, String kw, Long shopId, Long reviewerId, String startAt, String endAt, + HttpServletResponse response) { + List statuses = resolveStatuses(status); + String kwLike = (kw == null || kw.isBlank()) ? null : "%" + kw.trim() + "%"; + LocalDateTime start = parseDate(startAt); + LocalDateTime endTime = parseDate(endAt); + List records = submissionRepository.searchAdmin(statuses.isEmpty() ? null : statuses, + kwLike, shopId, reviewerId, start, endTime, PageRequest.of(0, 2000, Sort.by(Sort.Direction.DESC, "createdAt"))).getContent(); + // 收集所有模板的必填参数标题 + java.util.LinkedHashSet requiredParamLabels = new java.util.LinkedHashSet<>(); + java.util.Map> labelToKeyByTemplate = new java.util.HashMap<>(); + for (ProductSubmission s : records) { + Long tid = s.getTemplateId(); + if (tid == null || tid <= 0) continue; + if (!labelToKeyByTemplate.containsKey(tid)) { + java.util.Map map = new java.util.LinkedHashMap<>(); + var defs = templateParamRepository.findByTemplateIdOrderBySortOrderAscIdAsc(tid); + for (var d : defs) { + if (Boolean.TRUE.equals(d.getRequired())) { + map.put(d.getFieldLabel(), d.getFieldKey()); + requiredParamLabels.add(d.getFieldLabel()); + } + } + labelToKeyByTemplate.put(tid, map); + } else { + for (var e : labelToKeyByTemplate.get(tid).keySet()) requiredParamLabels.add(e); + } + } + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Submissions"); + CreationHelper creationHelper = workbook.getCreationHelper(); + CellStyle dateStyle = workbook.createCellStyle(); + dateStyle.setDataFormat(creationHelper.createDataFormat().getFormat("yyyy-mm-dd hh:mm")); + + int rowIdx = 0; + Row header = sheet.createRow(rowIdx++); + java.util.List headers = new java.util.ArrayList<>(); + headers.add("编号"); + headers.add("分类"); + headers.add("品牌"); + headers.add("型号"); + headers.addAll(requiredParamLabels); + headers.add("备注"); + for (int i = 0; i < headers.size(); i++) header.createCell(i).setCellValue(headers.get(i)); + + for (ProductSubmission submission : records) { + Row row = sheet.createRow(rowIdx++); + int col = 0; + // 编号、分类、品牌、型号 + row.createCell(col++).setCellValue(nvl(submission.getExternalCode())); + row.createCell(col++).setCellValue(nvl(resolveCategoryName(submission.getCategoryId()))); + row.createCell(col++).setCellValue(nvl(submission.getBrand())); + row.createCell(col++).setCellValue(nvl(submission.getModelUnique())); + // 模板必填参数值 + java.util.Map params = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + java.util.Map l2k = labelToKeyByTemplate.getOrDefault(submission.getTemplateId(), java.util.Collections.emptyMap()); + for (String label : requiredParamLabels) { + String key = l2k.get(label); + Object v = (key == null || params == null) ? null : params.get(key); + row.createCell(col++).setCellValue(v == null ? "" : String.valueOf(v)); + } + // 备注 + row.createCell(col).setCellValue(nvl(submission.getRemarkText())); + } + + for (int i = 0; i < headers.size(); i++) { + sheet.autoSizeColumn(i); + int width = sheet.getColumnWidth(i); + sheet.setColumnWidth(i, Math.min(width + 512, 10000)); + } + + String fileName = "part_submissions_" + System.currentTimeMillis() + ".xlsx"; + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileName); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + workbook.write(response.getOutputStream()); + response.flushBuffer(); + } catch (Exception e) { + throw new RuntimeException("导出失败", e); + } + } + + private final java.util.Map categoryNameCache = new java.util.HashMap<>(); + private String resolveCategoryName(Long categoryId) { + if (categoryId == null) return ""; + if (categoryNameCache.containsKey(categoryId)) return categoryNameCache.get(categoryId); + String name = jdbcTemplate.query("SELECT name FROM product_categories WHERE id=?", ps -> ps.setLong(1, categoryId), rs -> rs.next()? rs.getString(1): ""); + categoryNameCache.put(categoryId, name == null ? "" : name); + return name == null ? "" : name; + } + + public Optional findDetail(Long id) { + return submissionRepository.findById(id).map(this::toDetail); + } + + public Optional findMineDetail(Long id, Long shopId, Long userId) { + return submissionRepository.findByIdAndShopIdAndUserId(id, shopId, userId).map(this::toDetail); + } + + @Transactional + public void updateSubmission(Long id, ProductSubmissionDtos.UpdateRequest req) { + ProductSubmission submission = submissionRepository.findById(id).orElseThrow(); + if (submission.getStatus() != ProductSubmission.Status.pending) { + throw new IllegalArgumentException("仅待审核记录可编辑"); + } + submission.setName(req.name != null ? req.name : submission.getName()); + if (req.externalCode != null) submission.setExternalCode(req.externalCode); + submission.setBrand(req.brand != null ? req.brand : submission.getBrand()); + submission.setSpec(req.spec != null ? req.spec : submission.getSpec()); + submission.setOrigin(req.origin != null ? req.origin : submission.getOrigin()); + // 单位字段已移除 + submission.setCategoryId(req.categoryId != null ? req.categoryId : submission.getCategoryId()); + if (req.parameters != null) submission.setAttributesJson(JsonUtils.toJson(req.parameters)); + if (req.images != null) submission.setImagesJson(JsonUtils.toJson(req.images)); + if (req.remark != null) submission.setRemarkText(req.remark); + if (req.barcode != null) submission.setBarcode(req.barcode); + if (req.safeMin != null) submission.setSafeMin(req.safeMin); + if (req.safeMax != null) submission.setSafeMax(req.safeMax); + // 不再重算 dedupeKey(忽略去重键) + submission.setUpdatedAt(LocalDateTime.now()); + submissionRepository.save(submission); + } + + @Transactional + public ProductSubmissionDtos.ApproveResponse approve(Long id, Long adminId, ProductSubmissionDtos.ApproveRequest req) { + ProductSubmission submission = submissionRepository.findById(id).orElseThrow(); + if (submission.getStatus() != ProductSubmission.Status.pending) { + throw new IllegalArgumentException("记录已审核"); + } + handleApproval(submission, req); + submission.setStatus(ProductSubmission.Status.approved); + submission.setReviewerId(resolveReviewer(adminId)); + submission.setReviewRemark(req == null ? null : req.remark); + submission.setReviewedAt(LocalDateTime.now()); + submission.setUpdatedAt(LocalDateTime.now()); + submissionRepository.save(submission); + + ProductSubmissionDtos.ApproveResponse resp = new ProductSubmissionDtos.ApproveResponse(); + resp.ok = true; + resp.productId = submission.getProductId(); + resp.globalSkuId = submission.getGlobalSkuId(); + return resp; + } + + @Transactional + public void reject(Long id, Long adminId, ProductSubmissionDtos.RejectRequest req) { + if (req == null || req.remark == null || req.remark.isBlank()) { + throw new IllegalArgumentException("请填写驳回原因"); + } + ProductSubmission submission = submissionRepository.findById(id).orElseThrow(); + if (submission.getStatus() != ProductSubmission.Status.pending) { + throw new IllegalArgumentException("记录已审核"); + } + submission.setStatus(ProductSubmission.Status.rejected); + submission.setReviewerId(resolveReviewer(adminId)); + submission.setReviewRemark(req.remark); + submission.setReviewedAt(LocalDateTime.now()); + submission.setUpdatedAt(LocalDateTime.now()); + submissionRepository.save(submission); + } + + private void validateCreate(Long shopId, Long userId, ProductSubmissionDtos.CreateRequest req) { + if (req == null) throw new IllegalArgumentException("请求不能为空"); + if (req.model == null || req.model.isBlank()) throw new IllegalArgumentException("型号必填"); + String normalized = normalizeModel(req.model); + submissionRepository.findByModelUnique(normalized).ifPresent(existing -> { + throw new IllegalArgumentException("该型号已提交"); + }); + // 模板参数强校验 + if (req.templateId != null && req.templateId > 0) { + Map params = req.parameters == null ? java.util.Collections.emptyMap() : req.parameters; + var defs = templateParamRepository.findByTemplateIdOrderBySortOrderAscIdAsc(req.templateId); + for (var def : defs) { + String key = def.getFieldKey(); + Object v = params.get(key); + if (Boolean.TRUE.equals(def.getRequired())) { + if (v == null || (v instanceof String s && s.isBlank())) { + throw new IllegalArgumentException("缺少必填参数: " + def.getFieldLabel()); + } + } + if (v == null) continue; + switch (String.valueOf(def.getType())) { + case "number" -> { + if (!(v instanceof Number)) { + try { new java.math.BigDecimal(String.valueOf(v)); } catch (Exception e) { throw new IllegalArgumentException(def.getFieldLabel()+"应为数字"); } + } + } + case "boolean" -> { + if (!(v instanceof Boolean)) { + String s = String.valueOf(v).toLowerCase(); + if (!"true".equals(s) && !"false".equals(s) && !"1".equals(s) && !"0".equals(s)) { + throw new IllegalArgumentException(def.getFieldLabel()+"应为布尔"); + } + } + } + case "enum" -> { + java.util.List opts = JsonUtils.fromJson(def.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + if (opts != null && !opts.isEmpty()) { + if (!opts.contains(String.valueOf(v))) throw new IllegalArgumentException(def.getFieldLabel()+"取值不在枚举范围"); + } + } + case "date" -> { + try { java.time.LocalDate.parse(String.valueOf(v)); } catch (Exception e) { throw new IllegalArgumentException(def.getFieldLabel()+"应为日期(YYYY-MM-DD)"); } + } + default -> {} + } + } + } + } + + public ProductSubmissionDtos.CheckModelResponse checkModel(String model, Long templateId, String name) { + if (model == null || model.isBlank()) { + throw new IllegalArgumentException("型号不能为空"); + } + String normalized = normalizeModel(model); + boolean exists = submissionRepository.existsByModelUnique(normalized) + || (templateId != null && name != null && submissionRepository.existsByTemplateIdAndNameAndModelUnique(templateId, name, normalized)); + ProductSubmissionDtos.CheckModelResponse resp = new ProductSubmissionDtos.CheckModelResponse(); + resp.model = normalized; + resp.available = !exists; + // 跨模板提示:非阻断 + resp.similarAcrossTemplates = (int)(name == null ? 0 : submissionRepository.countByNameAndModelUniqueAndTemplateIdNot(name, normalized, templateId == null ? -1L : templateId)); + return resp; + } + + private String normalizeModel(String model) { + return model == null ? null : model.trim().toUpperCase(); + } + + private String normalizeText(String text) { + if (text == null) return null; + String s = text.trim().toUpperCase(); + s = s.replaceAll("[\\s\\-_/]+", ""); + s = s.replace('(','(').replace(')',')'); + return s; + } + + private String buildDedupeKey(Long templateId, String name, String model, String brand, Map params) { + StringBuilder sb = new StringBuilder(); + sb.append(templateId == null ? 0 : templateId).append('|'); + sb.append(normalizeText(name)).append('|'); + sb.append(normalizeText(model)).append('|'); + sb.append(normalizeText(brand)).append('|'); + if (params != null && !params.isEmpty()) { + java.util.Map filtered = params; + try { + if (templateId != null && templateId > 0) { + java.util.Set keys = templateParamRepository.findByTemplateIdOrderBySortOrderAscIdAsc(templateId) + .stream().filter(p -> Boolean.TRUE.equals(p.getDedupeParticipate())) + .map(com.example.demo.product.entity.PartTemplateParam::getFieldKey) + .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new)); + if (!keys.isEmpty()) { + java.util.Map temp = new java.util.HashMap<>(); + for (String k : keys) if (params.containsKey(k)) temp.put(k, params.get(k)); + filtered = temp; + } + } + } catch (Exception ignore) {} + java.util.TreeMap sorted = new java.util.TreeMap<>(filtered); + String p = JsonUtils.toJson(sorted); + if (p != null) sb.append(p); + } + return org.springframework.util.DigestUtils.md5DigestAsHex(sb.toString().getBytes()); + } + + private List resolveStatuses(String status) { + if (status == null || status.isBlank()) { + return List.of(ProductSubmission.Status.pending, + ProductSubmission.Status.approved, + ProductSubmission.Status.rejected); + } + return switch (status.toLowerCase()) { + case "pending" -> List.of(ProductSubmission.Status.pending); + case "approved" -> List.of(ProductSubmission.Status.approved); + case "rejected" -> List.of(ProductSubmission.Status.rejected); + default -> List.of(ProductSubmission.Status.pending, + ProductSubmission.Status.approved, + ProductSubmission.Status.rejected); + }; + } + + private ProductSubmissionDtos.PageResult toPageResult(Page page) { + ProductSubmissionDtos.PageResult result = new ProductSubmissionDtos.PageResult<>(); + result.list = page.getContent().stream().map(this::toItem).toList(); + result.total = page.getTotalElements(); + result.page = page.getNumber() + 1; + result.size = page.getSize(); + return result; + } + + private ProductSubmissionDtos.SubmissionItem toItem(ProductSubmission submission) { + ProductSubmissionDtos.SubmissionItem item = new ProductSubmissionDtos.SubmissionItem(); + item.id = submission.getId(); + item.name = submission.getName(); + item.model = submission.getModelUnique(); + item.brand = submission.getBrand(); + item.status = submission.getStatus().name(); + item.shopId = submission.getShopId(); + item.createdAt = submission.getCreatedAt(); + item.reviewedAt = submission.getReviewedAt(); + return item; + } + + private ProductSubmissionDtos.SubmissionDetail toDetail(ProductSubmission submission) { + ProductSubmissionDtos.SubmissionDetail detail = new ProductSubmissionDtos.SubmissionDetail(); + detail.id = submission.getId(); + detail.shopId = submission.getShopId(); + detail.userId = submission.getUserId(); + detail.name = submission.getName(); + detail.externalCode = submission.getExternalCode(); + detail.model = submission.getModelUnique(); + detail.brand = submission.getBrand(); + detail.spec = submission.getSpec(); + detail.origin = submission.getOrigin(); + // 单位字段已移除 + detail.categoryId = submission.getCategoryId(); + detail.templateId = submission.getTemplateId(); + detail.parameters = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + detail.images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + detail.remark = submission.getRemarkText(); + detail.barcode = submission.getBarcode(); + detail.safeMin = submission.getSafeMin(); + detail.safeMax = submission.getSafeMax(); + detail.status = submission.getStatus().name(); + detail.reviewerId = submission.getReviewerId(); + detail.reviewRemark = submission.getReviewRemark(); + detail.reviewedAt = submission.getReviewedAt(); + detail.createdAt = submission.getCreatedAt(); + detail.dedupeKey = submission.getDedupeKey(); + return detail; + } + + private void handleApproval(ProductSubmission submission, ProductSubmissionDtos.ApproveRequest req) { + Long targetProductId; + if (submission.getProductId() != null && submission.getProductId() > 0) { + targetProductId = submission.getProductId(); + productService.updateProductFromSubmission(targetProductId, submission, req == null ? null : req.assignGlobalSkuId); + } else { + Long existingProduct = productService.findProductByModel(submission.getShopId(), submission.getModelUnique()); + if (existingProduct == null) { + ProductDtos.CreateOrUpdateProductRequest payload = buildProductPayload(submission); + targetProductId = productService.createFromSubmission(submission, payload); + } else { + targetProductId = existingProduct; + productService.updateProductFromSubmission(targetProductId, submission, req == null ? null : req.assignGlobalSkuId); + } + submission.setProductId(targetProductId); + } + if (req != null && req.assignGlobalSkuId != null) { + productService.updateGlobalSku(targetProductId, req.assignGlobalSkuId); + submission.setGlobalSkuId(req.assignGlobalSkuId); + } + } + + private ProductDtos.CreateOrUpdateProductRequest buildProductPayload(ProductSubmission submission) { + ProductDtos.CreateOrUpdateProductRequest payload = new ProductDtos.CreateOrUpdateProductRequest(); + payload.templateId = submission.getTemplateId(); + payload.name = (submission.getName() != null && !submission.getName().isBlank()) ? submission.getName() : submission.getModelUnique(); + payload.barcode = submission.getBarcode(); + payload.brand = submission.getBrand(); + payload.model = submission.getModelUnique(); + payload.spec = submission.getSpec(); + payload.origin = submission.getOrigin(); + payload.categoryId = submission.getCategoryId(); + // 单位字段已移除 + // 不再透传 dedupeKey + payload.safeMin = submission.getSafeMin(); + payload.safeMax = submission.getSafeMax(); + payload.remark = submission.getRemarkText(); + payload.sourceSubmissionId = submission.getId(); + payload.globalSkuId = submission.getGlobalSkuId(); + payload.parameters = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + + payload.prices = new ProductDtos.Prices(); + payload.prices.purchasePrice = BigDecimal.ZERO; + payload.prices.retailPrice = BigDecimal.ZERO; + payload.prices.wholesalePrice = BigDecimal.ZERO; + payload.prices.bigClientPrice = BigDecimal.ZERO; + + payload.stock = BigDecimal.ZERO; + + List images = JsonUtils.fromJson(submission.getImagesJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + payload.images = images != null ? new ArrayList<>(images) : new ArrayList<>(); + return payload; + } + + private LocalDateTime parseDate(String text) { + if (text == null || text.isBlank()) return null; + try { + return LocalDateTime.parse(text); + } catch (Exception e) { + return null; + } + } + + private Long resolveReviewer(Long adminId) { + return (adminId == null || adminId == 0L) ? defaults.getUserId() : adminId; + } + + private void setDateCell(Cell cell, LocalDateTime time, CellStyle style) { + if (time == null) { + cell.setBlank(); + return; + } + cell.setCellValue(java.sql.Timestamp.valueOf(time)); + cell.setCellStyle(style); + } + + private String nvl(String v) { + return v == null ? "" : v; + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportController.java new file mode 100644 index 0000000..e063921 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportController.java @@ -0,0 +1,27 @@ +package com.example.demo.report; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/report") +public class ReportController { + + private final ReportService reportService; + + public ReportController(ReportService reportService) { + this.reportService = reportService; + } + + @GetMapping("/sales") + public ResponseEntity salesReport(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "dimension", required = false) String dimension, + @RequestParam(name = "startDate", required = false) String startDate, + @RequestParam(name = "endDate", required = false) String endDate) { + return ResponseEntity.ok(reportService.getSalesReport(shopId, dimension, startDate, endDate)); + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportService.java new file mode 100644 index 0000000..3d0184c --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/report/ReportService.java @@ -0,0 +1,189 @@ +package com.example.demo.report; + +import com.example.demo.common.AppDefaultsProperties; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ReportService { + + private final JdbcTemplate jdbcTemplate; + private final AppDefaultsProperties defaults; + + public ReportService(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) { + this.jdbcTemplate = jdbcTemplate; + this.defaults = defaults; + } + + public Map getSalesReport(Long shopId, String dimensionParam, String startDate, String endDate) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + String dimension = normalizeDimension(dimensionParam); + Timestamp startTs = parseStartTimestamp(startDate); + Timestamp endTs = parseEndTimestamp(endDate); + + List params = new ArrayList<>(); + String sql = buildSalesReportSql(dimension, params, sid, startTs, endTs); + List> items = jdbcTemplate.query(sql, params.toArray(), (rs, rowNum) -> { + Map row = new HashMap<>(); + long key = rs.getLong("key_id"); + if (rs.wasNull()) { + row.put("key", null); + } else { + row.put("key", key); + } + row.put("name", rs.getString("name")); + String spec = rs.getString("spec"); + if (spec != null && !spec.isBlank()) { + row.put("spec", spec); + } + BigDecimal salesAmount = scale2(nullSafe(rs.getBigDecimal("sales_amount"))); + BigDecimal costAmount = scale2(nullSafe(rs.getBigDecimal("cost_amount"))); + BigDecimal profit = scale2(salesAmount.subtract(costAmount)); + BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) == 0 + ? BigDecimal.ZERO + : profit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100")); + row.put("salesAmount", salesAmount); + row.put("costAmount", costAmount); + row.put("profit", profit); + row.put("profitRate", profitRate.setScale(2, RoundingMode.HALF_UP)); + return row; + }); + + BigDecimal totalSales = BigDecimal.ZERO; + BigDecimal totalCost = BigDecimal.ZERO; + for (Map item : items) { + totalSales = totalSales.add((BigDecimal) item.get("salesAmount")); + totalCost = totalCost.add((BigDecimal) item.get("costAmount")); + } + BigDecimal totalProfit = scale2(totalSales.subtract(totalCost)); + BigDecimal totalProfitRate = totalSales.compareTo(BigDecimal.ZERO) == 0 + ? BigDecimal.ZERO + : totalProfit.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100")); + + Map summary = new HashMap<>(); + summary.put("salesAmount", totalSales); + summary.put("costAmount", totalCost); + summary.put("profit", totalProfit); + summary.put("profitRate", totalProfitRate.setScale(2, RoundingMode.HALF_UP)); + summary.put("itemCount", items.size()); + + Map resp = new HashMap<>(); + resp.put("dimension", dimension); + resp.put("startDate", startDate); + resp.put("endDate", endDate); + resp.put("items", items); + resp.put("summary", summary); + return resp; + } + + private String normalizeDimension(String dimension) { + if (dimension == null) return "customer"; + String d = dimension.trim().toLowerCase(); + if ("product".equals(d)) return "product"; + return "customer"; + } + + private String buildSalesReportSql(String dimension, List params, Long shopId, Timestamp start, Timestamp end) { + params.add(shopId); + String keyExpr = "customer".equals(dimension) ? "COALESCE(so.customer_id, 0)" : "soi.product_id"; + String nameExpr = "customer".equals(dimension) + ? "COALESCE(c.name, '未指定客户')" + : "COALESCE(p.name, '未命名商品')"; + String specExpr = "customer".equals(dimension) ? "''" : "COALESCE(p.spec, '')"; + String joinExpr = "customer".equals(dimension) + ? "LEFT JOIN customers c ON c.id = so.customer_id" + : "LEFT JOIN products p ON p.id = soi.product_id"; + + StringBuilder sb = new StringBuilder(); + sb.append("SELECT key_id, name, spec, SUM(sales_amount) AS sales_amount, SUM(cost_amount) AS cost_amount FROM (\n"); + sb.append("SELECT ").append(keyExpr).append(" AS key_id, ") + .append(nameExpr).append(" AS name, ") + .append(specExpr).append(" AS spec, ") + .append("soi.amount AS sales_amount, COALESCE(soi.cost_amount,0) AS cost_amount\n") + .append("FROM sales_orders so JOIN sales_order_items soi ON soi.order_id = so.id ") + .append(joinExpr) + .append(" WHERE so.shop_id=? AND so.status='approved'"); + applyDateFilter(sb, params, "so.order_time", start, end); + + sb.append("\nUNION ALL\n"); + + params.add(shopId); + keyExpr = "customer".equals(dimension) ? "COALESCE(sro.customer_id, 0)" : "sroi.product_id"; + nameExpr = "customer".equals(dimension) + ? "COALESCE(c.name, '未指定客户')" + : "COALESCE(p.name, '未命名商品')"; + specExpr = "customer".equals(dimension) ? "''" : "COALESCE(p.spec, '')"; + joinExpr = "customer".equals(dimension) + ? "LEFT JOIN customers c ON c.id = sro.customer_id" + : "LEFT JOIN products p ON p.id = sroi.product_id"; + + sb.append("SELECT ").append(keyExpr).append(" AS key_id, ") + .append(nameExpr).append(" AS name, ") + .append(specExpr).append(" AS spec, ") + .append("-sroi.amount AS sales_amount, -COALESCE(sroi.cost_amount,0) AS cost_amount\n") + .append("FROM sales_return_orders sro JOIN sales_return_order_items sroi ON sroi.order_id = sro.id ") + .append(joinExpr) + .append(" WHERE sro.shop_id=? AND sro.status='approved'"); + applyDateFilter(sb, params, "sro.order_time", start, end); + + sb.append("\n) t GROUP BY key_id, name, spec ORDER BY sales_amount DESC"); + return sb.toString(); + } + + private void applyDateFilter(StringBuilder sql, List params, String column, Timestamp start, Timestamp end) { + if (start != null) { + sql.append(" AND ").append(column).append(">=?"); + params.add(start); + } + if (end != null) { + sql.append(" AND ").append(column).append(" 10) { + LocalDateTime dt = LocalDateTime.parse(value.trim()); + return Timestamp.valueOf(dt); + } + LocalDate date = LocalDate.parse(value.trim()); + return Timestamp.valueOf(date.atStartOfDay()); + } catch (Exception e) { + return null; + } + } + + private Timestamp parseEndTimestamp(String value) { + if (value == null || value.isBlank()) return null; + try { + if (value.length() > 10) { + LocalDateTime dt = LocalDateTime.parse(value.trim()); + return Timestamp.valueOf(dt); + } + LocalDate date = LocalDate.parse(value.trim()); + return Timestamp.valueOf(date.plusDays(1).atStartOfDay()); + } catch (Exception e) { + return null; + } + } + + private BigDecimal scale2(BigDecimal v) { + return v.setScale(2, RoundingMode.HALF_UP); + } + + private BigDecimal nullSafe(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } +} diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/controller/SupplierController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/controller/SupplierController.java new file mode 100644 index 0000000..89181df --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/controller/SupplierController.java @@ -0,0 +1,73 @@ +package com.example.demo.supplier.controller; + +import com.example.demo.common.AppDefaultsProperties; +import com.example.demo.supplier.dto.SupplierDtos; +import com.example.demo.supplier.service.SupplierService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/suppliers") +public class SupplierController { + + private final SupplierService supplierService; + private final AppDefaultsProperties defaults; + + public SupplierController(SupplierService supplierService, AppDefaultsProperties defaults) { + this.supplierService = supplierService; + this.defaults = defaults; + } + + @GetMapping + public ResponseEntity search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestParam(name = "kw", required = false) String kw, + @RequestParam(name = "debtOnly", required = false, defaultValue = "false") boolean debtOnly, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "50") int size) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + return ResponseEntity.ok(supplierService.search(sid, kw, debtOnly, Math.max(0, page-1), size)); + } + + @GetMapping("/{id}") + public ResponseEntity detail(@PathVariable("id") Long id) { + java.util.Optional> r = supplierService.findById(id); + if (r.isEmpty()) return ResponseEntity.notFound().build(); + java.util.Map row = r.get(); + java.util.Map body = new java.util.HashMap<>(); + body.put("id", ((Number)row.get("id")).longValue()); + body.put("name", (String) row.get("name")); + body.put("contactName", (String) row.get("contact_name")); + body.put("mobile", (String) row.get("mobile")); + body.put("phone", (String) row.get("phone")); + body.put("address", (String) row.get("address")); + body.put("apOpening", row.get("ap_opening")); + body.put("apPayable", row.get("ap_payable")); + body.put("remark", (String) row.get("remark")); + return ResponseEntity.ok(body); + } + + @PostMapping + public ResponseEntity create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody SupplierDtos.CreateOrUpdateSupplierRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + Long id = supplierService.create(sid, uid, req); + java.util.Map body = new java.util.HashMap<>(); + body.put("id", id); + return ResponseEntity.ok(body); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable("id") Long id, + @RequestHeader(name = "X-Shop-Id", required = false) Long shopId, + @RequestHeader(name = "X-User-Id", required = false) Long userId, + @RequestBody SupplierDtos.CreateOrUpdateSupplierRequest req) { + Long sid = (shopId == null ? defaults.getShopId() : shopId); + Long uid = (userId == null ? defaults.getUserId() : userId); + supplierService.update(id, sid, uid, req); + return ResponseEntity.ok().build(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/dto/SupplierDtos.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/dto/SupplierDtos.java new file mode 100644 index 0000000..499cf81 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/dto/SupplierDtos.java @@ -0,0 +1,49 @@ +package com.example.demo.supplier.dto; + +public class SupplierDtos { + + public static class CreateOrUpdateSupplierRequest { + public String name; + public String contactName; + public String mobile; + public String phone; + public String address; + public java.math.BigDecimal apOpening; + public java.math.BigDecimal apPayable; + public String remark; + } + + public static class SupplierListItem { + public Long id; + public String name; + public String contactName; + public String mobile; + public String phone; + public String address; + public java.math.BigDecimal apOpening; + public java.math.BigDecimal apPayable; + public String remark; + + public SupplierListItem(Long id, + String name, + String contactName, + String mobile, + String phone, + String address, + java.math.BigDecimal apOpening, + java.math.BigDecimal apPayable, + String remark) { + this.id = id; + this.name = name; + this.contactName = contactName; + this.mobile = mobile; + this.phone = phone; + this.address = address; + this.apOpening = apOpening; + this.apPayable = apPayable; + this.remark = remark; + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/service/SupplierService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/service/SupplierService.java new file mode 100644 index 0000000..238e9ec --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/supplier/service/SupplierService.java @@ -0,0 +1,96 @@ +package com.example.demo.supplier.service; + +import com.example.demo.supplier.dto.SupplierDtos; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class SupplierService { + + private final JdbcTemplate jdbcTemplate; + + public SupplierService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Map search(Long shopId, String kw, boolean debtOnly, int page, int size) { + StringBuilder sql = new StringBuilder("SELECT id,name,contact_name,mobile,phone,address,ap_opening,ap_payable,remark FROM suppliers WHERE shop_id=? AND status=1"); + java.util.List ps = new java.util.ArrayList<>(); + ps.add(shopId); + if (kw != null && !kw.isBlank()) { + sql.append(" AND (name LIKE ? OR phone LIKE ? OR mobile LIKE ?)"); + String like = '%'+kw+'%'; + ps.add(like); ps.add(like); ps.add(like); + } + if (debtOnly) { + sql.append(" AND ap_payable > 0"); + } + sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); + ps.add(size); ps.add(Math.max(0, page) * size); + List> rows = jdbcTemplate.queryForList(sql.toString(), ps.toArray()); + List items = rows.stream().map(r -> new SupplierDtos.SupplierListItem( + ((Number)r.get("id")).longValue(), + (String) r.get("name"), + (String) r.get("contact_name"), + (String) r.get("mobile"), + (String) r.get("phone"), + (String) r.get("address"), + toBig((java.lang.Number) r.get("ap_opening")), + toBig((java.lang.Number) r.get("ap_payable")), + (String) r.get("remark") + )).collect(Collectors.toList()); + java.util.Map resp = new java.util.HashMap<>(); + resp.put("list", items); + return resp; + } + + @Transactional + public Long create(Long shopId, Long userId, SupplierDtos.CreateOrUpdateSupplierRequest req) { + BigDecimal open = nz(req.apOpening); + BigDecimal payable = nz(req.apPayable); + String sql = "INSERT INTO suppliers (shop_id,user_id,name,contact_name,mobile,phone,address,status,ap_opening,ap_payable,remark,created_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())"; + org.springframework.jdbc.support.GeneratedKeyHolder kh = new org.springframework.jdbc.support.GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"}); + int i = 1; + ps.setLong(i++, shopId); + ps.setLong(i++, userId); + ps.setString(i++, req.name); + ps.setString(i++, req.contactName); + ps.setString(i++, req.mobile); + ps.setString(i++, req.phone); + ps.setString(i++, req.address); + ps.setInt(i++, 1); + ps.setBigDecimal(i++, open); + ps.setBigDecimal(i++, payable); + ps.setString(i, req.remark); + return ps; + }, kh); + Number key = kh.getKey(); + return key == null ? null : key.longValue(); + } + + @Transactional + public void update(Long id, Long shopId, Long userId, SupplierDtos.CreateOrUpdateSupplierRequest req) { + String sql = "UPDATE suppliers SET name=?, contact_name=?, mobile=?, phone=?, address=?, ap_opening=?, ap_payable=?, remark=?, updated_at=NOW() WHERE id=? AND shop_id=?"; + jdbcTemplate.update(sql, req.name, req.contactName, req.mobile, req.phone, req.address, nz(req.apOpening), nz(req.apPayable), req.remark, id, shopId); + } + + public java.util.Optional> findById(Long id) { + List> rows = jdbcTemplate.queryForList("SELECT id,name,contact_name,mobile,phone,address,ap_opening,ap_payable,remark FROM suppliers WHERE id=?", id); + if (rows.isEmpty()) return java.util.Optional.empty(); + return java.util.Optional.of(rows.get(0)); + } + + private static BigDecimal toBig(Number n) { return n == null ? BigDecimal.ZERO : new BigDecimal(n.toString()); } + private static BigDecimal nz(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(2, java.math.RoundingMode.HALF_UP); } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserController.java new file mode 100644 index 0000000..c54f519 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserController.java @@ -0,0 +1,64 @@ +package com.example.demo.user; + +import com.example.demo.auth.JwtService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/user") +public class UserController { + + private final UserService userService; + private final JwtService jwtService; + + public UserController(UserService userService, JwtService jwtService) { + this.userService = userService; + this.jwtService = jwtService; + } + + private Long ensureUserId(String authorization) { + Map claims = jwtService.parseClaims(authorization); + Object uid = claims.get("userId"); + if (uid == null) throw new IllegalArgumentException("未登录"); + return ((Number) uid).longValue(); + } + + @GetMapping("/me") + public ResponseEntity me(@RequestHeader(name = "Authorization", required = false) String authorization, + @RequestHeader(name = "X-User-Id", required = false) Long userIdHeader) { + Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization); + var body = userService.getProfile(userId); + return ResponseEntity.ok(body == null ? java.util.Map.of() : body); + } + + @PutMapping("/me") + public ResponseEntity update(@RequestHeader(name = "Authorization", required = false) String authorization, + @RequestHeader(name = "X-User-Id", required = false) Long userIdHeader, + @RequestBody UserService.UpdateProfileRequest req) { + Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization); + userService.updateProfile(userId, req); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/password") + public ResponseEntity changePassword(@RequestHeader(name = "Authorization", required = false) String authorization, + @RequestHeader(name = "X-User-Id", required = false) Long userIdHeader, + @RequestBody UserService.ChangePasswordRequest req) { + Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization); + userService.changePassword(userId, req); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/phone") + public ResponseEntity changePhone(@RequestHeader(name = "Authorization", required = false) String authorization, + @RequestHeader(name = "X-User-Id", required = false) Long userIdHeader, + @RequestBody UserService.ChangePhoneRequest req) { + Long userId = userIdHeader != null ? userIdHeader : ensureUserId(authorization); + userService.changePhone(userId, req); + return ResponseEntity.ok().build(); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserService.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserService.java new file mode 100644 index 0000000..2a82cd3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/user/UserService.java @@ -0,0 +1,136 @@ +package com.example.demo.user; + +import com.example.demo.attachment.AttachmentUrlValidator; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class UserService { + + private final JdbcTemplate jdbcTemplate; + private final AttachmentUrlValidator urlValidator; + + public UserService(JdbcTemplate jdbcTemplate, AttachmentUrlValidator urlValidator) { + this.jdbcTemplate = jdbcTemplate; + this.urlValidator = urlValidator; + } + + @Transactional(readOnly = true) + public Map getProfile(Long userId) { + return jdbcTemplate.query( + con -> { + var ps = con.prepareStatement("SELECT id, shop_id, phone, email, name, avatar_url FROM users WHERE id=? LIMIT 1"); + ps.setLong(1, userId); + return ps; + }, + rs -> { + if (!rs.next()) return null; + Map m = new HashMap<>(); + m.put("id", rs.getLong("id")); + m.put("shopId", rs.getLong("shop_id")); + m.put("phone", rs.getString("phone")); + m.put("email", rs.getString("email")); + m.put("name", rs.getString("name")); + m.put("avatarUrl", rs.getString("avatar_url")); + return m; + } + ); + } + + public static class UpdateProfileRequest { public String name; public String avatarUrl; } + + @Transactional + public void updateProfile(Long userId, UpdateProfileRequest req) { + if ((req.name == null || req.name.isBlank()) && (req.avatarUrl == null || req.avatarUrl.isBlank())) { + return; // nothing to update + } + StringBuilder sql = new StringBuilder("UPDATE users SET "); + java.util.List args = new java.util.ArrayList<>(); + boolean first = true; + if (req.name != null) { + String nm = req.name.trim(); + if (nm.isEmpty()) throw new IllegalArgumentException("姓名不能为空"); + if (nm.length() > 64) throw new IllegalArgumentException("姓名过长"); + sql.append(first ? "name=?" : ", name=?"); + args.add(nm); + first = false; + } + if (req.avatarUrl != null) { + String au = req.avatarUrl.trim(); + if (au.isEmpty()) { + sql.append(first ? "avatar_url=NULL" : ", avatar_url=NULL"); + } else if (au.startsWith("/")) { + String normalized = au.replaceAll("/{2,}", "/"); + sql.append(first ? "avatar_url=?" : ", avatar_url=?"); + args.add(normalized); + } else { + var vr = urlValidator.validate(au); + sql.append(first ? "avatar_url=?" : ", avatar_url=?"); + args.add(vr.url()); + } + first = false; + } + sql.append(", updated_at=NOW() WHERE id=?"); + args.add(userId); + jdbcTemplate.update(sql.toString(), args.toArray()); + } + + public static class ChangePasswordRequest { public String oldPassword; public String newPassword; } + + @Transactional + public void changePassword(Long userId, ChangePasswordRequest req) { + if (req.newPassword == null || req.newPassword.isBlank()) throw new IllegalArgumentException("新密码不能为空"); + if (req.newPassword.length() < 6) throw new IllegalArgumentException("新密码至少6位"); + Map row = jdbcTemplate.query( + con -> { + var ps = con.prepareStatement("SELECT password_hash FROM users WHERE id=?"); + ps.setLong(1, userId); + return ps; + }, + rs -> { + if (rs.next()) { + Map m = new HashMap<>(); + m.put("password_hash", rs.getString(1)); + return m; + } + return null; + } + ); + if (row == null) throw new IllegalArgumentException("用户不存在"); + String existing = (String) row.get("password_hash"); + if (existing != null && !existing.isBlank()) { + if (req.oldPassword == null || req.oldPassword.isBlank()) throw new IllegalArgumentException("请提供旧密码"); + boolean ok = BCrypt.checkpw(req.oldPassword, existing); + if (!ok) throw new IllegalArgumentException("旧密码不正确"); + } + String newHash = BCrypt.hashpw(req.newPassword, BCrypt.gensalt()); + jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", newHash, userId); + } + + public static class ChangePhoneRequest { public String phone; public String code; } + + @Transactional + public void changePhone(Long userId, ChangePhoneRequest req) { + ensurePhoneFormat(req.phone); + // 无需验证码,直接保存;保留唯一性与格式校验 + try { + jdbcTemplate.update("UPDATE users SET phone=?, updated_at=NOW() WHERE id=?", req.phone, userId); + } catch (DataIntegrityViolationException e) { + throw new IllegalArgumentException("该手机号已被占用"); + } + } + + private void ensurePhoneFormat(String phone) { + if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空"); + String p = phone.replaceAll("\\s+", ""); + if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确"); + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/vip/VipController.java b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/vip/VipController.java new file mode 100644 index 0000000..92a0358 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/java/com/example/demo/vip/VipController.java @@ -0,0 +1,143 @@ +package com.example.demo.vip; + +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/vip") +public class VipController { + + private final JdbcTemplate jdbcTemplate; + + public VipController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping("/status") + public ResponseEntity status(@RequestHeader(name = "X-User-Id", required = true) Long userId) { + Map result = new LinkedHashMap<>(); + String sql = "SELECT is_vip,status,expire_at FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1"; + Map row = null; + try { + row = jdbcTemplate.query(sql, ps -> ps.setLong(1, userId), rs -> rs.next() ? Map.of( + "isVip", rs.getInt("is_vip"), + "status", rs.getInt("status"), + "expireAt", rs.getTimestamp("expire_at") + ) : null); + } catch (Exception ignored) {} + + boolean isVipActive = false; + Timestamp expireAt = null; + int status = 0; + if (row != null) { + int isVip = ((Number) row.getOrDefault("isVip", 0)).intValue(); + status = ((Number) row.getOrDefault("status", 0)).intValue(); + Object exp = row.get("expireAt"); + expireAt = exp instanceof Timestamp ? (Timestamp) exp : null; + isVipActive = (isVip == 1) && (status == 1) && (expireAt == null || !expireAt.toInstant().isBefore(Instant.now())); + } + + Double price = 0d; + try { + price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d); + } catch (Exception ignored) {} + int nonVipRetentionDays = readNonVipRetentionDaysOrDefault(60); + + result.put("isVip", isVipActive); + result.put("status", status); + result.put("expireAt", expireAt); + result.put("price", price); + result.put("nonVipRetentionDays", nonVipRetentionDays); + return ResponseEntity.ok(result); + } + + @GetMapping("/recharges") + public ResponseEntity recharges(@RequestHeader(name = "X-User-Id", required = true) Long userId, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "20") int size) { + int safeSize = Math.max(1, Math.min(100, size)); + int offset = Math.max(0, page - 1) * safeSize; + String sql = "SELECT id, price, duration_days AS durationDays, expire_from AS expireFrom, expire_to AS expireTo, channel, created_at AS createdAt " + + "FROM vip_recharges WHERE user_id=? ORDER BY id DESC LIMIT " + offset + ", " + safeSize; + java.util.List> list = jdbcTemplate.query(sql, ps -> ps.setLong(1, userId), rs -> { + java.util.List> rows = new java.util.ArrayList<>(); + while (rs.next()) { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", rs.getLong("id")); + m.put("price", rs.getBigDecimal("price")); + m.put("durationDays", rs.getInt("durationDays")); + m.put("expireFrom", rs.getTimestamp("expireFrom")); + m.put("expireTo", rs.getTimestamp("expireTo")); + m.put("channel", rs.getString("channel")); + m.put("createdAt", rs.getTimestamp("createdAt")); + rows.add(m); + } + return rows; + }); + return ResponseEntity.ok(java.util.Map.of("list", list)); + } + + @PostMapping("/pay") + public ResponseEntity pay(@RequestHeader(name = "X-User-Id", required = true) Long userId) { + Long shopId = jdbcTemplate.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getLong(1) : null); + if (shopId == null) return ResponseEntity.badRequest().body(Map.of("message", "invalid user")); + + int durationDays = readDurationDaysOrDefault(30); + Double price = 0d; + try { price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d); } catch (Exception ignored) {} + Instant newExpire = Instant.now().plus(durationDays, ChronoUnit.DAYS); + Timestamp expireTs = Timestamp.from(newExpire.atOffset(ZoneOffset.UTC).toInstant()); + + Long existingId = jdbcTemplate.query("SELECT id FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1", + ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getLong(1) : null); + + if (existingId == null) { + jdbcTemplate.update("INSERT INTO vip_users (shop_id,user_id,is_vip,status,expire_at,remark,created_at,updated_at) VALUES (?,?,?,?,?,NULL,NOW(),NOW())", + shopId, userId, 1, 1, expireTs); + } else { + jdbcTemplate.update("UPDATE vip_users SET is_vip=1,status=1,expire_at=?,updated_at=NOW() WHERE id=?", + expireTs, existingId); + } + + jdbcTemplate.update("INSERT INTO vip_recharges (shop_id,user_id,price,duration_days,expire_from,expire_to,channel,created_at) VALUES (?,?,?,?,?,?, 'oneclick', NOW())", + shopId, userId, price, durationDays, null, expireTs); + + return status(userId); + } + + private int readDurationDaysOrDefault(int dft) { + try { + String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.durationDays' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v == null) return dft; + v = v.trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + return Integer.parseInt(v); + } catch (Exception ignored) { + return dft; + } + } + + private int readNonVipRetentionDaysOrDefault(int dft) { + try { + String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.dataRetentionDaysForNonVip' ORDER BY id DESC LIMIT 1", + rs -> rs.next() ? rs.getString(1) : null); + if (v == null) return dft; + v = v.trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + return Integer.parseInt(v); + } catch (Exception ignored) { + return dft; + } + } +} + + diff --git a/backend/.stage-src-20251004-193018/src/main/resources/application.properties b/backend/.stage-src-20251004-193018/src/main/resources/application.properties new file mode 100644 index 0000000..aeecaa3 --- /dev/null +++ b/backend/.stage-src-20251004-193018/src/main/resources/application.properties @@ -0,0 +1,107 @@ +# Fuzzy search global configuration +search.fuzzy.enabled=true +search.fuzzy.default-tolerance=1.0 +spring.application.name=demo + +# 数据源配置(通过环境变量注入,避免硬编码) +# 格式为: jdbc:mysql://<主机名>:<端口号>/<数据库名>?参数 +# 已移除默认线上地址与口令,务必在部署环境中提供 .env 或环境变量 +spring.datasource.url=${DB_URL:} + +# 用户名和密码通过环境变量注入(无默认值,防止泄露) +spring.datasource.username=${DB_USER:} +spring.datasource.password=${DB_PASSWORD:} + +# JPA 基本配置 +spring.jpa.hibernate.ddl-auto=none +spring.jpa.open-in-view=false +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +# 日志级别(开发调试) +logging.level.com.example.demo=DEBUG +logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO + +# 邮件 SMTP(通过环境变量注入,避免硬编码) +spring.mail.host=${MAIL_HOST:smtp.qq.com} +spring.mail.port=${MAIL_PORT:465} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.protocol=${MAIL_PROTOCOL:smtps} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.ssl.enable=true +spring.mail.properties.mail.smtp.starttls.enable=false +spring.mail.properties.mail.smtp.connectiontimeout=${MAIL_CONNECT_TIMEOUT_MS:5000} +spring.mail.properties.mail.smtp.timeout=${MAIL_READ_TIMEOUT_MS:5000} +spring.mail.properties.mail.smtp.writetimeout=${MAIL_WRITE_TIMEOUT_MS:5000} +app.mail.from=${MAIL_FROM:} +app.mail.subject-prefix=${MAIL_SUBJECT_PREFIX:[配件查询]} + +# CORS 简单放开(如需跨域) +spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*} +spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS +spring.web.cors.allowed-headers=* + +# Hikari 连接池保活(避免云数据库空闲断开) +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.idle-timeout=300000 +spring.datasource.hikari.max-lifetime=600000 +spring.datasource.hikari.keepalive-time=300000 +spring.datasource.hikari.connection-timeout=30000 + +# 附件占位图配置(使用环境变量注入路径) +# WINDOWS 示例: setx ATTACHMENTS_PLACEHOLDER_IMAGE C:\\path\\to\\placeholder.png +# LINUX/Mac 示例: export ATTACHMENTS_PLACEHOLDER_IMAGE=/path/to/placeholder.png +attachments.placeholder.image-path=${ATTACHMENTS_PLACEHOLDER_IMAGE:} +attachments.placeholder.url-path=${ATTACHMENTS_PLACEHOLDER_URL:/api/attachments/placeholder} + +# 纯URL引用校验配置(方案A) +attachments.url.ssrf-protection=${ATTACHMENTS_URL_SSRF_PROTECTION:true} +attachments.url.allow-private-ip=${ATTACHMENTS_URL_ALLOW_PRIVATE_IP:false} +attachments.url.follow-redirects=${ATTACHMENTS_URL_FOLLOW_REDIRECTS:true} +attachments.url.max-redirects=${ATTACHMENTS_URL_MAX_REDIRECTS:2} +attachments.url.connect-timeout-ms=${ATTACHMENTS_URL_CONNECT_TIMEOUT_MS:3000} +attachments.url.read-timeout-ms=${ATTACHMENTS_URL_READ_TIMEOUT_MS:5000} +attachments.url.max-size-mb=${ATTACHMENTS_URL_MAX_SIZE_MB:5} +# 逗号分隔域名,支持前缀通配 *.example.com +attachments.url.allowlist=${ATTACHMENTS_URL_ALLOWLIST:} +# 逗号分隔Content-Type +attachments.url.allowed-content-types=${ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml} + +# 本地上传直传配置(方案B) +attachments.upload.storage-dir=${ATTACHMENTS_DIR:./data/attachments} +attachments.upload.max-size-mb=${ATTACHMENTS_UPLOAD_MAX_SIZE_MB:5} +attachments.upload.allowed-content-types=${ATTACHMENTS_UPLOAD_ALLOWED_CONTENT_TYPES:image/jpeg,image/png,image/gif,image/webp,image/svg+xml} + +# 应用默认上下文(用于开发/演示环境) +app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1} +app.defaults.user-id=${APP_DEFAULT_USER_ID:2} +# 全局字典使用的虚拟店铺ID(方案A) +app.defaults.dict-shop-id=${APP_DEFAULT_DICT_SHOP_ID:0} + +# 财务分类默认配置(前端请调用 /api/finance/categories 获取,禁止硬编码) +app.finance.income-categories=${APP_FINANCE_INCOME:operation_income:经营所得,interest_income:利息收入,other_income:其它收入,deposit_ar_income:收订金/欠款,investment_income:投资收入,sale_income:销售收入,account_operation:账户操作,fund_transfer_in:资金转账转入} +app.finance.expense-categories=${APP_FINANCE_EXPENSE:operation_expense:经营支出,office_supplies:办公用品,rent:房租,interest_expense:利息支出,other_expense:其它支出,account_operation:账户操作,fund_transfer_out:资金转账转出} + +# 账户默认名称(避免硬编码,可被环境变量覆盖) +app.account.defaults.cash-name=${APP_ACCOUNT_CASH_NAME:现金} +app.account.defaults.bank-name=${APP_ACCOUNT_BANK_NAME:银行存款} +app.account.defaults.wechat-name=${APP_ACCOUNT_WECHAT_NAME:微信} +app.account.defaults.alipay-name=${APP_ACCOUNT_ALIPAY_NAME:支付宝} + +# 登录相关配置已移除 +admin.auth.header-name=${ADMIN_AUTH_HEADER:X-Admin-Id} + +# Python 条码识别服务配置(可通过环境变量注入,默认不启用) +python.barcode.enabled=${PY_BARCODE_ENABLED:false} +python.barcode.working-dir=${PY_BARCODE_WORKDIR:./txm} +python.barcode.python=${PY_BARCODE_PYTHON:python} +python.barcode.app-module=${PY_BARCODE_APP_MODULE:app.server.main} +python.barcode.use-module-main=${PY_BARCODE_USE_MODULE:true} +python.barcode.host=${PY_BARCODE_HOST:127.0.0.1} +python.barcode.port=${PY_BARCODE_PORT:8000} +python.barcode.health-path=${PY_BARCODE_HEALTH:/openapi.json} +python.barcode.startup-timeout-sec=${PY_BARCODE_TIMEOUT:20} +python.barcode.log-file=${PY_BARCODE_LOG:} +python.barcode.max-upload-mb=${PY_BARCODE_MAX_UPLOAD_MB:8} diff --git a/backend/.stage-src-20251004-193018/txm/2dbb4e14b6b0df047806bef434338058.jpg b/backend/.stage-src-20251004-193018/txm/2dbb4e14b6b0df047806bef434338058.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8f061c15e6a54b1adff614dc163e06cdaae69f0d GIT binary patch literal 123340 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

D9QSJ%mMiZ;roA2s2+XcqrJ~v$ZeGh&OP7y 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

muq%Ei@}-7}U@l{~ z$C=Z9n1e1cp*qKOn-yovp3~QB82F{!Wtj~=Mg6_(&mn!G8Udas&5Hf+fQ*4|o=5Yl zE!H-WJm`Mhno(9z4`7;$(-su^V5?z36@G0^(|aJ;MY#2&d@9%I;)7e)9B+BKkyq}~ghgS{u<5r}WBZpmsB5fWvJ+R8%ZdkO}=y7j8W4L0SmL#(HfDtvStfJjd zmrr%3)9$RNg5t|hK&ZXPnu3(B6@SJLN);d#!Ff4^iAd^RzEJcFlR@LoNx_fFp? z{xOMRTKTJ$P}*XxvH%#g zbr`*fHfD9Ee%;&Vl*{yxq$$xb6E2Thc+ETYG>GQC^V#79?b8kn@-fPya^a?11C1Z; zKQ=gak6&Ljh+21m58=Cf0ncr=?$F4z);S0DS84*>R4eCO63x5H)T-`(pfAk7soqo2 z;xO2KzUQKloykJPaQJeAzv&SJ#^QyUI=^tj6B<>_3$6!iI_EufM(Muu@9(kgQ3HN% z&uc$)f5L)pp!E^rm^l`|e!h^oi}?J5MD2e0B+sMRH}V&D{5K^9Xoz>;)Kpw&NjmOL zlfF5bve>Ebsf%J~62{;CAr^N3=~H3OBM&iiGIZbbn9n>;5?lu5MYLK@nUB3h}KW6Qi>BJ;c2s^YacQO8jlp|+;95YA|*xj?fPV06AtncUt6q) z1%w2&o{X`TY;u_3==oBCFPUFu=@c2IqOZk$jx$rZCmkVbjkW2nZTslgY<(tUvElGZ zAG;@(rC)8Ga}w%RihF-9EM^6ipJrsDo6Qipf&GICjwPaogyfN;Rt2qLSa%MMFb^(f}*SZg)sl8bNk=_M+l{c|3)zW zRg2I9@=uw`{HMTCpcEsN=afc(4ryd{MlgM#H}C?<#foJ(A{F)RhH}{sGJ-}sn8K0VHP@H1?rQ*#L=GNJwV`>onB9CVV z{)>(%TIY0Uv2v$$eDw9xos|k@8CIOm*A=fDcNQo=I<2GISt6FvnZc8TSFh>JcO%s6 z&&}ZNi{X)Q`L)KnL<3@ukgZeDSjKWsiMSR2^irhM^VaE zc)(qOAi`%x5H0#4L)@;JiRCI0YG1=rV#-VZrW1xyp(KWtsGl<9kB($#ybZPpSSf1z zdKO=HhUm>E8@BGK5_F*|bnct&K-Q;<({+W>_X{fJEE;^vNF=Nn(=kiS#MIOdc*_4} zinNQRF&P;cQJ{+7<434}Ax$H^2nxOK|4J?DVL+9NIBA2Q8l?z_dbyWgM>DzAoMHcB z)*pLa)rHWcf`OZ;=M5Ls=bG-F6?>KKX}7Pm&vI4d6V;&pAI~f3h3HortM(Y-^}Mj% zf1XhpmY#UOe{~)ES&6A~V$z%;7q5_USokg|qxUh4eA~cQPdp{14s_ObjHV zwr0Ynq%>n&TNq}Py@VZff-I#IEjc`#PsFY6J?(M4+mJ@wAY;a*?Dzm~he&k*cfRnd zT4lSgxKFv#HzC6OKd9StYM8pMp%=i&xA$?`!B$`M9YkdSEWU-`Ex(;gGf9`A9dUF-9hs7wFaN=Z}k$R%})+e&Cd_evB zn%RLWHnWS2#JQw+kVC{>t5qIUSK0G{h$?-SQ+-v{KsLa-$&tk1bWU6>e_XlrFU5LR z&WRSXs;jhr@&o_<=S`((`XsD`o|aZ*+gUAS8gCl->xPp*&)Z*HB~ zjw|bu;;b|Qu!MlBCb`V(D4S1375}+a)5HCp5gSs_e z`pbFaFpa|ePbGBi{?i8)pP#Sn-)Al}kXz`lrT@}DaFl@9|yTZ{?g!wwL8w=Rhv;} z3J^{Et4yFCj)55ml5_0}S1SZ#O7!3^B8zBBDO>`igh1e0#Dx=?i%^7-|BZ{;T}+W* z{Jv10in0;a!@XgZ-5sasNWh!}ii~JFd0ZPLMnkmTQ5bw9f<2D}N!;~rg&rwt%g&^l zGO+#6Y+s}9jdV*P379DKYVjK9ei}L4Zlg5nceowps5D~y=e9{n*)$&2I&yK;1Y?D} zO-6)}34w{*EVJLoj;4+tS{m&$;7-OHUA;IKmU?HrK+Z(+evq7)$s>aC$jRc$J|Srw zB(Fnoqy%>qoAU682@QHofCuLM?)%-$Ye=jz2e~_At&$?{sCstX zHuX__4lJM$dR@O4b`pxi3WC~DW;`ZXOv5&#OKFy(9EKtc(G%0XHK1`$X$ z3u3qjndcj9C_N)1xP{^XuZ^fa>+3DeQMlObNGtFqw*-6va1SQQ&Z2~x67hI7J;|Krflb-hTRfIztsq4^;Tz}F%2|zHVEa8{u&p(Z-fmY$ za3~M*=K1URPMt99h$guV0P~JZJxk@K?bTdak?tKHlE5MiGdk7{VbGFZ1_$Q9_G-T; z7Q0^qsI`!HMS+J97YyYZZU&M1CziOYDg&a261_`A#mSn#NE?)+pa`CeN4Y8XjJr2$ z4?`JE3d%}~`BX*ynG09m`bqG3Ei;UoGZ1}3|JHfU8c1~J4d&@VeIQPK2q|Ngu$UkRtK43GS~`D+<_eG+ z+DK{d6mWlwG}E!l&6AA_zUv)}5VfVxk#1@ectu2c)deiMG}BzgU*VX|2;e)2A`X2J zqIyLL5!S;zcT`J!c{IO>p~&g6{Gpu4ci5iIr4{MrhNM@ynhmAJAT!;wEgob4h4fk2 zUrmo`|A0APn;vd;QVl+k78BkDazH9qbT<0^yFDiUdglEu7cbK|yhyr;M!m1{;8T%+ z2khQlDv@4k@Xs5sN>*(dv550$v-DG0Iqv80H}b{g%g-|E!`#@2amS8jpE&LNC*Syt zc{MpKc+YxrI-C!;GQR#rNtyij&|(s|a_8Ysdhs8%Iw~*AJh3nu+?>(Z@^9|=@w&aK z;p!B#V`p;A1a-+Xh8-*FeTRD49}9u!WvhQ`>&3p_{Q(NAv=_+CQdBD6hN7YN7go10 zun7^2YpP4pzZ)Fgv8_zyA3t&2Po%1+?s-V!cNF;|&bV=D+wI}EL7JtuShaLsxAs=% z!x2GmDq{+TJD|{K> zQ2Ne)CF)Z^Cf?w$;LzJs&3TL%Hccba!y~mH3(uro-gtLUWk(^hn#VJtPp_={=pO&KfR6h;2a|IAk_#b}QzgME)Kc^yX2}LCWyELTghM>y{pMNB~!27FjVo z>J^D4=gM zs?uU@3F9X`F6X+H#oEX=2k=DOip1IR+S7_vW1PyBc#ZLNQ7eOA?^@~rI#*5RPuxj} z4=lWby`SKa4D`!ak@PNBmgN;?Q{}0HV(0PU^CanV?w2FfgKEXl(3=NI0TmJY(cj6B z$WDU=H(W*7=zwCk7!Ly&0U7EgOVy`_J#yiEPx&;r5Hn8Nz|Sp zI0A@DkqUuu&`2&DST9)BB9T2{N-K&C1AgN@lykELEIy#G55?7k+b*~&q}DSU0=>`; z2&ce#MB>MaictI^i1q`UTai^V;p$^g+#POKR!QO?UTJscZB&%53N>rPU(*ziB_GpUvNg=+ zw0oPCY|Nhf@SE-IK~Xz3*PpU~fbO$G(zjW^>hW-b$V7G1God8f8d=})W zfj|nG)D%$IlfUcN-}NqB51W8o$XhapQj@Z|b-RYAOb&QR&Xcy7*EkFdFN^GCngtS2 zi^?sb&E$5IFda5vhB2WgG_0rR+s^G^TlMu@Mnn`Rgv{yN3va#|`$>xbAoo{V_}M3H z&PU@`=KKe9N!FJv{8k+OY0~nepW%Yu0bMudupRsX|wvr8s`;9 zpj`4t_iZ_(R~}aG-LH{TWT!2)cjjE3Trf5qj0uuNuHRlZW)-wmnaC4iZitk-R^VeD zfPdLAsgYoiqswbXY`wxVCm`3>cwAca;KbW&pG_50=yf7y2_C62lM|#_U13JCwpZ8& z?-0It>1RO-Qm(!`{^vIaYhNbH3Yy=T!KYLkAKhm-IOzM{)+QvnXZs0HL&Z)+14~-w z0sE|disH&tSY>oM>d`V;j`zk|%?Vd%4&>l7`(K^s3_hGLb8lEy{TwaE!_K7PYr5oM}AYKd#K99T8+ zPdPzi9RmSU2HR=<314sSNGc=9GDN->@Fjt-H>D0nmhLk7!WT4$8|+JJdh(c6j9s_{ zFE6Q>XrZn0!U?13us#vV z;~YG}eqyI@bf=v+v(n&74N{PDAG6i~CMim1Nc}J62EOkQGYI@3>S2PP`u{(#4Ot3yhtB`jR#93Oq*-8=0>Tov z1!A6x7aYV?oStQZ?$K%wl3WEH9p$2mu47rOV2*5*qA>-A z{&ps7ga6SYx-obflHlk@urbraa*K*qVzLR!fu3*^Tf_hf$P_kQC-R9vNCW`o5Lx05 zolzh>BsASu5ty`?J2jOx*^x*61do+!DQS>yDKb-}Onh|h6IBGZ$hP9fwh}^>%dvoD zc|g|T&nSI5#Py+ZdD=MGSy8F801Z&L)qW9{Ar*Tk=d#(QYoO~N4dwP5cVjPfpXw(R z(9;;{!vW&oFofB$qJgP$r}rM9=tEanHb5T@Eg_`ALJLOmB80sIUhoPL=iQA`Nsk8w zoguV@$do$|5N4p+rx56ObJz`h@=Cm z9Rox)0K!Ddz(8A{4j6clZwN#-P#hwX2}vZb6>^PIK}-RL<=j`1f``%wBQS&|nx1~Q z_e97KDN)?R=J0lO%mTh7Xfg08ALPKEjy>mdYj8sG@H}Sxwc@)t&Fl&Bqw|QE~c+a|Cq}538P7G4{TWGIZS?|C)d7R&c*Y z&{4b98HCdz`R%{CZlb?w8q}1=AG%03Avkg1vI{udMBJ6ZKP;Q|G-qI0l)WPJ)ypEP z>s}ct@=%L&NmY2t%;iD_q4ELO_29Hr)L9H*R~X~ptg`o^pyd@r_2qbJ_;bNMB08bRmvFNMb{?#0#5oth7c za97M*O0P;;nKix6aZ16P)5z%V^BRlMvhK4RjqiE$g+7q>=&1^jVWvms^K{*^RpG$9 z2CFwFV}7=|SVkZG?&MxNq-;cDI-SxNt@XVyd;I5Nyq2VrIwAfcm&!_l{kJ3;<5%sX zYRA1%9W=}eblkm?`qA=QH06>hf;U#Xn z62_2_N>a4FTcqvu{@u^0^EsdI>-+uvF^8Blo_XeZ?&Z3#`??b3_T6sv>IXJC| zc396*$@;(QiHgg6Bv1T${6+zO|6rbafP9}eMn3L&s0Hz}`Q_9OcVym76bweh zp5898_%zVXf<2e{T!bVhB*H?Bhd(C9J)kWpJ>yE27w-xs;8T8YhQPDtue$l8K&rrB z(!F7Tz07wO$X6m1e1OWZ>Fhu7XmGIfRF>kC?%9T6f<&nuK8zkmVJ=y5#7AMp=4l@U zgL6R984aF-l373Aoum^y&caZ727Ff;qFd-sLWl4w)43vT;m$q@p4ThtTF(N!aDu6A zNHxsll`;+yj}~yG)0tjw*YcITTojqh52!G@D&CYYBrl07AEmtkBFU!ZBgKm6SOld$ z;+TvZi($5gBB#%S+a45?BZw}~xyn4y_;9!@-X!LUdDnFx_%L)kNU5)5`f2l*YSdraV7Aaqc2L?9!Zub_=;`1A z(Fc(`hBG@~Loxvolr%&Utlb^_(&#lV(q7qQmDo1Fj8|)j;gd8E$=OH|YtRR1uuWl@ z7~m@>(@W$D;yxm~+Or3P_exbS$3tcXZVXa*iaRiYrvpX?E;vZTyqx6YV4eWF*{Ior z-d#FAJr?T-SUYXtIhkqKNbA5ZxTt!NqFpuk64SRnuMKRf^W7o*LVVCca5K10pmB6S zFkY79iZtS<0kQ%&v*s!o{-ISxYt?8w1-q;!$~5aJYFk387vFGoav?I)3d7wze3@Du zYs3PjB2pf0>guzLNV$(;iP=SPsX{@3ln0=&mWDVpf=9%_G!OYxx&$S#<>8W0MR1C5H&af!-+|1rb>XVJ{<_<;Z{GEP&59_$)&dM}Xgd1JOv&21{o$ z2`Ic61VlP8Ib_ERX2e9$kA|3y$R~l{2hjoXn+7vdudJuZJb6JGKY$Wgneq9;r@A#S zj%l_g-i|sm9k6ZYP5!mUo%wkmeFJR;)$mR3z zI}F-J3K?& zwCZZ{PoLd(=k5uHllU9l26mWahm5yp1v}0V?Fp|57nMf>mK#5;9$Pa|Qq*Jh&L!>rGe-Oe)pV79 z@ax`6-zd;>RCWsPam#i*GU@OKr^0LH)~thX%VuIhHKFnun@Cx5rCu#2o?=r%?~@Q@ z&Kxlye;bUD9+|>w$=8xgKR;Y^-ncubqK<&R6z&^aY}-!G9IcM|>XtcsZ~NA*{^S1k zmH5_ZgT(8reqNal3cH)E+=;*5aeyD>dP2g}Ahu?)ax1IvQgeYDcQTO~&tiYrlcu=u zd$p;z;P_;gYvNj5@P|`&WZZ?_W{;X#x+CV8J0qv5m_=iIu;q-X+OMa(uWWh4CWo2d zHQmZS()j50-X#e(orff&%!yky-w6J8oW9nrEp)b{m8BmF!zSVev3)^Yg>!-X_Qe;l z`);;fil-wQ3@i!!uUZZH3RMYB2_ZSc^g`TTO7etIJGuYzIGrzYk!}*HFyN{chjzjI zQ<12STr>Y9196Zgh9OR2NF$CE^X-FDeQuu8!ZYo!ox!Ob&GGp_7z*PoEX(b(< zC}p53(~p)AFt?vN)6Yu`Fga3*dAH+7N)%I4n3~-yKkX2;O0MZAFOjTBt)RUh>r-4I zpiN|5VWr=f#MZB~Wthtk`c&9fb#A6_Sw##cdD&IStkDOpM_wUyW0r6+SiGcV4MmL! zQlZe!*IF!8@p5LM2EfDyF^Pk~BL3)7#pjbu%EOYE;U>}!${cORl;#Ao7+5ufl391Z zc#6Tp>I*tyr5l6K&fpFTO+yQ*VcF{L;O`tZ@0Kmjw7=4Tj3;8}b`zpg3{SlbF9>2T zUdpR=&>=I>di%MiG}uqsppcKxkFz9TKCBB5v8+BmXtgd3bCX>}B{%AUDMY+`$EIWK z!gz8jUVw(zLc+zb3+dNaE(hKOl*r)T7Vl2FMV6Oiu`TuLV%_D)D#l34F@a%HoCl88 z=*IUhJ1CkFp~F;#B9}pROOCw|Degf=j@*u1W-IIf`$LWA9n0QK8uQSUURvW^So1ti zPg$$ljjH@wa6dm}09P7PtsR2jQ(A~dI!RmxN!Y(Vc1XR#ivgUV`BE9#%%!jc`q0n| zIa5(O8s$)p=cOG%d=Y8O$nB&77wS5QdIh<7rVG-VAPp+yIk?JT74=7Dx6G$;8aWQR25sTf)x zNV%ZOIbgzoa82+DVu~+!b-l&cQq2}ax@~S_LhQ=efS-S@)b6ZNzfrYVv~BujA`h3* zu!lb?HMzw)df2RS=NC;jOnQ9pvaTItbzM4`Oph(m-xTWVfI&Rlf3r%F+=JZC^OO{^ zbO-_)VQ9$?R=@bP@N3v)5hF`*@Njni6-S_$018$=>Dm=zm`T^nh;qsn>6t-T9fch@0NrI?vG=^DJ!C0K3m(nJ$B2x!UZSy z-l(SeZQP}7^rJPry}oTQv;W3Ubrn?F?KeB>Pd)mJ&nT;{RCc$ki=!%jDwSSqbh6(w z*54`;w_-;+Wj7s_(2;SqU0I9fWbfos5iYU(nthCN{3#dqH#K`l%zd9zqKo$#BYW9H zwF8^ip0S>dzMdqr!{-6c5I13eOtOBOdXnyxz#6*03!4m*sywoz5{BUIqe@tnidwhM zfRklsTb1Kqd(6-aglwcoPVab??CR<)G5rfP|7H3$A7LTNru~Z2^G+1M?oCiYM4-rD z<3N^Py+~y{g~g_Xo;OFFaBzwD`%V; zda>>Y`El5z-3u36Of}A8Lqb+;)?8iabayi?LQ&dK5%YGV%a1o$*jrk>{qf^0?bEMc z{p?K|6{Oo?stW&VRe7=9uf+DE-Ko!o&lG|k1lf}xLz@k@e6a&(z_n}nPqU6Fht75c zjXfHp#!va*xUQWBlL?AKwzY++m+q#=3j9Dh&r)L-rOV zo?IGN$|pZRKaNJNl%8OkizY+Q`n=isc(GYf*8CbIfX?M0(dYo>(0taU&7U(C0qT9S zJh#WF4d(P+@s2Zf12QKnI13%xrg>!RK~rPWt<^BHXWZdC4tyVU%=32igdSseQU;Lk zG7OD6!!QG`o}_ED0WG8==off}JIz)yObFAxz=))mza9RTYrV|6yITqg8AC~ zOJYFu_5k#g6W!?A&?Opg(ad3z6hJ|X2mVJMlAL%~PknFZ zR5J3q;`AgQZ9(;uhZ+HT3PvNx=7U}hPv5>Y$0nV{ZSOCBhm)+k(RvrDO?C$ucd z=bfpfVkA9Tr6a`Ehd{r}gXRLMb^v8U6dVTP0UQF8p-|@1EKn?03Pq+5fIh;B34l!s z09DNKGcLabpAac#u@VL&7D-7sLODE@2%=`bF z!>@@9K`Ad!K!eW%c57gr71a$>@15Vt(BZ*3!aesYa|4c$8OaC_*K}Mbkt06h_+N$EWhO~FT-=hwq*N!AQ z&=oCZYQ=sSGPvfwEa3bMi#2j7zW#7o*94yfR^*IrcBz2``0ZpT>rcHGDhTJO=l zm)lt1B_@pN#od4FRCMdKz5m6KEI z->kA^ZDBQcmqDzDQ?(y8Mm_E6&iIF6nCB*x(i5%}O~OiEWq8x&(|RW(_C{T|1oN{O zqi!j-ZfnYt-ZGrL&S}d%E2R5z5D-*VqD&Hu>Hm5jUL>Q;55Od5JSzZ z7`>}nJp*nVPZ-`ee#}Bm+jaKx`rf*5Z~k8o-SwpXw_H0bou#kU@?5e|K(bpEE96q$Fh)qnggTE__3ZjlI-gz*v?OU+nwk$$bAI zK9|2U*1x`DJUs(kpW6%fdggk6>MmN{#NgQ|S|83spLg(S5*fZ#Ev@%eJHxv4c+VS- zOwRr3xSAih^VdWf4H9Xl2FJcQgoYQe`Q3uj-FiCTISFR*&X-x^OAOn`cV2jufwJ%A zkJY)4CagajK7LyJlyc_NlUB33XA=4J>w9&lSADP6x4Qo`RAk;2-S7Cf={R%Et0lMO z&gX^`7OR-eq3j&_!{jUNmR?z%QAyRR24X!!iHmiwp8 zS^falvifsUz2Azvm-EhV*q<)5zwP3O{IdriN|ABle^^aCU!Zs`fV@k)&*b&zm_Mqo zRx8On1W}?5qDL&AZL2uHKEJiRQ{{2sOohU^+Rdm!M>~^6Z~G*7?Qm4SZ!77_ZP4kC za@_hib<2r0Jd2d!XWx6S*SMB#>-l-@X;zZPb&li9>}`i>71LXj&T<>ZJ5&>85`*al z>x@Tq2MzADthTK2cZ_$FF=8~mL>s3CjtL(PfBnzb@KLGCn(MSa$q3ijsKw-PQ2Ik^ z7Yv>@KV3l(>qR}vm2>^qnA$zMGe^5JXIHpOCE>QNrJ5|F^ByRyaHFCE7{~aV1_tes zA;%&*N-)4qzjr9SzR){#w+l)T((C1B@_jH^gCrdPgtn%77M-XB%Ac}ER>`NE6kZOo z6(oplUJT`~=M@IXq+d(&P}J?@HB1zmgy~UHE29}3Rl>`i@iHhhfYCIi!m=nskjY0} zqM@Degp{SUOD){Ic&!VI(JI4x>))o^qeZ=RYb+;eU|+6iFtEe<+U#<(N$2dsl6>sW z|L6t0NEWj1NnuiQF5b`AM2~ZJl{*zO!Ia~1+&9S9MCgtSP=Ru^CYSlM+MDL)q zNH5d4$e8Qp+TN01%NKdYQA{b+M-+D2T~#2DF?d`Y8)gT*LTNiVA{0pcabRI`-lnIF z1knrGY?CChm8+L0F&0o(VX8a-o(?%2-r5wg>3C#tn}*l|Lyn(XvxF1e&$aG^st)>+PZ z)Xd1T+SAtGvB!9s%ziT^u)#*FPZbo(6CW{{NX&u(kUDfh5X7>er^8Zp!JCvL0UZ-E zofmo_LlM+RGDn8&eu~wA&v#b!K0elm61RbnG zte_P^3lP%K@L9GG(0#GqPhBGku6Bp*tO(L&M z`prZUKqUWFG>TO;Aol<~!sP_PiGxNG3LKykz!L;{aCo5aluNpP7#uxtAXLy+_1gTV zQ==0*N9(?Agmbo5rfA~B+rp$l4gV7LW0A?%YuC@(>!g3AEB2)5TUrfv@4Y3nV~_9J z6#er}k;wu4+zoiw=$_5hWOiG2tXG>@>)_ZI)`2!JLz9L_v? zFV36l;@+J&Z}K4S6n=ABeRyP<&>a{+j1zYW7P$j~2cOw0VPNKNyPoF&e73K4j7-j32ez zaA@lJ3`#R0> z>7nZJGu2MPp;{N~bE3^D#IGAy@lgqK0X;(hZC|qQwJ(SY{`zsitS!WP8{J8NjqsMm zr^>YaaGvjy=RC`}1ARD>@L?T4__O173Qg*KjY-8rvxalC`?RE9`AFuO>@P99yvne7 z>_y}5fQNVX^plG2MJffi!JT~S;k#Xp8}_|33_l+H{3xS&?v7b<<=E&*c;C;DbNm~B zcy3$z_Xz6tHQ~E8YY)CUa6!Z6$s1L6hK;q<+QeAx7uP;dTyFJV?)xepyXn8Ege!eG z$r?YqI_^5_*c2tgGqVA#-^kumyDE(LYu!6mQ{Tm3B1YqpQwr3l!HAmC18#}E>7QW# zE{Jvi*p{>d=X4ZWlw}VL9p_-!Z!62srX+Xip7o?Iy_>o!-`0tb+BLTSnEO$+OGVDF zne5*6npav1Zw2pkP5MizhObth$EqJGJtCSYvUEMre&vr=U4=^YXV35rVN3tmhQ70) zO@GIpTK_ag!U6A%*CkJtN6P1+Pfpm{6QVjU6e>(vblFPrH?bdaW4Ma3SD$;M@UgxL za=OB)z}P*hchk{+{|V2k3KR6$7GIEHE?<>_GJazgF@2PEGW=W-zs+(@nfzrFsTjJ|54G%KIkEZr~*jJy;~!enB_M)I(yV?cGB|zx;(QPf(^kK`)1L0=7wC_%G_{ z!4tzqPCrclXJ-GHluok z34jB76JFMmlVv@iJfmnpRo69PF-{B17Hp>PcR`&vd*b}Ym5oMr?h9F{9dwUUm|39_ z3I)VWh-PIZnCbO9Xn+}%J5w@6SeRz)Hrbs|RK|D%PW6>HZpw>Oi|XO`+d2IMghZ5Th58E7wtt_ex zt?cTdZ$gvuJiW!J_3t)995nwGIK_RQ4Dn_mae}#$D1t@_w!lCt36ULeu_uZ=uy*S2 zeV?3Bs^*F_>!isG)xqJ%teZ_|IA#^HMR!OG1{^223N-T?8PS3QFwU^?BzGacg-DVh z;JcA_^cWwvDyd3u9gAsjV#J50~ z$c1`flZkFPc9mHSEBZQvr$|U)+{|U^AAgBZ+R+RKLfVJP&t3-5=uM%22Af+VDEx&f zA?!Hh$hjBy+{l(bLi=QJD^y%iZl12r$Xoyci+>@J zU`L2xEP#=EA}Rom#8?DP6rezr2)Gt>V+W|j@KexocU3hQQ(~(lE{9{`O@xx)TGSBb%{na6+wM?_6&D#BYz`!k=TCLRki&r)dGzN#=82R?i*U@L!lk6d; z_G!m@|G4^5-9ye@q(^#$&`V+KP}MG3`i@U1lNQJ2zF73^3bRUhl;iRsr{?N0^|j%s z!A#N#jAp9mu(t~9k!_968NJS1D^7(s0`^S~d;Z2eVzV7N~LQpqFZH8*D%E7o$^2uP7)!dtGo8-NzkwMHg{cen48rCZ1 z5iSX2TGg+%rQJ9BxD;)n>V2_C_2o)k*+u2Pf35rJ;n#Uft!#ax{Mj8tms(G3E;u}s ztDT=k78iOX`u1=w#7sB2C$VmF+#IUM{9yU)(+xTb?lb}IHF z9^E}W7Jm9QeSCw~?(VCz{F7>Hrihy!Y`Ns}XY0tBowtX6y6%0t^(}pvv*7u*PKUsa zi4k0?ru(|I$U4bA+P;T|@+AjFaratN;!n|0<+^R87f~5!IyYQ(Qt(k9QkjkB`HZ-) zlQ+^Y-{kF4jly#9GpO(nobNt&4U?W3@IIevh^StzGr zJDvLXOapv4Dr7$(T8GzdWfRm+0OA?I2HkkOW>7n|KvIYf2ziMo=J4?n;2y&66;42$ zfC%wBbRgKPQW-Lao=3fQ;AzmlDQnwS$;Nj|dKb9OapT^&edu~ei%{YkvkNzx`E6}; zaMoOzSIfA^idbSB?vuhl{&&?4!o{=pS8hr=*Ru+Btnsfyr#g@Bd9SuUtBIiddR;Q5 z*h_gjbtc-xUUWM=Fg!3)G!|Sa+S!i`ESDoIdJEa9DqLLFLJEs6 zhr$#fq;;VR42B4t0c4&;s-6X{O2v!h$}q8n$HtJiu%Qi zL3#_C_f(yKZziuQ+dfB5-TeG%i>W&8Vqe*1PoA)aX^||{fNzx^cXx+-1{;-AEN4XP z!+xYtai=Z0hz<%2ZUd1tgvvuKbBJ;XIR}LONwXXb09aW_E8&2$@Ja=%oj!@`L6h}* zb&MO8SbG=|+J-vLhMV(nEHih$w-&I($%?Q@4xElGRk$q~=y}c#T22R6@Jb?-b#8BW0(BKGFaN<^s3e!JuJrYwuoiRrsfuK#1 zO>060Smz-aCo*+}!~<@&eX#Nk%ESHhr7#8QOJ(7Db3r9QY9gUp5S2qDRwKWMgd>0q z1|vR0P?v>oB@svzpC>31w}n{e;7X*lfMFgu5FqD4obGY>GziJ~bfPGTJyu{f98$Eo zJU(gmuhTK0U%lt^4+{JIg^|-oFRJMWn`S8(gqCVa;XjdXGw7E!FV||{Q0N)Gtl3oi zN4EVktJcuFtSfE$CS4y&1!a~B=L_EpS(4(({r`!A{z|3uc?SLg*IZD}S@CTm?*7Gz z1|f6+YZ0LWf=>a(6($Ac@ZE3L53+y;*$Mdp@}}@WcKp|@77Cnv{G7Bz`IKM){+mm!hvycHuMd3>Hd#50=Y_m~ir}8VxzTFbl2dBK%PFV2n$gbMyee|8XBjtV=R&zy9 zy!WwtO&|E@ZCA7zUK*dCtNQ$kS7>=>JDP8(mU8_w_euMmRW3CX(oxTtHaXQ-n)Q1v zIdX}bl&sp5EzU0w3@O&@lxGZbiQ6a^dM&I_D473)iz2R6As$%!V!mvi`BQmYrLM)b_ptw&z0n=Hgtfr4Kv>*9%jPcoRnd;@! z$*lhVGmISUkSVz*{GT)Z&o|_1E7bZF$Zg^QJe@^(n!3+bPkqizjX&Es^)X64OXG$n z(|~4LEnAP0=L$v3v%faIKK3PzfN>7Z+~1zP?zyep`ub0nHqG0m)Dzw8dgv{KpT39a zy5@EMRG93OeEq<*_LWumz4V6D*^B-ouYX88^DXLST&Br_Pqk70E)8j2T-GDBS95V( z7&<#*`GXLL#$0Wu+Edfsq%R38I`T%x{w~41MsIhGcevdWZ&>8-8kZH!$W_>@zi;4R zkydC}=L&FL$L_Hu-KiX-#G7_&Z;~`P6mkCSh=pJ7J9J0z@O|wEx|;;XhAKZ5t;zgSxA>_%_M0$#g#EC(Hg0X<9gbPK(pU5+j#NJ+D?uLIuuxzs za0M*a)S_ee&gyl-)h-7=lEfDwvf6oG6o?55$_KsNnmN)Zcm)dB0GZUfFwDa&FBN*} zgVl|j24I=zjb7&LvH(K0&fn9iFO{QS*j6iMmGC$wUaox-&;OoIb78i?#)q!SfNZ`R zUUu8Vcg~d3!uux&t5DD0ISDxjnjch{(On;GhzVk@ZIw{X_B>R&;T`L40Y!U*sdvEV zE{@!heM(Oc2U9+K}FpV<(1GpkGM(U@pVC_@iIs9EB@6r{%cReNEU z7UY00H)8n(H6U!T%(xBg^Kzx3JF3jRcd}tg2MaQ4rCD;}f%Gil61z?Qwv`|5lv5zx z-ad-|k07*w*}Q}Hp5lPP5DA8!4Z?-;YduW|{9sM#WPXtSM4`Xg5>jaX-BsY4W@=}q zr3SphWfU@#y%B6#`p{%9mm}>hVufE}XRRXuT*oW*+T5eAO5h-;*aI-T2c*m|l`CBs z()BwJE=sC0C@S5eTVp+x?HF~W&A!JjJdiIf3v6WssT8A5(*MSjQXuC4E_0EA2x1M2 zA|jX+BpM^H4wMy=@`y~quY%MNp{VI-AOkOQyGqOM^+wXw`_KF1vR3r(PXi|7u}ly1D=5E$v4^VC(oyJsV#&VT%_R+^>LceiE* zZL5%s(&()o!L4x-rXFn#nN!C2N^QA0U#8WX!L!wmMn<4*EV+1olWn#IbHo^wHoR>(x}Z>zRCs~O1Um#^OeSntpz41O-FDj zrw^y}y6-J`yMw>hcWgf$Mfu@}xyTZZQ_fgm)gqiWw}{qs9jHl2u{l^~!|vL5S@758 zD=dxr=C4llfu_Num$Ee@X)yxSqm2$J9|FzVbBbjp<*rvS_HSPIKydKXZNUlo7S$sG zQw{sp6z$Ks$tiLiKU9es96uEwpA?cZ*O`%rld;q--G+5JuDn}^@%e*mlhbor+t_FJ z@-xK$qMQS-&)Mn?o!c2SP{#6V>FnM$&NT3IjncWoZ^FnkX=CjxO^p3=r$3H%PYoBZ zoE{!(ywVxyx!(L)@m`L+i=5P()49qoawj9zr!NaHN0Rhi9VA6PtLM(Khh%Dm>3xzb zCX1qq{Yp>7et17#6{d3NIj6vX8sD?lLq7J)P(QGd77O_Z0E%2nwB59r1=k!O*P=yDSgJIiPkv z-p^hUnq$E)&(C@SI(CN9jZwa~PnVA1UM8^{CeV2B);sF+MD|$;)pPTb(T%&8Zp3y8 z^cv6~_$UJ_hm&L7_~$S3Q3l&GwhBvK^}oJ(wypA(p5DUI)CLjfYG`|0o$8?V*{DCf zGX3=vL%b!H;-6MxFG?jl-_mUO#J77FWT+Pz6SGAqAYk)`B3Ev6H(N%Ey%+QJz)<6b zW1n6c9X$JH=7nYX1Hg>wPhDhhSsGkskEH>oHL;C7t3I7d+|%-N7v? zsb?>njyaz5q8=EYtUquvvUz0h_dc9^zEbmlK4m5U{q)OVj}3n)r-s<&DL7ZYYr1dT zsnq#m)iUSy%J4l_>p1KPf7{y+vM=4U9I+)}H&BcCv!iFu>|q23)E#c@9|>=oi%%qE zYTwXayuZqT^wNOuJFe5IMwz)Y#CKZi7+1;pmQ0o(=BUd0Za>dm@o=B^)Ur&MSw5YB zRq4L9*H`%Ul46NRe9QfaT}n^(K*j%S7=iQ#Jt(}F>ms`SjCCJ&?f@;6!(hJiXuSEY zPx9u?!kw5(v%WW;W&?XUa=3H+ZdteiFDTK^38=vd+M^$k7C5id_zY#?LpUCrEE@x&95Ul(MQ1k`eI zQ3DOkd@P2YAxQ}qBb>ZGmvy|+Jhlf#&2|J!ac~P?9PZ-gfhCvWpAb4ZxTrFGc~xsA zT9le<&Ne15yprC}A_fG3WaheLpfwpt8g;X9RCa`!eF$%PZ_*aE6UsBX;f$m}r~?8K zLlRg;*ocP|8Jfi=BmEO(Of}%NLqmm`0y}jNv~yT#)&k{8nV(Rngm3R86!sGGtp* zST^w{ps`@bl4_*p>MRB;#le4mS!DqLj1m$@e4m_y&xE6@LXN&=q{+XdE4kywdJA1a z;At&bW_0;l5n5d)y893o!^z>%upPI-l1u7iJnT_lnhQR2fPs)^V8)5iGJk_}QdltZ zb&*5asL-RqrWSL?om9w?^u1BDq|1kKw_OD_z;d~~V#qBnh<`^1#nAV@JLqCw)sGYl z<(2u>K9%1-2ElTj7PRQxv-_|T67aUX`pi;(%K z_|)kF<(iO#6&V)&;ak?A*D=yj(nh)H0Gf;1@U} zR>@^BU~~)QsRSsN1W$qeDs@CyapJP9Pb}@$+^emomRi{MbzcJ26?*6DM(IpS_*i^E zz>$b-U*}0M(7qU+GFa#@OHdXvKI~vk8?MsR&$^T=vBfn|*V{*HU8GD*{0P@bHYTydJ`y^WMUic-Op}K-<%AVovne1Z0LpkUWNh2`vE$0AT zbs!$@FwsBd1yWY!A)^d9N539<|NiBc)~lBVN4sJP4pOS3S=pw(yMHFJp2Vjqb7b`& zCGNu6u3vt(jeNa&?;O47TcLWUh0lJ21HL5}Cl)NVSmNE9s-#BjO_a;4A8v2SKDPSt zIMXCb;NKPPwr@*sgR;hE`bz^rjkOt$aP9ExG4}P$D92a6=U%&2pS%&==Tj6F{U-SP zrU$}%-Y-HA(@`&9nR+*}Lssp|4~mIXN*{a~^3gE+L6vgd0pF(9R#B|Ju+)F+iPfX zk1IYcs8Es=yHO!$+ru(h>?`*{QiaG|LWaI{f(D1haPDhSF1EK9Z$}FNN*FM zS2|{WV`sjiroG)d9^2X-b=~I~g{(YBr$6dh=JsNzQ;bK_xum{3NdO8VMJ?N$5B3U9o~d#~mJN{gDb8UcHzWw0^IT`}|KjWVZa2flel?J*2;F0elNPFH>X%*u!w6gm~P$j$*3 zIQk{@w&|6)fy;;f*5AG`Y)5h+%gp%!2Lovvg?dSt0^ey&axySG=kG7bFO4W0wwuUt zhu-QI4AXEakw@0It;^W-Kw^#~r+C&WDY?#x@4---tL$cDIv%uss$@_1_!vl%x~9pm zw=%ZQl5YOwan>+eGtfan6NNh~KO%X%lVQ#d!82oZbJ_NeMDs;-m_miJaQO6@zY>|% zm*Y&<%CY#;zEc}dRdE)^ZR2bptp4hK_~50u72)4~{L`qz%IbUZlWSgGPW{6LMe}wHSmoG8c`zioOYz4bL{+XlV%+4L&ils0=QmjFg!fambJK z*H49PxhdPYtCan8*DDn!ZH~~&YYypsXs@=YU`hcW;5*@+i@^h1y?0L? zP!6hkN6KBH69Xx%9EfJf-T(_mzw%t}BcK^C()MzdIi_2T=1MSy11NBNUEn}s<>cbg zD@Q!?zL}C2NE(vZZfvpzY!K#opmUvJHyOKvg#ruDVh_wjx|bUhAP%x~46*!rrHV@i zcdqnVeMosFoqn{&;UK`mb>Sw2d~|CJHSQ)41T}h0Dkt zy1AsIViqdQ|0ryUfA*4<#VQ%U`Y)81NVKPvxf+-9rSfu6X(EwJ?ON&SvlFJ#A4F?r z!?m0A+cx==QX8ncd`-z7Kpp`NfWroy8y;T&Cm~*Z&4^dk%1^xaq-7E?FV19p*7w+} zqg` zHL{T?wsK*6x&bMg$W!s6>M~^*Fq9YTj)}`8($IK*1PyyKW%#sp zNS?Its;A;-c4***#M@aybTbeHl3C&rfgz41_Lu{#fq+*bGRyeR#VYAu4er{P4x}!% zcWR8aDqcw)klnINwdgMU`g_T1EA}hY7xiW~+BjwVd>7T1gemjG{?;!8kRX6xvxD_` zU~iC8;%UdjLg~R^{*%3tO(6%EFu>u>?@WRnWO%el`Mx|Z zmaO>c*rT1*j}s5mYie^PaL>K#kH5YC&MR#1X%BjI>xQ=Y9MfW-S0{Hi+vmvE$SxtP z=HbUkYJMif2jT>fh3s+Qqnrq5g*X#EE0Yz0`MUsqCt9lZ|H2U;T-p`*51|yeN zQVvJZhVJSI`Qc25_tua+FpC#?vN4rPGEzboa#oOb#4AqH_b-r=p8A70X`3&l%QO_l z_28wiF=%G9!N~!&*VKo0l~@?QK73zTyykt^kC~@JT{dc@{6qDy}?m_Xqw$Mz&GFtK590daMnIFw!|yUDSYf^(jB4_XQ5TvgKR$hwum{p_91zR{Bnhi3-p~%HIZ66K(d~w^z#F(y_!JXCvq1cyo^5a|CDAtJ4j@FBNpDR84rt+{qT6QSe|J`a_ zf%X2yW}@sjiTGvg?!L9OXI@wQ6L#zCTuPEbO(V1ndjuPV(I_&%f`3BtKsw0M5PS*Y!qn=kj8S2_AiRcE| zM#jqez%xzZKTM{WB%i7$gWjMmnzg7Lhiv$+Di*3l$GR5xfz61QGqNTLXBRRj;JuKz z;4mQNBP|{Bx4=FO3Io>I;PLTl79vnXQ#M7=dzMQpzKA0y5uQ)=c0&4bU?3k=$ufh@ zP#Er-Fc$P!^OfqfIME}YFrjmm?eu993&O!xgaP#(6WIH>ckje;H&sYG&W5JAd z2MKVK55#7G;WGq5urnEyj&#}ZZUMVi$3KPd#HbE3y@1&=kSnrgA;E%D<~rbyWq}up z($N71Y@8~>xFgTQ_){8`Tzx>>o?@X6Df582f`=U|L>yEMcfi|Z1!2Y68x%n`GDsd& zaMN+;VL{p!c7tF^3t8b7$P&ZwuVJUN{}-wbV|^5UCal~>CMejasm;{qC^g*O+UrbR zc#K5Bb83WQ=zV#Ik;x<#Z=1n38dk15Ry3$FRJritI=RS)loWj#EqcN3PM&T;lgUo` z2v2#39gGiq_|5*BeB}a3To!ErMiC4^@saoazaL^IT@(TIbWns24P-8du&a?t1)?4C zs|NI#2ZNB|G++(@@y{Q&jxq@uHPQM|uwG_}vWrEPtaG$ksn=ML5g4@54)gBk`^llS ze^ZKeXrkRiLd9eYoj&3{p~Sx-EqHu^mn z=vHeF^p`%z=HD^OQHbqhMih#ky_cN4aJf58A^5VCL?&z@wEbDN(0a3R@JDSMy+spd z1tIShUP><2*PgXu?>_cvDATO%ui`G<{jv`i`Y%t)$S?f+@0&y|jU+az-QT};VdbvY ztBGBaswGlSx2b&W^$fUHQMa&T$<~qX)ix~wGUEfQzdNufW3u2fI+S2ho6x#%&+6{< z;zcy7>u&z$_`_M<;X5)!mkQqboxTz!y29`H`zCg=I;Y2PaBWoSfqfsh^w1V~PLmg~ ztEa6}?=9`nP99CtOPJy`iOhUjTCSws=nr`@rG)bN+St9|(ZS}DM3+;|MdfLS96yIJ z)D=J9s7Jx~^S4UeEo0D6PVJ>*&`Lg!`21xVkW!WZHq4IIXN_FT?ex z8yV3D&su3O$-Z}LxK9!-*>{cVk2$XQU9BKF zb&QdC&err@-{33Pt$ahF((NTUNmP8?^p&Ot>xB=mp7D6Bhwf6i5O{to%0wPO;DI-mM@wpq}9wUrgwrc0f@GtKb^QbP?Q^!B%K0eZbYr)dN--LD?~= zj8Tb+yG{dz7_Y<`sb-i58!0Rckm+S~Ro*)~#|e4SGFM5T{5D9N$~}bDWi0esm?YAf zHuo^`(j}(3WU_D;ZMqbmP&glmhikP45-0Ie7bn$NMoB*(y;(yFTd4kLqmM z_U^eY7tL%uij0(^>_&&q?#ZskwkG$<;JgEGzpyU;v@)MWc)#Xz6zAuj?)~Yft~T{q zE%S9*Yge@BFBh^;=*RMr3am)fY9Hu1w~C%LY<;%p&c&*M&3Cl>F#h(O52I{>&)(z? z2mC^%Co*iN%B=+ZKHMwc1-w29hRlWVtR1w8oL`QeNK}Kn7wT$b=dCh~9Of!#=gRJ> ztSP4U7_q#n^faDn-!9z2K0M+M8gDMvLc(jR0%-GHLvu+jta=TAs-qOl7ox)% zy0DioyZLs`X;|-pYTtMP1Ez?52#INc3jdJ9W%XeIvLv7%miIUTJLuQW21s+kVD)#z zaYf7*EK~vpOyL=^7Q6BSBB_`x*bQug`fx7FeHmI5w*DfU&59)NL?tq6F;nS*lb5fxAVqsn{!~{&j z4y&ry9THsdFeHcW7hYdX2g*Sfn&ALL#gt_z6-A{$Ta&0BHVY-{WmN@70%V=(A~@w2 z+vN**1W94WBb;sPeu1p0Q1!6Vt^&uA{YEIS(~1R|b?^(v1*LnvCnG%KYMAP2eHvYG^fbdx`1-+tC(Yc)`bQbc(Ee&@LreYoBifto}yfh6Y2{ zRlY&ST;UKqgLn2<4F_QbsECLG4HSpai>!n_!av60w1R3RRU^D^fRzg7PY+{=83eVN zT(`|h8Q{yC06J!=6(B*Nl?0l{m=4~V@lr;HugQSxRPt~Uw-=cKCg@T%h9Nw+nXoW7 z3(9ms&k*0Jn$w~5b<*?8V5q3{{&4=}ZZcuX-kgT^(*u|9Dk}Wwp^q25PbM8%FObbE z#+KxtTf-4-Q;e3CpU`H4DS~U(y*pnr$S2mFkIixRd_@~~4$bnvZt*TN z->50iXKNJ^1)ykES@YXJkZu6BXf}0>`cGr_m zcruuGVi~7mt{lS7FZK));+X_amX&C?2_Llq?;R8{aAb;!%p5^9;90q;ZV4lm$&t9t z*gLQ>o+h7{W{&bw?y~V34lRUuwZ(S&$wcX+X%}e{dJSVC4Hedbk zHYo$)D;7zrSso_tL~gPN>2?`oGoN}V=$4D5qi`@ZiyfFm&1|2d2j-4*3@Oc`kBjp1 zx~Pxb%FAPi!ChI0P#&8~fCd7MbXp_xE0lQx@d63RoGo5P)ja}uA(moCD&nc@vSD!s zVL|B_$w0vkBSd6zqX*GFLw3{wmP8DX;754DO$UvGUd{zK*ys0_hgKF2avj=(snC%| z(R5NlNean;zl(TepmzJW?8tmYKR@O~!q3Zz%ckm|oKaP2gEkNH8^ z&7f6{{?r2X8T!dorl`Y4bOa~fM*KyZf)80ty%qzb5KdKz4(i|#LSxyvk{3C4Y*uZq zE-m3BNUpoy&Giv_mh(aoORCA?+}-Tc38;;*MjG0jaR*seAh%N@q|i zhx?4>Fk%H-_%$?U(4m6)NGKFk!O+^^2pwzSeqP8jb_6pBY1V=YAbW)e_;&-7v<$;!3wscYWWaF85g)a4&h}smloho-9yqP;6W2sOtwdN{l!R}yPNLMK^C9m*>T*tcY zE*ba#(e)*8N#mvJV(!D%DoITUDt)LcQX6GY#t#$frculmd%4)@fJ*`VN5Fdf zf26mu-~Zraw{mEx``BceHrLyo5Z`Sp7HJO)6uRN@GkehWmJC|-Dnbm6( zpHly+*iiSkBlCXgLea`4%#~q1H$57TZRO_~%))PJ-u?Y1B=i0U6t^#aaPr=+tQ)tg z?(gGnkEyvh=yAg8?dA1*PCokL?H|3Sed`Ae7gv0Bh}3%ZxS%<8&~k*d>x$Dde`1?_ z+yNN;-A`QWb+HjF9kyl-1S)R~43zXQn&uH|{UB(=BUt}4Lg`g(Ve+D_>YC-_z6;u) zVN$i}q-vn9P;kahyV~V|TQoo5MJ(bzfA?4G_HPN?{dK{gq#qZYjF>iVTm}`X-P*W$ z_0X*G4qpgblo3yEciw-U9VSdTt-flJ@}RvTclXfbYv0fQb&p3kvf=lm>t5LWeBZ%q z!(-dH1lNF=i?e&X)6Se#9ba(weEgc0sE5~G{@N-h_5YBQxGwPrx6Xl!t_MB&5vhqL zwnNE1Zj+<8Q!~#kY7lu=1-)w+=|9((b3EtUKfHB~?2CUsL2)M2XkuxWxw7id0&fp_ zjL_LCbe8Wy)|z%@dn?|)#OGJ{4CLb3TSMo1ovN2+IUcOaZfj~`)L~8=V^_WgjBx=; zz&x}-lRXF34Q!EH&@I%0IComLcxEZ!Y7O~w)v|RwRXtqmUXnBQoKem!;A5U!znOWpFZC&Z* z^{<(x_Rk$ZR7d#z&{lu+<%CU(BiBbhIC;!xL65}sLiShbCyv_WOx>=-?}*#naOyJ2 zw3J~nf_~WWOWx+055&>*-S1oN-{?Q+@f!0(lNPQ&N-+}|RwP=MeBu7ykJ7$7)$K~x z`^E`D$EV#(Wm;_;nzU?rLJ+AZEHc4X>)>B^F_F57Ogho*HVmcqa^(yci+4lHuY!I? z^S*lBxD_uk4J~xf!$}IPKJ_-EQ6AI($w`T)q1n} zeiPjip(FgsWJNKjpM$`7k%yC{GeBARQEw%Vg4T^w$c!Z&U88ca_9#ivheehzcnSv1 zg1WM~VFFhCcNuw=x~RA0fF-~qqR3c@1N-h7$iyn z>!Ut@RY8UQWs04jM+i`KPZsSy5!S4k{lvq}y}0bP5l5 zVU)~0N==iS>0ogN5Y;%eJe?i3`x=8xu~LYP*kP857|WThF&m;}1fW}+YF_SLOWa~B`9=ECz}mPj@$1ufzAQ5nXjf@EZndnZ-o+SE z3kY$N+Zr%-T{`dDoU4*^)~lCyy^-|hJ&G=XmmwcfVHK(oX@!SRFrxEtHti!rh1ljX zIC92~*`G?a&wvGXQh1nj%`15(WfPN9vh#Ok@s1UVcSI$Bw_iH3@0+1DAzwT#{t;X= ze_q#D`qp{o)omY6zm)_Y-}dwCVd3=bQ`2zAG6u$fX)ADF^7Niv%=$GnZ(Z#4$O9(p z3_WJiYiAZSro|n3dn+S)W%&IKo>lg%qcS$XS?D66+iywhpkxfJVal!M?@YS-;BHss zSuWE&+pqFt`^TR@>=#8(&f&R_C}OB7hU2Nt_HQf}h^m#xzUp$`zr5=7>@Z8lq_z4l zUzJdQe`xW3_d2aeIN{Ll+An0jntv#}&*nBas8SMawEDE@`wp(nZGSGf^h4qgVJPlL z=Tl!^i@kB=m&awN$`xLhrx&jb@%af--|FGi6?cP9IJ?gq8Sz`T?C#EnJGU1H_HXGr zaG_!Afv1%Cl;l5OE$vW}GKV(E5&WNAf}j1FyREMbQGFA(oxZw#@Mu6_r}cEM!eu?s1De#zOQknTyH5xC zFDyAMPkujf{rgEL_msEVKlj)qK{&PT_MaLMsIWJ}RRAYE4<m9jQNI? zjYMQQt)M+AR@B$?WXHvYStpO}2(5AsGE^{I^_8cWu-3e9c)M!)j&qs2RIS4!jrAF` z&%cOLv=#-w{eF(Y&{kXHAGYAeqg9IRN9+HT2wRVGW<|XFuJc$q(XW2@!)qQBWt&94 zP9qJfuUWzZ*%rr>YevwE-VKlQ=b*9aGlccIL85u|*mQC;&=ZQ$V-rdAsaf=-0aEw8 zBSrB5#FkaAmYIqoqq{F{tb6r%W$%g+zZog#BAoup@kWL2c}c&gTRYj#sCgs$O&%;z z-wR%6`}Wwq<^}r(k6k%WJ~fS)_J;ZXefwW6M>1OX82R(#y(#*+&v<_yJvnH< z@8fHZN9v3RDYZR(+i;ylli_7~;{BicieCQWva+yw%c4u7H+PQ5bS-?hzwW~EA01HK zxI^ojcXZ876U>~$54bXDuMC^>cR#}~F7OJ)EgJiX0zue;iQ++%L&U0bGB)ID7v{Tr zh9=G2Dz_LGHf#cFMG|0_oj|^SBbptnv+!NoS8(Ne9DixbFwa~4`&*`2XqcbVFlP#B zolo7yNr)~-m8UqI}FRw*T6E#4^4}>qADUzvKR9Pwm{9JLN&)G zx=ts>7gGpKVa{;cIBm9PsLtS8uQ0S(JM&$=yVy0V$IE$!@>+n-NH99H5(tb-F)*G7 z776Y}V%-67Jf8xlriEUEim+n!uzolA?NTOD1}2umBY9 zh1|%5dz5tph@z((S7T;iS|{Ottt~}n7NnjWXADh!*n3k zz*Zm#-^LLU_RUyS+YE2&1}>yN{vEc_&c0J4ul+R zS2aFl{g(R0mC>S=2Ha`zmaz7EzRaR$UKV_Q->Y#Sl$2@0jvhij+QV^*XPeN#&t43V zrMai;K{(!e__^_csKKGh&zK23i4TSkK{qao)nS@sSa4(mClajMjH@R>dqkEZR2F>j z8-ZY+B*qJKrNHc7$}sl)z6>qUhDTd@%)yREX84g&iKBROMQqmXO5v0 zKGw8;Xd*OYCD9mEZ)~Bl3)U2js)?w+UM2SVre(geyjIh4Wz|XVLHVB{y#}W^#+rX- zY{)u2l%Kkybayb?TnXd4uGW+8Y3fz<^M5vmO<2*{5xB1S@1X^BKm7C`-wuS7+*KRqkBCj z73%wudOE=uFvOV+xP_n)2IRYz26IqMv@G34c(>FYoM^=13= zE|puqoR=-Ay;|{oE9`Y~voVH|P`Z0%_ZAQ7-lI#O-RmKC#%0g!*vW}0`scwy!lZ=~ zU+zwc--nlVGk5k^xZba7u$uxD1hWx2>1=N8^nJ~VZj+mzg~iu(P2aVC&owih`r6E* zYeR+E)&7H;l!_N_Ca1g!C&0%RKY#MBmG95g3AUb1Xl*&YP@7A?Qo8Z@gTR-h;?SEGM8 z%i)Wem%op0etfCbH73_ttttGmuj>zzZ2oe6i$|>2F0P<4_UnMe9DL@XtOi(|{Pt4& z`H#{56T^NGmK_$IE@*fMGq@kNw|#IKEV;gWQz|d#9r}GT{^W(_j~l*aR1uj7T!_Xw zyIp8%q(;s)k*+0crPNRq`FpTTkJHt-ca|h9_!S@bfin5!0{UN`leLbHwmt!dM*CGQ zG|_D6zki#@?|sYN-Bpi&LAFYrZeS~9+jj70aS(UUMgHWD+sYe2lcD9|LcE`#xr-ci zwhS`#!wZES)NLKAZ}0^Z+~k8bv-noI1rti~FznkuRGXySbdil1Z7Y~TQ36nq%1&R{-=Gw?9d z0^H+(Z}=UKpm;OnutYCnbZK4T9}PD)d4N0LUg#BR6;aGF&_v84T?C%Jte= z@L8RTTCJ1C8}%vp(lV0uqRrkUK6KQ^bydv7{k#n-84KAtuNI8(tkQ+R?1Yi*W-ofV z@;)O+sW8Q|j1p{@?ZEX%Vq&k*dL=7-tY=qZtvA-=)MR6>?5K`c3`7`>=F`W7Z3(no z${@Fb)tRsp&{!O_%3yv1>J}|fd4$#=??t*Xt_~iCqD5j{IJti_Oa@tSHZ&S3myqMn zC(Z|%OK_+V>yNr;7o*}`$tnO%1zX{uc>@opG!rt$a1(oRM5N(hB-Q5H?EI`KS@%yb z&N#-oKT2uItbgTV*MC^K{r&n_fR(PkRM}d zKQ3EZ7go}^*f;jux&u<}aKu#i-GJZVWQ^@8jVHVk>>YU6p?NEl`@*1+^ z!*3qM8$R~s9!^Xlp0$7I;4(KrruVLYiup;({zOgZj}b`{z3ERj4w-NkRObcy*MNOv2B27(thX_kbFbx zUC*A`Bi=IS{W96!mis*&)7ZQ2bC6)~GpAi#RTU?npjT$DZY@}v*6}=B-q^F^@_7HQ zkp-fgEJW#-<=<~g3_>+i9dd+2@^!5jDg0ImaumJva```gNt2V~ry%Yv+PCfn=c2bh zF8aDW9}z9MlzN~|{mWV3A1E7Js(cdCpWrn!YnJ!eY*P>`C-_9#;}*^FgS~-Wmbf>1 z+bNk&dCd}N4@%E9x4$Ze%z4sq<%`Xs-gNF&f8gb{eJy5 ziM_-$Y#{O$pLW_2^^K=Xdk{ykc=p^%nNSpumN!i22{mmBqjnMVK*M9x zva#tF=F=r60Vfg2({Rtmpa_f(BfaBuv>%K5|M5OB4#tupvclE;UtfZaDj+Tx$6?fs zXa{J5J~0Tf=xWR|V_w}x<_N@P5aA_2lk`n+jAMnFiNUn^h{E&|?@_cOLz}gJBPTh; zE5s4S)kSap+0TEjy*xn@i}fu}B<|kZ>T%sLP|~E!O`v=SOblcMJXjzDO@tBAnt$uT zfp=RJco~}~V{=V=Y4l4x=v9DDF(0E;@od9Y#SClo9{+V1!x|uD30(|RB&hmPK9rt*ODDFG(33G{ZEG#r95&X~Krhk;!>bNRD}W@zwpAvAkt|a{mH@M8 zPbU=SnAS9Lva=ZaO9qNX?V(~+QwCWD+Y>gP+@|6U!JVkUstKO@Mipr*gl-=O7`&Pm za_W4*zXM!=EZ}Bh5w^Y&$dMXY0fn_cumli$v7a{0G6BC+-I6gD9)exL*)Y$09VsaQ zuouL+;A=1Pp}TS9wXqP}5xuWH$apSD1CDuY^0XSFsZT)$Iztp9mIE2Yq3==-TG zVr$Edx!Tm1s=1Qt&ZA8I$sM*UX%(>Yb%yAV_?6=h(;P&FGoRMX6%<6TgN+?0=k*rR zRnXMwjmJp=yMc}E6C)PPIdTv$gsktY#GET2!DC;T!^4!**0*G8rGx?-syZw;OFi+mS^Z6*Jvw1*Cl{AZ?ebCcbYtp32oc=@WaE zBVh4$dpJr429rUsvf$<`u4YK?`D{rU(XrVHhS0QHUl^ghqa-BUekxRjP*x~t6*By# z7v3)-H44MFT5u6*%cnuH1`TDm&s{_(JHF}9L^QYLPDT2ks)fD8ad*oO3sP1;s5?Sg zT>yQUvr%>GR#QcN8Rur2EWDL=PST@(pTF3*LSx^N?R$Tccl|Cm4HlBW-fX&)v)rCi`6j|;^_r**z3t=jEc)~<=rPmc ztNVL9ce=&gzcOXJA&gZ2r1+_Dja1#AwLg>c*HM={U+|;ic>XTM%h(%UKN?a0aw<++ zrN*DTRrIsZplV-ukGdo!<)`C<5}R>ZVh>Bk<(ckR9RqX74=?jMLh?9`IGyejX>s14 z;yst#;Un=4RK1s*0q1(DE|ciwEPIt1eXSJ5ExfffXNnTy0RrWlEYt(4SQYOo{4E@Bu+ zbukW2KPM*m$h9$5_V3USO<-{@-q)!ukv#jg7WG z6;+>H!LZmq4an_#sas*OB3e@h-^YFaQ=@Sh+y_KZ0RG?`?3x2IdI(|yRE2^bLRxlA ze8m*I=+{htRD35lRs#C)>ArE9vL5(87!6eZB{C^enTn@A5N}MbZ9%}f6YUT}dM5MU zf%OSAi;D(t1kS7VAgLN_Y!_KyHx_m>t{rstrZ(`xIB%r%>6ICxk zkR7r_2LEImL}GZ)tyr+;4g&v1j96|AhDLWePkVVJn+Tr;xcW3PH9kj#$VVRlb=anb zVG$sOvQ~f;UV+C*cp57q`67=hBqP@&C7bPmseqC2FxMFrUinf6(H_S3_8~O-JMbr{ zBVnt7!opzzLt>b0J86R(4%#p5P(gTI3Ty+!G)ve4YZ$zZBzhmFY0@F|g{D<*njK*s zCh=htVe1Mj1U-N`zI883@#<#)HDo@N^*AB)fJO|@#1+-Q244PR(d_!Ys#!fIbCpUD zPUC(%)bnP!EkCxed3{3bOfyP*3hgI#OT~AytOwHiT72iWPBw{okyqt`+ZE%YYneIy z5qI~EtLNVkleYi@9-eht`Y2{^Rv>$U@EK==rh~j9i+AU^_3r07BXhrmS-AJMX? z4#O+I1!DW?!E%Mq9sniCTt>m(D5D*Cm{`-{pej{4C3kzbP94toCRL zYsZ|qQn&;*$Ecr*_TPxe*n5RTXx?^=G0bi08fnPMr|tX|pI&t^Bku|)bj1=r+ZU%3 z)4I^Ad9q{rVqLA*<@QNa_tQe)Zs#bMP1hxrT)33W6V54S*OXk24+FHXsBAef>lNOB z0gj$a(^H!*zS|xBVX~-r`I#?&2okM+p0DnF<5VN@u+vy$=h^5GH}Saex0flGz_Z3fTr@qWOUFbHI{zv5SA3Y{vUwmiK|Bm}fX0>92P_H0T}Up|sYWs`h0p+0ZiE z*1z6PhOCbyKI^L^;Ke@7PO;V7P&#oQSSRQTin&)_FvjiRN>oR12L-95wrPw&YRuI4xRpVj^pe^=Io$_Zj8iwH~JZslF3 zd1_$Y8H$}uXmP@m%Y0X~(A{)L-#&-a9t%az$$*%wZ~{!E0~5mW8D)1725y%2UuW*iwn6G#+X2_$Ek7kWIaF= zY||EH6 z-S+o8V%4TFEjld}nd~rJ5Km=UyTbn1v?VMNwn9iuS|ydXC&TC;2v{KirUSmn4uX}F zsf_L91{;6l@E$1DMO#g zwoi#-hY%)6gs8a3XE2*-4SHL88|*&R8J7{j3S1dh_M^HXn6~)TcX80im>ztnjBe7&!j$x?7>YA>t29 zk}=@P0iH4F=74d&fo_h?xEX^r0n`&08oRWp)0NxK$MZNi{_u+Uw#M|L9k&-eTYl2v z=F^xV%Phu#?#>VPF*i=F8wv*Fgt)+x9U0N)Pc?VEFP!y1z+L8A*LC$Le)8Q~N^-71 z5L%P1QwNbiknC*-iBN;w>zRa7aatrmTuZ*XWtC7h%74Z>ts+blCIFs@C;1+f`}|N$ z^wuG_uuzq%Wn6b!C6`!axadgXycC(W(!^hC2DYeI{W@+WM3MNQEDW{MykBhK_)_=y zD;^M$upzq#`42`cq)|!1jER<+oLQt}v9b*hL834}j6ExA11xK8 z4IU|aeERVu!h?c|VdRU`*S)VKT`hXC@BE{Wk&mw~OB?wckz1R!=WH*IoPA`{`-mqi zie_&$Ddt^_pLwsDq_JwB`euGgRDwuX6e{xAp4{{kY1Z~)zELJ`1z8B-!Xz*Q#0h@q z6c#Rj^tj>iUloTb5y~~Qw?yXGz=ag39vzO6Ys6m5wQX$ zeydky6(Bs$ioJ1sZjz#tE>_&)Xu-J^>da6DVTE={5r-bDQ)>yxIUc3nm*Yjm+qeeA zMRC4V2I(h2P@Q;9vhT3&$O8^q%zHG^6?Brq?=tq&V7u?sJuemAGssc*(y=;*87FLo zmDApMNn{`y(YfJED~SlrUzI4Um=d+N2_8|xr8DK`ufrt8WF0ih*xn1MISpKVLd5~$ zy9e(OO@As_0Yai5MHXRiZzRYQu*=6y`3g!oxQMtMM-q7cTquKz`7K9oYKyu00aZmu zjhijOh!lWU^XwQo&=bKBl#O+)`ZzkWzm<4C6{v7Q-8F&Gr%LTr{w8+w?2TPp@oslzUQ*aQaaME2+DYIKFHnpW$}q>XYCpJ9Hd?CemY$WnQbDt_TET4yrHH zTPX0^OWBamo?4UjuqzryQ#c`3449E1e2;OQ0LUMb!ACZr10b6k2avNbz!%sF0=jOj zj0*?}FmrWwp~9odnB;d33dTl5QYs5(?JPPDepw?~1@{cy7wokg*X_@zcfcnD>B1T+ ziH^}*n81dCJQyC?8fea@Ck7j?wyb~G@a6!0Z)1$-4g2UcZP?_rWfPaqUgaml&wCwP zHPUvbH6T^?^k~JmTm6;@PK|en1E*r5^}_IyzTul~#MIdlEhn}wTc+r$B&mzK&(4qR zFH#3>sVQ!!Fg%?^LCH}x*jX58uYlerEHoKAmhgspL47h4ScBvZ6o=Gjkti6u5wkAH zRy8cp9T*|pFV=~_EXT^>%m=4r1Jxt8|&Z< zMNY!YR)fLof^PH##Uc$FhKJ2Q1w04q;Np-;Q}-M~?{`*&c;w`C!m?qS}l7~J;{G7l*VT~^-c7@u*o)i7sAv(@-lRHe&O`Zf9@LHv@bTNNfhC&vZO z3>#5(rMcw!rbZs!#7w0Q9rfI;*l}sCPVAUp@)hl9OGlZxVV7Ub);x}8q%mV>qur2y z$2rnDpkCbP1=#{4J~V+orQY!aYr2 zwxcoZcN0!%sBQ}ETEr&?^j>p2(>kLiCV$J6n0CciHqox>Yk9HFGu0hhPjbv28_*du z3I62HO~gfK{NS5Qp}*c_FWqc5R8LJX!`CiLLd7TdYJQKDEFP@9?uMhlyd3qvOqo(- zs}s9UAEzoyihdRCa9_Q=>lxc}TKYD%s{A0nvUz}i?ud-RCp{Nk;(jy?I?@MlOx!Z^ zN45ka9GL8rx2XH$`%606Tfr(GtOgQ9riBN=WNv>hcV1MIsQz5fszct-!;@yTqiqYG zPBo191zbGwmrNL-zswR&0sk3yootc>29FQk{lYPmZB=+Hp~wS&bpUvQVUPtiETK=T zC+mtCmf_^55b^+8|gU@%3q-`pv17~3ncr#Wa%5aN^FPfwqfke z@qqcl)yIou!rj3;h0y@GgzfsA*?_JJ1Z=1fHhYiMrK_gF&WTFvS@6?T@WrW?Uhl!n z=nA8IEI8r@3B}ZKHxvZiGT}R&o6S{Wy-(QbGFx3#MY1J3I7s)r9tM;9Gi_O%7P>%4 zHcz5IrPHr5NJJBUj%fkr#U1Af7%qV%^gnXIowh^Iy+1Oy9(T+x z^$)ouu-jz{mjJ>UQtD7Kxg(x@VQ$@At@jk21Da5IpYAe+*Mck#nPaLzuOb{xN*%Aq zr&W0uV2R1uq4Z90R@Tv5>x#n|c+mX|Hsh(n-Dx~O?;eEc5AMAXS+m1f8GM{Z57xQ_ z9~N;wS%Xb$vEj;rIO%|KG# zs72-j_yT4{7BWj7b&dBF4*6b>wIehsbI=;djzWOW-LP$MadZ{r5G-LrXIPU-63w9a zsAFND-^n~G^P{0ivUjs;UsAr}uKl>)^f=nygQCl!+T{Kl4jb1+T}ke!cKK%WflEIH z*IM6b`I@hp;8rj$WO>g$dVfZ*^NJo&YJRJdoeaGd;@E)e;Jwt&!SMfL4x01Sm*$3# z{V1Cg9fSFd#Abry$l$xqvT-Ao?{|$5Vtsjpu$NKa`VsDDK2T5cH0Z-rv##AM*44~v zek$@b=q`w-f5{Zoe98HexmsDTd^+=%%xnI5u>7jm@LHGijP54nNG1_ThQQJ#OLL@~ z{Z-{leybND-+>DzW!ms$xng65KrPUNWe&B51ctjCv0%xQn|3G6G`jW|Y;?=9_`>GOYnUv#jeZ};rsNrA<$ z7c1?n_D z&S$B#bDpcZL%i1Zd=mHhm>7=O*v@*jAv|O3`<^arhTcDIWhUUuh zhLosY5_|#x!(IL0-d!Ulu;_+dH^ohKfL;f+xvIsVznn|J4S@peeH{rYhJT67F_|0Fix@Doq^JAL03tsU=@av zgeZYA9=Nv9A^?wEi+I{A@rO*ytZT{7`JiZ@MmMidA2~Wp3Za214=Bh!Ed|TbHUY@m{Dwdd;W>$D#H>ae}xN_8f zhGSo)-P%eWffF7wPoDqwN_}QGGEbbW(&O1f1+O_~H#(>xu5&jUjMgk-K%U>nC+@Cj)B;S6^4;0Mb>n1D(+ zNH$`=u9nbj!uA^p3{c&0t*SyEHC~=*uF%c{m3z8AOApTwa2OpiKoUg9IYcs8yWrr> zNv=)7k_{m>uC==yVz!=^N$k7Zd)E9a zs;+(%mbJBM=Mk-_yg2mSdaCylrPExBb;YhngnnguN0Y5fm?8;ZyA5g3-NUghLmBRq zD#wk4E-l*LyQKsQ^>gnT-!{D3)Y&*uHA%kug~zlJhaHBHVP!X=dG;@1=SaJo72T68 zCzR83pv93Mn?LSmI|xrpF2~grln63k?zx}0TGu*}?i>BxuD`<<W(?6uZte<1M1bmu$!Hl3uV4W!@A1keGM?z?-wd z@0X=acKG4#uBcxR#FUiH0NaagiBkqErq|ni_>r~mSY}FN(aw6-_v6o+!7`)# z!##JIJ@g4bFD=$kYkA>+D*UFd9$CAC0<+h!2ZcdBaIspg?!D%JGA;Vy%5Fhl^NZNt z>1$(R)hDt=FzC?z-sqo$nJuyMJ4L~q&Sd;}x8iVHQUy7oz{;FBKZWkdx6FJX{vs^W zL9Fgi>@Vp!`4tOOKior6CJ)5c3nj_q`(K?f(CX1HKI!wFKub{XegrZC-bFC9q> z2OuEFBWF*f#I2h+j+`gNhU_?4AuAjNhX*NEeovJ0z?F+o4^I`gA;bd2I16S@m|Rr= z7lDIZ2AVF!$)$>U z5$W(z!t(>GDtH~Pa8@Iif=<2`Q>F|ehen;#Dr zSQBI|HiL~$U5$~yxlNuXq%S4KXC#=3GG&E#(Hk7*<=i?xQb$Uh0=68Gu&FFKT0m1u zfFrbKfZJIZLGDA%6Q?B$gwMiR2!_@X=fQAaY2>|>D`{odiG+!j#L#2(U(~n(J|IYC zLz#tk-S~nLC0Ps_F~Dl#0{8+ce>v1308>In*hsz=K}Kvj&ef17Cc}T;!$>DucvEs^ zzN`>{o@g@RB|cN0D8%-lA)-V^Mf_e$p}r%adlIG!y5(-fd(9Qi8uiG1aQ9$ zBxi~qd^XQ>@pwOXK)}NS6)HlGkthoqJ?IxOd7AXK(N}hx{D7M+L2_*HXsdb(mzYkz zsAc!sMLW&XHpXjH?p~RlCmZTpc}~(y8J?1)Vdox@vTb&#YS;b9UtzJWuupnpe8#O= zGgp;9s?Kc)sB2SO?XISO-!8m2leMa#eaYEknOF3liD>zI)!R`NrZQ6Eg_8;VL1$rP zOtE#h7?e6I0IAW{bG6AO-LzW=q`0rH7whtM8PQcWc_ifcpZZ9)*8j%P0;F+PYe;~> zMtyJ1uD{)Ij{Jj_Z-+rYbAI4$@EQ4&5BebA17*Lfd{*q8B3r!rrl0dyneW?FUE@N{ z$l}7s(XsT7h9>%gMFOieW?t5zTbld#$&k`}HC5eR^$&(n2glxTJZody2*cbD>hAYx zFJWK#B+1X4m;X%++}Qmkf$)so6+k#xN15qy6DY=7+?o$%hwCWwzLNi1qhzptC%;5K z9DFA?JsQ8AFTOGydG6QpR~*SeS0xwv-Q$!gr#iEzKN*fb_JqFT`^P1Js|lRrQF#pm z+k3;jR!e_j;;Sh2sc+X(X58)wAR#$ZFA@bk`xdg#2-eTOdak-9&XeG9`Y_C(@t>|_ z)8>w&&+csWk-_ra7(e~Xv`E@I)|}O5#WLd5`jEie_yg}?P(nqzO_lr77!y4cx+v;w z0>1I?l9o-PZb6KH!?p4pFmbrP$_=*Gm94>xU7d^l)zplADq6_}ZK;&BukKyLGt8?*8+s=k5CsCQ3XJS9%PkB>=V!cUIs3hp!to(NOJ$W_qF85qLis_c&>23;BNR0 z%*_SKC^#>FHfO-LF4O?|7zE5l;1c`S#+k3tm5Q-Z)O+r6#0*IzV>;J2!y@ytU+Uy7 ztS2v?^k?)b?7ls(ApRz__W8mOdJL&tSIAJ!3UMLt^0N=nzhz9`+d{j=u~*VsIk~d< zIy(+6)7D$PVq1N;_lP_p^jvRVYNWhAg2f2!kR@5Oa7Xr#aTa7E#xo>i{|D%csv-}L zhXiJoso`MCOrv9i=I5^?TLSdfP=EiX=@5mWNUIW`8G+BVb}kEHV>4VQzsaOZNuI>i z2Tn*~2#biI@6^?G@!FsZVizU862|CIjBkXbkjk*=uN4*O&4I$g8jl_k2K4}_G|aJ# zxC~<{(d+>IZEAj|++?aKMBOpB4$7M04*n$cWwMPeGh~WB3K0l4=t%;w6l;eCWchHDVmhOvtZci5Eun z0h}M74LB5Nun@OrJkL-wKM+JQGx+u6SqV-VnK%9B_ilN{T+QjL*;V4QSv7~19Fp$e z@P--Hd#E8Tnnqhnxww{cgs{P(JK@OrCXd-yn(Ulv8Ick&bqj(7o(a*z*rk3tBK*-3 zK}#-|nG!I0lpN+Y3qq>Fi@X2GdVy@cU+YP_+!}Bum+{oV^sWOYX+*h-`?9dOpq&RECH_k5vt0#t-UBCAyU-oL~!?L%Hr2m6_ ze=`G#m)M6!42c}RtPSj-HLkjH{g1ANy(?-;a>-ocFFUSV3`EU}WnQb4EnmM}{UCMO z#9R0rEy7)Sz09Gj$=sQ4ZdPCKl4q;!22Tp}W1bTLS#50|}2 zXoyS%2EvgL^UlVebJ?Pv>%?VmPr6Skko}NYsyzj(RKH<0o)vQ(nMA~uMhBD>Ndn`w0;m!eaAr%V zCF2G;T#7M%II*o&;`wbGU?I`Wy%WlVHOfRe)Hd661I@9pU=!XGX{X>Zt+fS^VSPihM_+W~=WE(&% z2tYSb;rB`3sU%xO$i8}_2R{vr+l+cte;zy|8hFV8529?hXwBUG*<}{cOdE2`YzsN) zTQ+LW3QAVV5~f&t39-bmOQ_JpvqVfB#LPpX;le@_sztZQbvV^@CHn`!-48S}BWX$`3D5%$VL$bpx(ysp1DcZ2J zV(M1#3A_%(A$v+K9&8;Ic-?B~?f_MhSwKMwjTjJErm~ECZaF?V3xV~5k_uyF?$L4+ zd^Pyt;mLYye;H_ssPSZ&z9--!_?$Ym9CtQqaT@OoD{ln;u8IM3&B0QPrUG>}W-|_|Z}BGWa>M1b9gE?d%$1wwpaYuUfX;`P-UOodJRYpV!kVYT@0ovrtg3%J#5{m-4O-yTm@6l>EQG5W`>6mEZvxu}vJ7(gl8WE6r z#ZJ3MV&Y>CH^m%M3hdkuwhJaM89MrZkio{%IxCOGfk!n@6QO5Aa>X5`|NWBH zJFf$q4P2{|(r2umgYqx%v$!+RkZ$|>b7uq2hHh9dvWmJY%mgCwtc+qOMIx&XU-p6- zV+YD)Y-{odY--NKM=MdS^3<1B|kGq}U!z|Am2{c<7shgy0o|Y^){s00cn|LDE>A zg&94BKchH(b@tlO0WJZWFu2fhf#jNOh83)B{0NHq8pYkP(_&lU9R=Kn6*$c9Ro~{~ zU)B*KC0k)&85jyd(DO?XXy?TQHVTmk)CJy0g!k3>nf!VtjEN!xQNW~;QJZHQNZ+a+ zMCgXXFG77mI0-v(`bmcK$#)qx4N+G$4JD3(V29KBEwuhv9v-hrP&!wf7E|eWYT@KR z2SgQ2+9&Y9zaAv3w48VZ;UFbBp+UP z`qLo8eZy|OjRNBg3egT=6JrMB2}Vjc*qx*UhzgZgy+tP4DdKb6AXuK4)ub^6GLFDC zQvnbLgh6A?2pR>J$tQ%_=RuZTs%yBHUncLVLCVTQrWpas!T=c{u!pA!pNm}j#M1=@)O{ex!ZcB2 zTgGk}9s`@y0zTIIn#_ZOt?2oPscyJq!g56#8`y}{s+%xpK@ z0LZTBna)75Zw#iwzkaELW5QrhJG42-dY%{nmok;K1%lTc=z_qB5|v^%6G(J1AQ$6N zeJCs>GeOH>3h!t~#u^j*no=Vh{11%t&9miU^+z1wDuWn3%S{_VishQb@x{=D3t(bJSIO4spI494Q%0y$QDNzQ9Q+oT&9>ciDDVOhhj-eHec0v#r z>@WNxHtb5}c3tHE;vX+?=9JyM>MqD4lu*1IZ8V=I9AlFXm{P;%2z^sR*>j|B4l9?z ze5WGL->>2yD2ZINW!K2|=YeeroTZeSBRA-{l`aJ%UsU1mXTR)R$8hU(yUU=soSu2N z@*fJUDJ1_j&LBlPve5yInrru)5;)(ZgAYjC2sSpK0EQpnwqc|uVUGQ^sE+XzY(XT* zAvCG6<)$(;DUtPX<{?6Rs2&%6t@uGLMlYVXVxLL01Nl^RuKG3iaT@d2kdso^rI{cx z+;j}}^a3Y?-E+t(b(xT$lHBmsAqW?j$yq~jB-_x<_u|6AD;M?>csJ49d767%$H>4s zPrBSZT!{FAse{~wfi)VZMNLg7SixKVZEi%yy zh3@;Xj64?Oz~2zk8RpYCcJkmB|M@S*3@1l_V2o7%`L|fK`uEqT$OV|`=PvtyDc`VY zHcI@!YE9!)!S46dUmE3skYfM)KF0i1aKQ=H)Y@7l5>BnOW>BlCWfOFI0xuiltAi1$ z{al*<`+0#y3ivn=^p?>soAhp?&owRRzCyb)rMFj_a%`7_q`)I^#)lBc`2tuzr1;6U zwG40^b!88J4$GO-Srd1CYoo0Pj=8!6r}aBAx7M_FQv}%@%m|8$1A|!;Gxy0>X?F86 znF$gLaw~fOoF2DT;jg@mkvH%0c(`y&2B{YP`y98HtmrZMMjKo$TBc^eqV82Cj;MA@ zRAw?*vjMZ((YZj93ZEB(_7LaHMPC}MHc3(kcrygc$@Kp3b1Jb(a{PgDgVz61D4(8p(jXRl5I-2N=%(g)C7S<_^KN;vumXgfjBNXV>3 z``Ivy#yrb{(0s(Ld4py{w1GPeweUJOTdbmXcp2>eZ3f(DfUOPDUp$a%U& zJxyjC6WKj|LC8G8`mZxu7N1&m`>`lBI%Pa%ee?w2=wP1ZpEg*QLgq6r%>z-92@D{i zA^em~v4m>;GU}N$Qjldq3@xI|jsw`hIqut|xHn(tD`{JL> zS{U7IT3y)=MJLG7IIgn_B+ERcD<8?Wc9A>I8RV&~hbQ{2J^kvzivdO=%=o1o(`q++ z96&G_X+;4c2PfxIiAVrf$dF5d?1=#+pAe&qcyT-8E-_aEjA5m9llQpU?B*p@3)@sU z%rq5-4`3FjfWau+xjKzW^maHc3yF}#uS&)TP|4I?^s98lYOt(#w!gZJONgl!)<^*r z4^)t0EL$9!JXZy8_)_hxN*`?SBhssJbPEj7w~%cD`0kP1Oa{xan-FV;63r?wr5~lg z%X7m!&aF3NB~LM21q5QR!jf0Wz~pMY%BTh()_php=?9`HfpCgpuRrVr11^A@2Xl2) zo6;k+ABlFZD&$x)A$LFGioBV18|1`SKxli*BY|j(4{6p3Bkpv2lOPjAZUwy@#$MkF z?KhTG`+4xg+?K_nP(M3roL_$E&0xR9otkFnS3_C7Q|ah)ypJlI31p__!Pw#$@b|^P ztVOS~jsX8nHB>x0>N;PuAAFzU{S`mQ6j~ohZ+K}K%E-wVEXJYzZblP1XkR^zQ3?x8 zAfp&{Ai&Bp%vlzzy0RcR8T(VCaqj0x1=Q2eLnfnb#^?4I?&#B((G6q zk=Cl6GbbQWO%YL|;udLyywK9A)6i-k=cqN-I%Niu=(T7)mR788jz6fzWJtlgh(j(LT~rsADkunt=*-&=uJ_c}o#r zmz9e#l*|ojAFdcMHjUKduySpLoQwrUoey(}UzxgML1?wUTKSy2MU^+yM)}uG+u5Rf zC(XIGC*HB*G5)~DxJe;LMUB9+eTS>ar;ii1#_CGY1vJcQ1KhDj>q^k=VQ(uGU{TER zy9F{6^>6e%HO0yYt%=o|63zVY0;grdWeJAsW7O!^ZEKlR(zP*E*1N%|E~Puu5h(`4 z0%$}Th5J*L;yDUgyhuBSc2;BE47G!H7)bbjAJeTbZ#~%@`;rGL1%yv~1Zw7nm=d|< zH`bn;Ty9PLrqm=a0YU~13CeuM!d!#OgU*>ckK?DcyYORW)LTV-m6|ib@iEvbbm|nx zo6_g%clryx=~OYl|rqxUh_-9fGWwIo6r*lp-N)9eXQeykb5& z0UZJq?*+%#foNawsuiFS$n#a}KSGkVI7^koxIw)}zsLJ&7gpc|)^@Vd>NSRK;Eg68 zJ@idk>#|K^t?duvW0tgK6%RTRvbaf4d(4)CpU3J*5pLk z6_j*X4GAly49zd zx6aWcm!QvIX(LMBDS)#Qu8)i)VgkgB_9!Z z$MegYnXa>e`i|pE=)mo)99^F9RlzcQFe>X=tM?4PD)4ymG7j|#%bP=WhA2nW8E>oc zOXKr0;m`Gh2f#l^KMV35=lhbfgD)OlVk}{{9+aIpbn6$E_tLLr=}yNie`=p4^zAIw zn)D0bpX^!nWtp=HP_0IcMwMKQak;{=8oq8J!^+U~pZ8wQ&4Wj$y+#T)vr~b?Pbl0)oqJ8R_u8+Sd&QKLBojbj`j{ohy za=yQP*C!vsyed?QYt>2YQ*Vxx`SHZ>>#+RYF{Yv!u6~)|=K1zg`UH3gfIplFkFmfP z5bD8&hE0GrISCo0#563af|&GfHi+x5I7PC~$x^+(qX@EyNpATK`<-keB}cBjkUwcn zhDR9hKUCLFqf=i=6qN-;=0?dpL~vRuKUAJRM{R~}L$3Apji_%ILbJIGi>sAHhGa~{)s<(gZXQC9 zU@;(OMTSKS-z=E)sjfE!zVw z&M+du30D!6fe~IVjD`po3klBfrF584BbQJ;q1Q$~`7sSPJkiC~cLYt*qd)!d7ax31 zpcbjM;tcX|kNiOD$T#uvhif<~!m8nim$As#B*f#1L%IF@ilTSz(pTY)8e3h`n=fsX zhOx+6#Dz`3y9UThcGDM#5YT0#?$vJeUbqWp;OD{)%rv@zaB&4ZsAayMsba&#+de% z#!+3gHo`j8#yE6*L^M|yI07t#q;Mc_X{35QcT>AXpGxK0w*4u+?0Lx>*V3%ffglX& z3F&z{$vQTAlXrVgOEC*`fl`*%JXQ@RN1Qone`yxh{=_A^nJ%&LF&0IfTG>f|&Ag1M zm=bP747en6%s42?2F7Uc3g<9#gfTc)Rgs!3s+5_Y!5NSyW-;|DNSyX|3yrj*u#6Y_ zr;TbDy=B5h_JfvU9m=S(%t=!&QOo81y+u3KGyg2V?`?DjIl1AJk5-Gfc`s(A4XlwoId0Q5}vlM*LXt#wG$PcnpYh5v# zi$!l+Qw=3qlWKvTdZGLnB#6an5I={QIDI)cL<{WAi+ zM!V4A%KF2DjRf{T{2=5`;8()xwjX}-(R+^E9(jbv*!Q~ZKI*?i?;TnIiA3m__pL&+ zurjpP`;P8i>!M%q@Y$yw9lw()+kd$odumYEf~|x5F4s`Bs~T-uf^aDB=!7!YDkq338lj~#6RB*9dQ;`zBH1eGnz zO7s^}P#?r?_5M*}(98l2Gq-Rc8+rmjSH~DpX@v0wZNxsBctb+eExjCUx>*3v21gvN zkwY$nU$G!`IAD!L@|y_@an8k}bGVKI3&|Ij)zzrPF?0Yl7-#fI?jl{GTwX`$MF2#sqUpR06ty0q~ zEQDUg0V3oe5RkP~$2yt~UdlzcVgN6q>x@?YZO0NkQfGiDHW~^iKId9knOlyl=zcCc zSP#-(vfNOg#8OX~T6zk55@D3#vXbbNGU1fogFD|MM&D(^sREO6JU;M`(`M$X%VQ-g2Dgf08W+({~TOCB(n z80~ik_GV!apz3IS#ef^oYir^``OYm!jrXh+B^aN*KK)R#r9Zkl8(04d>|I#=0GyAU z)c*ssRfF<1JR~Knkwt<{J@_2tgpab1+#s#N!!`0@2t@{$qoY&8 zo{&Ey`0FQk8di}EHyPod9DR<_(rX_^Wg~Y*ByI+VlX#Nw>jYyw#`BITzaVn|Wz?#{ z_VMB+F+l5r?TLOCMCvcu{obJ#q4lx(t_Zlbhygsnor*SAD#pFLH+es>x1)TQET%V! z77tld=^IPvFY;#oM)zM`388=eN;-2o*x9N6*R5sDo3(0PTG^31#Y^4LM{O2np5cddeLK0eIf{$QbLa) z*ZjgN>}XZeuUp_DI0F&(4pR=IqT(HcAZF?p@NSri)QlS>p3xt~lGP zIy1ju?&RV*&wcR39xk_2_sNs8{ob3U-+pzm;Ov{VZSy-Tb24UGugLs4k|@XGD5tQY zH_<8bXUv0u=G{K?bZ{ho`H>R#{B;H8&nfTJrx!PBGqHo5SlgVgNbpy`*}Qv!@#!Jw zGj_@o@7^U_#KlkvIzKcC!^aX#8$~z-fqDYF2%lxE1cM(18HJdZ#89Xv61_}dFUQ4O zY6eL!qx?zqUzivQ=^98ta(=|#(lwa{OVw+PRxBw~#Kta7#(KngER@5Q;Ze$GzN^@Z z!CrOWVy`Yn2?_1jqy>3O!CcV_TvQ)bkltiwyUttFmJQZ2XT$aG8lNA!bQj*j`|Ll? zNsR}*0%t!0JG7#=u+Aq|A#Ac}bG+wYYm-FGmL(cqs>Tv>Q9Ng@)@Qn-E37l8%aW}a zqC#=u#;fLHa)KIB%F6tJ+-0Js8dn>;bvqRl{t_fFpS1?yVlK-yjP*=bD%?qP3puC0 z8bi=#o+DvxEvk2s9AXwc)fbZq8gTK)-cPSTeJGi8ivG(`WKbdM4Re+Z|?fW;9?+BmaH;#!AqcKWbUMDynd`n#dY?SA?lDaA22xd`nkzj=B>!v zL?B66K}0`V7Vw3UiZi^H4KmM>8dduMBn=Dq)j&+7%X%2gjEIp4loA68h5OBsA0QvY ziZ-%eK^*>H_(vXk3>lf4hwqConlPRu4-kG3vXc<28t`x<{KVwW z;Q_=WuOPg-h&-Ze^g$kA!+RfuCZ*>l1hUsyucwaTo9}riN;myUMtP!*Gv{?r{f&ug zH4HxqefA)gFTd<+ZNi2tEs-6S2-_1e-vQmcOoPs4;3nb%%gOTs4_F@ zy1tkcbZhh2b6L}l_0FG<(I#|cwsj6MeIu`E0Ev1^=BeOft^A6fYH)p`@rEvlVNFu_{~Dbyd4rAPgRNAr)JkA~j+7 z@d}IBnW>kgnOsOKl(BdCW!NDf@%%dgeh7Te+8MR{)V_LIEQO`(ANl0J=u*CUR|xFNpm zwaYu9>i*S@N$JnIW0k94Uh0}Q#`VkB)V~I;^l8x_oZrg^@rlxCyg-BDN%TSGhd-d@ z^Lu6waE_>WP8f3UE=Y>4bY3jW?)Mpl)7>o*#=p65JED}{f!B9?&%%09pd3SA>1e05 zbA&Q~Ng1S7DZ=K^3FdXXAnK<6_v(tv7RCEo(b%D8K5D)b@dOY;o^YOks;UA97l+yI&E1$WD?GP7Vn@_p1NB|mcdumC$i+;Gmtj!tpxf+N>P3jciIYVPgU`6k-4!(Lhbk4BWPz`fIioo(+zu zk9$R|6TKhhN^3MIS?ch4=WSiItpx(bg#jA}v4k~l`@9cQGTmE94n(|riyvri5R+K& z_R%lOyF#ZOQ3GxE^PVKk0xFnOcZz$#=Sf!JC6Tyx2xDysgN$Y?+K_G{M}X3|j1MB5 zf(6L7>()eI^7)x2Nn+}#aIMm{7?X+&@m*uxayMn-O*{&m=pFrJtf8^gMOtB&Lh>_m z0JKS51Ce<8d}wHegpTY^iu#whX)5(??UBREn)pL0By2R%*#R<)jJ?edPWFES&_?yW zwGV3C1Lll~zU;tE#Em`7V2o79FzOmXTcapz1T!HzIjk2Wi&)4Uo&0YEvONT+V4Vi- z7N*58;tHd#a6t|mstIZK2;vG?VgmUdD%0W5M~L)#hG@fo53qDI?9-J) zd{uk;FyJ*pjyhFE2aFovGKAnD@zG8@n-&0Y*d}Mj0m;QIl zcYP61KX~gp&$qp?$UJqZmlbkF6HH@3!A3c3ChkvWYS|DnfuqtqO;)wGT zUUjGKm6x&-bG4#YgTnP061Pi2AC{Igb+(8!8T%aDP_tDdYyxq$i2HqCa1Z~PES4&< zD2~#HK*=1(rCXB-R&Oz;UswS?s|17bXh@9@_;Un`C~;OzmpDZm@h_VB1rZRTP0D~o zIBT|f;*H||0UTOIh9b;KL{n9c$?7T$9>jW!!m>c8m;llu6^&8-_QW(8ogmL;FcFYh zHdr$8r03x#9m z%4UKePN3ZQJDba8!bqJ?df5TB&SG|)%6h@h@p}Zd3KKez6##by~vEE7&lHDVdne6 zeH&dOzIwb&I31ZN_#Or#nw})usue}N;9g*t$b`q#+TGa$%Rv&hhiWz*o+(+|uQ;Q0 zUpZIXX2>>xW-ET#_;*GU5Kh))$xWK}kRU4Zz}v6=VY}O!;*V!OqW-_ucj#+~iEU@xHdKz0+3chZg(%dLH8^)nMl;eXL(z53d5o zH^_bCIS30`;fN2WNX|1N# z`ZRRX;KmLL;MW8phqC7;)~GE-s^Y~O!n*7GHiDoXvZ?c{h!J6E`?+aSUtQWQ>9{*O zB-r%mMEknx0+Ut6`8b!ulb?~=6;>LK+yD$}eK0Y4(B$DnuYWL$1lLcZ9DhvUqa6H5 z&pLAZr-keXJouBM6D~sN`oiRHSkX-yPO{qyr4|Gnz+TwGKtS35{||n@q(J{@y!wbv z&IJArv+qI~U142#;mZk^?K|4vTGiV1C(EtsF>%)}T+eX2HvXCZ>x%zQe`BMoFtLB# zS&K$>>rZHL3JGSWMX9gWI)lkj&GS4?|u`9Xe)pxC& z0|65lv+;|SW(wi#4E>>Ee2kv)iq&qkuyS1R=0U1{SMA<+xg8Aibv5G%)(aC#=}Q3| ztQ0$dQMgE?_I-k>TPpo|F@38~xJz7U!d@<#Iz}I(KS6&nAyxaM!^*KT2Y4uZ@Tj|scDF_yH*WM~v07m{sa;VHF{RIO>6CEM zrLe7CQ4mc~m&!YS8@d#ns-2U-&*8o_a8y7uZ9C(nqc`)-#v*-_{ZtYZsR-|iMk{w8 z?B#f88k}zu_%v`47|3TdY(P-<8gB`-U_6F5W5_z)7Ull3zeOQg5I8c(PhjUGKLFF1 z$l6U%H%Lf`J`#x7Mn>9Nlw1+7O={v zMQu<4WBT8(q^m)6S)AoqB;m zvoJwl4pJ{u{7YdAO$8OpgGbP$R#MU`%s2&}aQID~ggl))`SzD--cl z^C~oPIZj;e=90>SnS#4B64wy62w4fkIE5!0Pw4EH{=d)$+irp^qb3X%)AD33iJ!}9pA{4o~gMNI1fz{u%%eplmQEoZG8 z9l%@y7YcZ^$CP5m;+X|kc)NClA6_Y)w6BGLZY_${Y;ok*w~LCdUgPD8Q-7u}-7QIOegd=xF0;B4KNSN*!M9`Gaq}HxCrW-NEJtL1`-FS^nIABE?maV=5Uj_+Jq0dqsL}>IS3)3A#VdLeiP~|4 zm>EP~wOhK04)~~(T!V9#CK;2--A~%eZT1%k%MLwe@q7oF=xc^Bl}do3)532sPi$zG z7@$IjUL5Nz_{cQoxPd!~Z!e_#OGtVW`&Tzo`Q)m1_r91YEs)4EN(_n#I`xw_fukog zd8KknnezrpYh?O zvMD%f!DMT4g1{(z)paGHp;+cebg^ShLU}8PKFJ||#`lL8oX_}v52UE&E^cyZ&EF^@ znfac6(D}Rx+xQ~)vN$sWTAbWp*P^&}2U?LteOu!g!tuG69WYVL`-{Fzz7>0x1=R?# zO~Z)KBiZqR5~lzW8jBtMN)46NNmt-bv5K1|kK0zW;9&{lO!MiR)+Y1@+=hN*GyPee-6XpJI4-=MrknXU-Z+(Di&Ox}+)j6ViArU&+-2OZ~wVm8W^J$idoxc}%k zMsANVS7>&JwG_kB41lxLa61STHu`#p+rE+Zk9@?4AQkV%a8HOE5W)^?Xwh_{D38=& z@{Wb;Fn)MU9uoc+kN<#MW}CtIK3{BmxpqUw;`K{6zq{*jlV;Vm>FLU=E7r8X(0%04 z&foVcckMK;JbKGAaC>F*nv7RxFO_Azm=b3h8kn{XGh7 zl$zK2zBTt!b1SEMaJ_|Q14`M56x&LAP?L?e@LyTUaw%2lhV%;r4u*UMxTLUnX_S^4 zL!XX&0!0YZ|BVR-iz0`FLh5(+&K{rygZ!UM&V&f-0@fh-%!yCuuKU*1$qc*Z`tH%pu&Qb^{O z_;DWKY{F5}3iow6>?0`O5sR29ujJ4n3$@8lHxNpeKq}}RaAvC!oUNq_*zO`UnYjjk zk_)!8ggd%B`ZuR~?_iMpgnC&5@#d&J%)ul1TG7Yu$)zj(1)w88b!2mhW_?Rl%gMh{ z|H4PTSEY_gO55K{^GGu3SS58esaY)(oR3 z{;FX8H*nRuF98kVwH=PsClP@ntV%e8xlW3KJ7udd3%H^;JKSZarKOhaY|9-l-TLhN0hZ4H|g~Kaz=s$*wS@8)nW50KCy2 zm;sK$;A*6zJ^B&*Nr0SCWw8OnhXrT-K$s7n=7(}M@;(f+Yd*n$6wt;n%~jrnO4hJw zx$UjE6VKc)rG*90E#UUXPm3iRpLXx2oiloV$9xz)qo-GPbpPY)&z>>fKOFUUzhP>c z^FMu6s?6Coowi{1=dY>XeE)8lbpvF0r*J?;?nmICL&RtE>!7h+TFZ>}@Y}sqWW)2? z4*MHs0f_lRE_<*IU7opy6HNcEG{7rJ0ImVOf}liJED#hGN00Q0YPK>oODBRq7*L0C~44EQLUXMes`K}aS0LhCRFU4;#!&CjcU0f zc&Eru6+lW%)Daa-6l{=At%y0^|28=BY9)s_ich=Fu4m$jJY55-Iuqj|y47_ZJX5+2 z;wQ7<*E!z2`CV;yv`s#!3pvnHHo`y%5o81tGY-Aaq3L=Yc8lY16e+>yW9&u?u-=e# zm@3z8d>5L8so1>qwJUNz0HKQBM_-6(>gTSLrQaK^m!S9Guxr0;xV(v}! zK&M7rVs(1KVy%Xi#84^H;-Ln&ZAETyEQXcS-XSYq5SEL7twWY={O;{bPJ?u6C3zOS zgJ2xkyX68lsMe&)#i_9JMF%S2NwuzHrR%#FKAV{`u4JtKkgL+;I86_%v2Drxexrm+kr0XBx}y#;*%fj5D#f^W(#iNjf{4 z>(Dl>Aax_mKNFmR)A64p5`;<P5EFlCJGxCDy}$>?f7SG+dj1Y)1-%b&jMT`5Lf8WO!CNN=eN{9(e~^ZIJT5 zFs2IAU;6*S#!f(0*p==Zsag2=0;}&Ik=pve{-MZ4&50>WX!Uxp>tycb&#qE4C$5}p z&Wmf>Ve5%Ik?`ctuM9r#TL0ay?r-mub+nD|QO}EJN?x8}F~oZla{f2>yPgBIE9RLd z?aA)U8RCq@WC#rkaA-;|NnXtj?Fe*y#0us#F(u4nE|A^jZDQ6?PvyMeWTjZ|mwi($ zP6D`~h*EJOP3<>(%J;&vQpO{>6z7P%G@MhXNjm`46gD0~{f|uS@;zkSeFIs!9b}Xm zxeI1)cr=fKT{%sQJ&X3q{W9HE$JfWi2EMR0f zbvxP^3TmiG(qoJ1$z=P?E^ZE|p(zJ%pKRM^c_u``X1*8%h^W}Is~L=paf^#G96@o& z4UjV7h-_JEiPt<1l(dT9m}`Lj1#&ZxQUY9v5g!IMq+K5iezDrNz5TVuV7yH50d~(t zXoV}J`972n>)L@ekXOKa71S`-i`nRyR7}ny=o@nIa%>PPmu!slz`WqA(&cwZ^dufl z^SFf>o2V7u_kMKQvwE;&%r(b2;TEz{Pb>@9@$fQflBZ*KMBE&9Gsur<=B-0m)MKvJY^>)XNH18>G8OqIZTbqk zO-x06X(*gqnqq4lZx&p@-U?R)9v1o!nraKcE)5zZAKaP}tymgNtA1+E9>QY9zH3-& zs-iF3aoITS8DEg)6);swS+7VElLQJ(i0y@-lpq}Luu@Fu>^itORB9*-1u=8lIeGC4 zX1&>u1tltQM;s+ma#emj!dUlnyn(Oaw(gx7dO|Pq7oj4&?O6pR$^oD^R#D+h4_HdIoY~>}*-{>O`KmWh|pHKN;e`){| z&DdMaSbcnd*Y*5y&bjXM*E#3D&-?va_Xh(#9cBhD1}Z8l<`>VO8d6cwEK*TXZ(paq zyd#AR?YO*9dmHL#QlSQTRxbxv9W`ESP*GLE8PDvlU5@EJo|}79QL(iD@1VxHm)KKL z{VRI$RKqyHdgC057SZ(s(5im=F9_kirQ2Hl!tW^gYqtFtS92+WPsz_cnT9#KzdERc znP@qj&Me+N?mo0#>iwzqcJ|Xk<}TGQvv8=Hjs2L6==DH;Xiu^Ir<>2^PW@&kkMg|z z{&V;p{N5~6Ulns-y88Q}Y7oK*uf5S{R`s?6Bz*semxH}_Z0j!ZLi5)ZLxz`EsJ<c92OWrTYKIW%g*hM7wmTnupe6Nyx zCE0LEw?OMU?^`(bTJqaCt*a*~$u~V#XtoaN{_=*O^UsY=Q6skQTW{X1h$xPg-4$#*aaMf@T>>eI1yKUawcO5L`gdX)xMoP26Q6sy@`qi zMQjsys$M4Q&b`&{roSZFu^jX`XZX;F0}oh2WdbL-zIlZpcbsHrh%2(?i(wYCl>LHF z3t7aU#M;_}V-DR#@15WRnJ;*sMLW=SYAa&FI$749=)2NG*dSeX0S}~c!Iv_N9dc}* z413+P`6yfi*ha?qxybaxuM1ew7RcP9^07whN&2M~a~Uu3ge8WuxJWJU8$3p^SWnjl zGkaqvSiV)rIRcPDKo#56WX&UnnWISNir8L);!H+I}fmZ0|2jL)kf9e3dLN_xa^x`mI@SFI^GvFrBjIoO73qsoWwr96p1 z)(>o6SBGE&9c*0*8oFiFJjPDpZ9r_u^^plFUPaH$ap?Ypt$ay>fc0ecnbDi!ig!(P zD0JwXnaT@qiA>!|lgDBGZZT__hEFuySHWMADcdOXot)lo>$1C=r8E32^NDFOhgE+| zv%djJ03*$r<#1rH9B0u97J?4Y8I)>ah6JEFh5`W%^YOx{`uWXBYd%yPm@;Lcax5>J zf&`B1Eh4l&h7r1y>89N=OX3ly$qms1(kxL!!#a+Mrj+J<<~z84jdkKI2E(o^W$gZ= zV@+gS&a~5(*G%vb(`}fQ#w~*$yf9SrI2W)k3OeBkab6#lE@{q5L0&7}al{})45D~h z8xsHMjX!*t-}Yt;XdEEFah>8L2uN!UE}Nc}@W>=P zPtOizvOj5)V3_&r-Q39aln^k>^nFNh_@oDCkrFdK$jeIG&)vV$7?86%o%++@^rhq& zSMf*wgzE5=`^{P5Hx2YQWfcGFU0Q#Yp)AYD}8|=q;9jhV`m-W$%Z}h^gzD5eW|eL z;Z1`UWjqLct5AS*05qt^d+5oqvV2awZqM7Xv3(B&6xAW*3*bs0+-(WINb&g2x4fnj z!~HGq0uBZKjZ@8=Qm7KG029T%XO(&&uvCjE0z!+?9ny3UChBEj3q=pjy>9<;UbTup z89UzhsTt!>33&TsJmJ(~NPg)}K#M`&t`q07#UXLQNZ{ShvC*{RR~muxjM8npq{1Rq zo)(tuYTv=f-s}_kiUzB++zQj`b|c3bsyNane#m9V{q7A2G*Pr+?QN}@=5be0lR!(PDOZK zmKnOhjXVaG0dYE2u;p$o>7sLp6g%F(8b9pu{a?)hNRfCE^uxAQ?NHTLWjSBYSE?m# z4X?58WqnTkQ~W2LjJ(&^wOgv%mzP!Uos2g7!;u2{iM{I&TyI>JFrfJT{LL}VH5 z>pC+jg&>Oy#4aVWKdfX*Y>c9*p8v?EnN&p+p3J7nGN2{@DEj7mi|>}npdhZ}z(6y_ zFq4Q)Th*1*VIlkvt7@lSOP<)HCytwv>Pk^xOyRzju2J?m#oXZ`s zzHAu3VblV>`N?tKh`~X?e<71X2Eg1YQcY?#j^|Pa02b@8X5qnI@StR^oTwf1_GF|i z$(4EgecentlCrqjG0L|srnh>kJ6bv22B@ue?O%c0I3k6O@8eW9t!2l2{BL0I=OQ(*?mz27NagoUr;vWu z19)V8DGLL-xj=;ay62$^EmYT?Dqz+N<1Hr1`gGaZ=2>*|pfBllxMH)+ah#P&_Uo_P zzfIYVmD>g@EBYdp#ni5-@lut3iT&?y;vlp1F28HC;V=G24_(=Y*;TH*Wl;DQ4&eS> z(-xr$f7hkzal2*yjXM0J(XHMEro9eKP&APgN@yva*F zVjnI$Qv4~-(1whO`W4DY7)TaRl};djG{0=JX5(9vky-tzWoe{Lz-v}!02M)%RW61_ zbJ~TsP$<`KkzK*-&k6rn;Rx6&7Kis^K6Ut`T%h*ZE*Kd;X=-)*rK7c*4UYEc6M=D{ zpS4|`@g(3lg-8Ym;glp~I1p>AJ7r8gC$$biw7h{-*H?^w^HU4?yyu^#%7^pQEpX$fJXxzB5SsPukn*X4L3e(^gcfn6FTP++^Enl9&AVVtK3#M3#9SPv zr)7Tg=_b&mQ0-k!(zr>B@6LJ0G8~toH?$k?S94O8Jt z+Wf^v#M7Uf=D@wroenTIpoQ9freVa)6|51sFR{6RU0nJj(Eym8)sS zhVevuE58CuY}Thp+VnUb@YbBi^WlOyrls-F1=8bZDgHfsp|2=S3n2(Lu#otZ($*Q7 z5keg&>*ik3VOkI0EfDU7@C;8dCnX@rWe3_Y|JmulQ+Vc%K5P1##g{@d2z86Y+abZE zMR?kJq%+)_Z^5h}LEkpOHMf8wpLj}eTvf=?>h;7%8?CcQFJi4C?`CjXRhiUA&z4`%6vZH(BDc)a9o7eKoMc{5BVkAjVQiB9Q1g}j8N7jb&B zji#|LbpmQkYM9~$aw}(V11htYB8Rj3xUAj`%ilv+=h2XvHW8px?Y$lqF0Edb>DlAx z-V`F~2cwgyscOG;@jlhJU7_eg!JAiXsY74q(X9hE1;z2_UwY!#g!t)5^zW&kf4Z{A z;j!ZF0XU5wBjVg&!mJ|X6J=Y1Ol?(%B(W)JYuLnk)~4z{h6h@W!jg7{{M>=#pS84fC&ZViE|2-g?U%s{x`llnwZkZ5I~ z)pbfQk-t@I`ab@6DIgQcZfs#A2}Mm?W-f7NrrGLK1wfs7qdfIHQ*S-A2nj-F7m$s( zh{o~_7|$}F{<~|>Sr@-J389$=b!OQjtP&LVf2v-I%V;$nz`c1%`s#i}{UfYo*#TV+QUy( zx-hwJ(3umYZ(rUo62>%2&|;CZUn|knS+-;v;^CsKn*nT5eS0wrQaZ*;N!yBX&VWi( zvmBkWF~6})U)Gto=+T9@+&L*UE=J~BoXl>hvQ3v}lZ7;$F_L($$^f7ds}CImZ*hfN zoVK}2g~U(1)4GVIRQ&@cNNHaf8vc)MbGr4bY_sFz?1?3FbFD@p_ye+d(6G74tBs<>;k9f0A2oY!5sMa=b1gl(9G?4+(!(lE(U>aa5;N{k_A=89>wqNjD_@*`jTJr z*b?7sJnA(--G%+?%LbMebO-|AI#E1yFOTwlRANEBsb~{99Y_Rj+^}`v3mP^hY*Y5- ziJn8vP7Z@U3S%m4LBm^;_qWKapZiZ1E?JAmWNRr(oAq9rgM(`n!PE?PSILJ;GhQHI zV6tJvS3QZ9B+|OupL<y@So;Vr-K2F^;2{?G=G^x`t#OoI%{ ze4bM|e%MZi%4NR@(Rwbxh6}j$bD?j_9MbLGj9O>Q`T6$|Ua*F3(03RsKpIu5tovzWO`sLc*38S$t2 zTbKS4;IfZ@8dYHbnm}@O+aads`L=?>s@# zJWn6gc7{BzN}aLyR?1@E&zT+axb4b=$Vj{FU#xi~qJmcv+ySCo-ZW7FLzwW@drTNx zkKzI}D`Q9*K@*43{?Gn!?sm-PbQP>PoHC@flQ9X-4!Ea0xlEnsq{G-C!XoLwaG$8wiTTS&K>>56>)Sh8%K>)_rjVH=#u2mTKBmcUe>w8adsGss9Z} z6o{rR3#1l)pz$D;*?qqoE~TY*bIl;cR-=A3vOh(K9d2YH_4qw2wR78&^OTSamj7zB z*g|c9uNv1a$<8w<`DVhAfPy^WONUhE;b4H#ruA-3<-45nxdVW?dj%RdG?|Pti<4C~ zvhIcO<$OWa^NiGqUi^0fQh^tjSdm~p+8 z)0SM0b-bAL@ezVsx6W>U~nC@9~{!x8y8L)gKDdSIkcgv4?Eds3pUEfr@ z3Yw4xytdrheRA1`FGeJ=MlC+~+vwZcK%p^yIgdzop)oq(jH!yy9oiUzqpEQ4f7%VY zf!t%(%w1Yty}`@q((#u|y`g0S%>_5u_oXZ_dE|b9MI&w;nT0x=Ui|&P z$7ca~Ru{V1TdFEpe(t`eja{s;LZ4G7xksuBM4XMBM4R~i2r;q%zdFhK_cN$t_-E6E z>GZA-XANa9bRsmczBM$U&hudP4n|VKe*ht@d{;JPfZB%JO3G^2!xpiO)M{RzBeghh zB28M1hQ1G9idGmH^j!W!_u4uuGq1$j@1l`NSznj7IS>zBpmY6f4y5oIt}U(ldt=YL zmVr~_OcIW3YWP`iCQ=)n8id_ITz&w){fddSBlFlz>DcP{7FtnyJ1jrjn@G?6;H&-H z;L)frbc^Qmt#29DY4l97iZzLoyOw^>lu^sEu$;AYqjf9Kj+=U%7RV8p)3?n3>Si2P zWG!{81DRTqhSvQQ&R*^Hr0on6U+1y9IDn>m+GoiE({Y-|VP}X7OG6wY$ky3|2juQj=)w&RTRcg!#&|!ZX(7N7uCRQ&U4n+7^bxAJ_gwOw8 zEG`s#@~&jJPBY|_q55@2HZEV0IyUUqD{8G3UB z^twk!BMW+(;@A?$N+`+*5-<8{p>AW+i}$_q_6pmr`XNt@Xz2E`%H0(6eFde)4UwK!;_?2zEbr=7A2=b4mNw-^_*x z)&`F738rSpFUV~rdu7AN(x-P{?cOK`tUk8#1nBVvU5ca~O|%v{2c@%y%XPiaVW8{! zm92a?1DT0g`=c+~qaCR$f<-evvWn3Yh(G?@12lKG8)U+%dAU*%QMA=jw@mkE6gP>s zCp+p{+{S+?IZRL7_1L?LQ~t2obE@29+LaxS^Lk-*HRA7fr*Qu=SMAmWa-XUCmwxx6*Xyk#@K>|ExC z*leBf+V$$tPbU*(zvi{niiWgT8Z|Qsc?KrRWI_{Ggnq5nF!xvrD$4_1VM^bLz@{+KA z*BgONq}<3d+xlGsUv^dFbCSF>DzG|ej&i-2uV z&DA6mIb4~o^^&7G0P$!kg6CnaT3_c)tI22dOY+&w2GemSeBZ@K(NEAO+dTG)BqOQ^ zP+rhl#&l`6*g)r`hTdE$zisf@1R!cz2B@0!I&C8nP*+@orwGCLn_;yy!t(E58aV|BT{sB>bh@QToT|E z8i89LN}C`k^`K#eq8f`_Ew6Jxli0%_NbI~$qzqe8DtjOo1jIwRyhhAaf+dbg5ao%M>fBKRn#@O_U_s8INI zezl#+q1DokHh@*th+MqLQk7E9?Vi@F1Poy)PvwzvG|9h9@t!>BYj}{tyfhPP1)5?_Is}Q=zB)z4+%Jo^fYivQa$2T|^oKTrM^)^9~4=Q{7 zzD56i2n-E;_FMIJFtU*o+XB)02@bK(-3%%ZU{K1t!fWI0w0Oz?k+JJzEx>6471XVK z!Dif_)vit4l*gt_tiZ2gYX}>YjS>AQS`)Igcp_?RWgHvVAf^3UPnSDZ5s+5+B|k41 zewgghYUD=qP0nCeq0iMIO2)m0YpF*sV$~f%1I}ujFm4(TtpM9L5`I*(bsnYk^92u< zw2~e1#Rr@wI;*m;_B|$szbOk1`3i zqW?%8fv5jhE{uNrCfKmJH;K>qof78PSox6F5Q!xeOr@rV_@wNb=%%Dt$9gL4;_&#k zD9~w^bclZ~@}s>+Z!ab#lic}nldImf|7ZE`D%Fv_m-T_`3vW5r2>C1O+tK)`jvfbG zAgF<`>q=hf`SDE|3AUfe36ANJY2Z)cW@ipTH!|TJOMow3z@chDx{CHP_J@+>_<79-F?pPE=-E0}+`9qDI8VJhw#9HtyVdqMC%qQw%k-p)c9XGKy5f=hK(Sq`GK<-s zhsQroHDh;|=CHIKr9Xy?4x#UY-Z|{$2gdt%LC#49 zsoQ@p7i0@PR=3uA_wa4Mg7qaCyS)txFD72!=9Y#>;&6?%bdp_6#p!xP^0WT!7F8%3 zUOs+hO0d|pb;=~iB+u}(?vG331q*_?EhLv&i=7qMiZ{PWzX;B%%rrkOjU+zrYA?)`8*fO*XQ0ax)k3| zuWkg+dML~$-#rUH9kT1!f7$cG#}ifyX_KwSUX;Ez9w*Lxse^cW~^H=7?}S2i26&fg&X>i zON6*f_9hZ&EAURG=!`Fpwtz(_4b3HoYw=Yn z^HeJDE9|R;N27Y>Z8z*scKJvxtV-w}9Jg=D~Kra&O-vYM3$MtRb zJ0}2Tb!V1Z-b~7%E4`ZY2-pHY7UR%VrS%_t^9254&xn>55+p(szI(%eQP&~O(%0l%R842O zTJhAllV`-Q&@=Pb@QVYZOSf-1;8*?g$>*O#h1z`dIF9LfpZgcS9zl$yJD4{E83Al> zRN?HmPE_aF9?b7lkl)X$Z0)4|T0d3fIUrse=1J?}d~)O|I2syX`P-x8jy?|rSHFv{ zI1-z+I(R=3!pjZ6>uxj?u>93~L%Bt@Yn8vMR%P)h_^SivjEAse&GkX;3$vu*k#Fsf zMdbw^8T0&((rdcV0JG1{+HgXxU`qk#TfM&a12PlknAhvsOIYSs(iQTnEY^YLpT3y9 zrSyC`$=o^ysM>$*wPp8xqxe8Kv{3$Mds&l?R`4_KWIjQ0+tauFamjo(%Yuww_uqWg zFD^(!Lh0CZBK;Sl=%hF(<=vv^&DeQTW+O&Mt%hOo6#_IZ>k!<9fWx`ShKdgmhdu z^6PSg8F=|p{Xhkj*C&yNcTs_E-CGTk4n!Z!e#UM+2GX9rzdNDo*8IqwQ|%HadE1x` zFL7Cy*OYY{6-t8wKqqU)K3obeajZ#|slORt^L5LW)1QF?24}ySD0%}Q70Pms310do z!98ER#!Q9;FT+ANH7PBPwN_~Cxu=I!9R(+~9A@b8#6y@A1X}dc{636IBE&SQcz@$p@vb>!Pb=wS1&z7*^Mek{d8wv)Z{ zNrYSNiqDN6T=IcDmMk3sDu~9PqmUO#g8G{dR>o+mJW0NgcLq(qyb_|0Q}yRruV{yx zQ`h+$OM}s(eJfo`9u{BZo`4hV5dXNyBE$k@3Y!sCw){}HJ;G?UMfe ziSPF%o@Q(4w{GEITBG5KV#R5Q@#4owTJ1v{+?w*X(PFb4(cshKU2d>R9{EGzU*68 z40&R#Lt707&Ef@n0```>GWlR6W_vl{KkLM8XykYReRkZ5V-M^xFqByw?xE3y=N0!h zYoxXKs5^0f+XE5e?VOQ@@xC!xATyb#Y*M2lZgo?ajFnTdGS(5^JCrr&Bo{8|_$)Td z{F*s~v`QU45@l}=jZtFE#y$eZf11}CNYzP2OB<*Wh8<7o%k${|55Dhxk| z7VmURPB^K)8d^xd2m9lu%~H}E33;S!p7{B-;z|`PYAVQA`;Rqe zV?nzCnNYlNIgW!3td6*O!r{3R(KKe~|Gr-N>BiwtB>JZHCd=67Q2>Q}$NJJI%bTE0 z?onQ5Sf|4yeHU7s7q_`7Ia&lAuJf^Y0ju;LW`K<~#k%ydlG3$nxUWgqKPtGrZQS}P zRKnXN+VvmYy6tm>!nBVAo2^YgB;V#jU=X-j|`^bVBWMYXB?Pm6Ao9#;Q=PEOO z-bS{h&kuIVLaezj)@IlLC}M|G;H#3(6tA_H%3mJJ9~|?P802x;FqUsAO6A=ruQ3z+bE4HvM_D2WxA|9=b@?qBSj&vH}%DYNRnQe0+g zI{Fnxg_nT|k;HT(+jD$%^WLYVXT8D*yF}CkV7a+k7yQChUtG-Sybq~W4bgf*leqrh znzW2soZ=*4gP=wSFHhcrfTGxuuasWozPyX?Yllser=Bb-eirt+T?*7w0UD}!OKUAw zB3#R`7<*f8U~8O2XlHMatUBLKGT7v15juo)hoFx?OLH+JktNZ(_ZCcP zr=LGT3tQeV65|;fcul!*d77RyOuK4rlbbQ0m#)U@5)+aiw^|F9H@~Z&x3gX`hXn$OqoSDJ2M((kQNI!(@P|=H#K!+3;|!S70To zxQwXv_PKD5!gVcZ`rgD$kMgt0rnS|UE96^}?!%K&3s=`zvy2PsIN%|#FnN0b2Kb9~ z-FaiR%Yf3R`>udTe4c5I-KC~!4PPoq#oK}>vYGx!O%Z)MR`9w-isSJOWOZ=6D6(1r zpe*m%KxlnP=(21N;xk+S(ooH{wAPpE%k!SORFVpGqM`Iz`HM?QDsjbwNlumfC&s?2 zKiQvFrLG%@UG%DO-*50*vMcG&a;)5FkV|l@-jF;zt8XN5Y~tJAXR6XmziTsVi zp8ndsJ^f)YYO)ux7qyscK*%rB+Sv6Sq)+@ZBpo1hH<(paf75wHraN$gUKhn76pzO16=}RcePfOHbpX+xg$N^3CA4!A}!# zjiq7L*s52nzS+hz;BTJ+!Xwl2iMV2XL%`(5?vB`4zzbkXD|Rnuh>z>=WlF&_y6wMZ zlsL`)bW0y?*yu;zk-a*kDdNug8DNXr_sS3QT^e@ErP1dsxX_noO)Mv zpfzP||2yRsg*h{?T?8+88xSg(eembdMM&*e?xL$)wubf->ZWU(QJdV$ygQYg0ZBdA z&sZ=}D0|-O4WY0kktK5&p<7~vwJTWYl!1TpPOneP?km;kl zF5A($hhzjrjMvisT^2R#txF4I!AOR%t7Iw^d90MmCtEUT(I^f>P#2BYI^YE1Wd6R>wN;CS750z`S2a7YJ>K{$L5jzO9-_msi3 z5>W;%MZm(M@>*yS4Ejw*PuqoJ*g~L-Wzj1twvfMyzZa_$#p(B!Y@jT`Wb!Up2YY)% zV_}sLWx6zU+eN>B;8pMG6CwupsiFqlbR6Ph!wTtbQ9+S`D!>v;<16HpB-8(n_Cb-W zazj<+KiwANTY_JTDl+zuCq|A5^1S_B0AD#hWB57ep+0l;MIEtNh6gsL!U9G4|GPrQ zv1}H~r|x&B%dW=9wSf-~z`S&D4M7zXv0Sl;tQ@WomOxdtYsi|Qj>RgO0hfJ~nvvyo z)`7{3n>Eb+HlB(y2XQwUd~&V{QXxool>pgXLPC_uv}~|ToGHpj%z_48p~&f$`Jj;2 z4gBHmYa^(m?Iyieo8Ue1(R@8y#}cDQKnD%+r6Ft^q+Qq%5PR~hms!^@@C9tPYwpa< z@#03SdRu(OH`HQzR9bsg_9{rOz}eWkQieTEqV^e_lzwgMBqcUxdL!z(3BbsDutHz` zY1f(LTyoFpf`i}GFdV^R8zzY$ra$=g#zvBSx&^24{8~~X8iu`d?mzXkEkuU zF2GFTLUdJ!uwa`5Gd`KOgRBY2OZ_2s{P!1r)wdg%k{ z?&wJ&HGPj<6(V=cav#F)uCwc@J6NRReDq|4meoAx9P#YI z-+hML3)kWC2;2sk=5xN_ymKen@NdWoiM)ly&kgVi6jhMS*sLF+UPdUlQWcUs16X1Hq(nP%%8}YukICj zU0Vm~it6*$zGX(Yt$9pMsA{cdwcZ|~%e|3F%}mq#le%AM=gRHk(LY=(R3(b0-2^?4 z>23qgm&C&w>@`trp6k{Z<7pbA(lFFo>n9_*L9ddVI{OUOfO8zYsHsR#!IPS21bW9} z!?BG&Q}Q<6_&$6XeHP+M^_*HL77Gf2rA(bcF{64hrY3vqH`Frt{kOqG|i-ii;$xW@^pro{$+JJH!hSWByp24ftEH$kV^-8{^HGE%i+gE z!SwZ(G{i^U9;r)<&?vvfB9iPhZBU4a+>yUnYn8Rc!6RuRr9Y_NeGZd}ty>u|%vB7a z@l0%QK5S%JdE%wW`MPfF!c+Pd-&$?#8|2k+cJo_Sn6P^%(uS}r_eA?NSr3}RJr8!B z2Z1{&+wUfSg%XO_zWJ2_UsJeO6sd}dHSmQ<_P}0_yMf%Q5V;Y02W=pA*TW{9P(fO7!NhLZq7Xh_Z zZO;7c8EpBM;||or(vQ8Kl{2ro$EdxCBGGP9C=8+ixUr!D-oY)unPhV)RPO4N^=4Y zsFn`-^gb7AHCP`-FW#BB+o@*h@HpyiX#^%i`W?M)qf`K!IFXyKMgW}zpaQt~>QnPM zEq%|z2iy~aFX42Kis_V2U7VDu*ptij>GCpYf@x1b`x*gQWdoTnjUn2j#KT*}-oR!5 zPCDg5=IzggSlwylnW(o#EDwURKf9qFcM_0FVkq!Q z{k8}m7reeZ^UJ$~q_rturyXZh9D=4OmM>x|>d*!!Jll7UqVzgr>hQ3|s%nJ^2o%-XkGyHwu(m%4B$=eF4I0f;glF`Y1 zQuyfeB;Y9SMsk!#0>VUEl3tMmE?@wcOc@<-C>T;(bB|qfkvZtSuA|)q0Wbq?u+HV9 zA5ITOd^*lG&qLg@+6^h-rys5K@5nY0{y+w+=0=a4Oo494txc*^PD->u>%~khVe!3H zOm=#Tj)UbBm1{e`gozsVh%4lWUwqfZ`|$-GGZXT zA*FO@EvV))Z2X!j@yxmOtFKm~nLT>TqgAlV=<%@k@V76Q|-ATp|$RLg)>@1#v-a8MNmmjGkB^S(r6${DvT&5bO| zEl-#<_SAYU?%8hcyckk0coH@(sPT+7^hoo-K;__fvI8IDx7Kya`H5SKza@I7r>bV*QH9dllS>~){1m1|2};Y5`9%rmDM>tB39d14)W2RYUW4wT1aic52C&RJi5@8-v#67`U6>u?wrc zqcpJ1DEn`R;>3bEiDx&@n1eu(cSPWm3*IPA-{)lR;>A3sSTf`yzPD{})9@dMOE6&S zGCjL*9#wQ=;)biDoeNGZAeprR{WkqxSb_k__~rE9%`1;>6D@Ez_F}veTc+IL>@Y^3 z!WdYTRV%s%o%-)jaK@ZN<7Fc%#1QJvCrpk+5nQlDf)ed_Q_jELB{Em%hLv>9+RL6Z z)4+L)&%V1~Cq4L6Je=e9NEd3r*Vipk0>DsZEmpq{e49&>9|^v)5I%9}t9&m}dSSx4 zBv^CndF)ET3CEQK=E6?+hrdEIe^p1tD6Tdlmm2@o{pTya;I$CM;1SHOu4Q|O??fYM zqK6D=vHdsq9GUQb?+Q6C+VKIn^z8Lq)tYa|Fg0Hv@blYyUn_#?;rWb_Q| zGwot)61B$YIJ@^E&Edzicgb#5h0AHZn)sw`V1JR(o3bF0paoJj2``$$Nq+jVkvX`u zFK>DK6EcI(d>j00Zm@7Wh=+xa=Y%Jv%q_<;?1wuh_}g$j*I}OOh}-}6B;3)z28S}W z(uV*b*$|k^nco&F)n-?wpmj<%t4(+hMGYEMN{a+0xgmQ0Q2qVlSYzai{mLxba&UsP zb-;xF)(p7}JDBX)T@&ByACI8T)Ll%cI?btT{2xdIoaLWAuIzS#_DT1UUxLjpq0mV{ zpb#hklgn0IhL&a}YJA!rSJ3$pB950SBK9!ng?ZKc<{`LcwkURl>A^t;^w|&rnO=eZp z;GN8OtD6ixe9G7h1CN>9&jW3jGOA}sNWO#RT9wBKpBH`I#*JjcAv&OO5b}|$-}W2@ z2Gh%0^R6o~y0tZ2Tfk(#YzAcgn6nB$nIDP%qmxVWW^IEV__2r>ou=-I82JvKDH`yC z+74=3{-okaYIl~qB>B**t+EOXJw{n5o~cO1!jS^IrRcU@2XspyR zv{yoPG7ws32{H9`rmjOmt6cQ~!#8i{!wZac!rr*Lz%R2w%9Y!p1`tg<=_}J_LTzoosZ2Dt$Z7VF79GhLbkM$yYxa`Hc zj6%hQ{#BwGn-|+8tX5uMbDI500hw#66-d{xT*{gWBx+Ydv0_do$2ZX*q)TXCCq=?s zd%b*mKY>IQOvdQP4(}YU_9*JG)GMSr1^S5C(sXwMGPoT}_JYyEa( z{ks_$eE1#bh<@M~r?R!>H#W8Dq3|@il5?S*^A@3H$(N66emCUD5B}G{$0IFD+D((| z_9Mqe-w{!HT-d5Tj(qTB6d;}L!nk2>JbtUjg>i*^<@24s@sBSXmgBq&HcMRl+e^Lq z0^ZAk<|tGwR(l?y`fujIS*8~myBR0nY4OO4z`7mG&SPM%*tIXbJ+jlAo1%GYVM)t) z1dY1&aI7`rqoj?6=k({E^D2SAz(jgWC0@6K7MX4bm zogxxrbPX7YlmSd7r4i|l5z>MTN9TZ%jxjnmY9oA~-+9k_-gEd1#(kdazP|CfN2Apd zN@uO|Nuke+REk+OIh0j4DZEeS-_&;cH$TbQ2*xhB$vzX}wI=zTap1lH1~C~L66nb1 z+LC4MV{k4*Zj#JjGwB0lkNMD&T^O0oEK_Wx-(GK+l_lV#|7Se(mbqcpa$Zm=E}S`}30p%>us(AoM1xg?!#i!Se)mW>}T zsx+QuDLFqrFPB+o8Rfvi!yFA3Vn}nUcPIzH33-ml$){g z4E5lUCuhr zOHJZMgGXP=c%cFGL2U-#7_&0QLM5uigd<=`)tgy-X-*Wbj9-=hFj&EWeT5aCk^D{3 zbAOkWBpF|@)Do}4KI0q*=!yusr1c^FVR-&Tj$}&C>3!QZ#!5GR@ri|6O_KH)jS-2l zPXtbXJsYwF)Vk5xkJB~(8dry-dXFpoA5gi#7a)^~Z=l|60#TVqCn zlPIVSc5u8_sepo<*-F*ZL(Q%+YZo61CZ4l$&`YOK{Xla=7Z}mI&JIs4|E5Q*JHhyj zTRbeW1TD?L`x!6@mMeR`Qt{pKOWSCzF@Kdu2x4hg8lxiZ@e4tHKINd73q*lHi0y_x zNkCOn`vxtkWcwLPRrhV3WkH)EeTxq~R*FFXOoDD6et}uYp8t*^dgmKIl{kR`Fo!3SRiBnJEeS0;#F{~(~fTW~!%%-M_K%$(k=RG|FO5n=;5YR=7r|BPX zl(CJR=n0-Byho_fqj#z!1y=NTqzS z$TKeE!nCZ|MB=HBmGd2eThV)SUcZm0C|*k6=5KPUG3H(*&0P&F{LEhe>DuZEPX-FG zv@~tttu5(EMyh*v-fX6K3HfEQt{>{5mbg}rk8*H3)%RKW=D10G3-DiK)C@tMoIAk; z-6UJmix^LFL^*10R9LHMjl@y?gJ4MJQoh%2btS76Ji$W2hPnS_N%g=`j>dyhy6?@@ z=bUbDGdIrx^#&!-pqr%*rAUe-p*+K*9D2By|Md@EwBbnX-C1rMu`2l=PsZ&df&E`s z=3GMrmc=E7$}joUGu8h)XeSa9uF>A*5zTseef(V_>(zo!Z}&`ceFq_&Jaa9B*5B2| z7$L(o7W4JUY3}mEVQ-acVc^K{Wo6dKNttE|r$}JLJHOEn=^*3OePiiz#$RR{!X7Ip zRVVPYMZOLFP7__aYSr~<<}>SCJE=8=#;3guEdqYeay517Pc$F%(=bZ_?q48MX%R25 zG)yBNK%Lz>gTrsA9G8!zYDkBR`vJybP34RKMx3;uW~))?ld|e8@PIt*V9b)2td~ zXrE3%{6rcjw9^U~MjPRSpCv_&EB{<8+}F_vD_t?bF|ZZ_->pArN{`R}kJO97Ekoy3 z{(hf6dNf+WizHS0lO`6|OcHP-Nj*Rp+rqg%n+GP$Eq%W+EP0*Lw9Q7qp*VzjW_qTW zu`+YUt`rZcQ&4znH0N7t0GA27;tGXPO0BI-`uK6j+x64AG&?+1gR%xH%hX|;I+HHT z%Cwb3gSQR2{8DG0DRZ#*0~e>B$Tq|(5I775gx2$6oWZs(LN$~eGq`~;dUUMKFwdH? zo`*bWR0V#!vc_fk<{M@y$e?Mmn#-(5R;!H&--5-lQ&Iz+Oqk6`eA zgRS{+YUk*`ePshv=@k$l*PrS`RdL|9FxZ47QIlK@$6wE~>(Wg;A$`mz`gqb$Z>nM| z$;KzY`f&XVk4pnepoR6NW_Jiy^IUN!UGmB{U6EnYV6T0BnD)U=BBW5NY{`PSy}8TA zz*||<5&+~;1(R`qUfMKE{SGJ!gxD=e#5G*O%LV?Fuy(N_8ss^t1cvDbT{zl=MCtXE zs(6On9NWVQ=q&7|t;!Df_!|lXmuCs8UIt{`v)sCYoo`w>VEUM;JH(O=uhY8s;b>LF=R@G%;rZpP5F>Q_#cwCXTeyWT#=_5n0eLd}S{_U1G#QqX z3-|h+-i4iHc?13qPUmH&+w^Z|DjoGN_)*JKsf$-G_7$8+V zv0rKL>|`KyqWJHDE`KHZv=g49EO5Kv0iwNdshQA|W%zw48-H-^DKAky!7#L6Il6@y zB-|N%#hSkH3%@NGXkh-@_UtH~V-Be9Dz)zD!(6M}_HS&i)xre4m-x<*{wn+5s$Y5K zxXIJ{p~?V7F%j?qrUil9>lsF2RI=`67!vewql%cG*{0n=aLQw0f})a5Q|nt7H4x8& zG2zA!;@Xp*2c>QPKs#iDa3iAx*Br^lzn5yE!7$Vud$Kz;DuFaUPaR(8Z#!H+;mf^v zp`{}|D~0g&7wMZ8Y5i5S`|j)V@R><%)7HU>JX)yWz$u%M_(z-B7=g$?A1Q|%%?C=P z;4QNg8_TDp)8D)6^+aGzF<2t)Pe`&@AYiTVITII|pLu;>+v^6G(WVUGD7j~WIpx$O z(%bQA??DkWeo<5M&q^#;V?;|NEvL>SC543xyvjcv`SnB&a0x_|h9XTEWyEY|p4A{O~F}%xW-pUTXWxz2b4OY!zDP zcGq(bl!Im`eCyET5>SuX+&!(0v}aTT(so}pT+m_Kl7ap)scGzfwLbbf+C<08I)p`5 z$%??!#=|{aXR~+znu2hWTIPZv;hR^)3a54@c_a^F9c4-Yb#AHnVxyb<3Fuq6DQ=)w zS^PZL;mMk?tptjIBy^TyKQgq-c#W$t?W1 zc$bStC@x#F*82xlq_Sof<^4DTH3<~(9W=D>Lo_<)l=YYK(}6}d=hjZn z_z1|BGw-}ps93F1B z8p&JLy_q?^{YxVMCb2Oaowt8Ys6gO%qW4vgom8z`J#X8FLK0BhIy$Dhx$xSHD)X+- zbMa~046@1MNyh#R{XHhICqS00wKYe2{< zWIPzL`guEI2(gkf)AVx9!u`N9WT3{Fxq^Ur69{^h=1#~t>+wToHhJX~9qiq!ehu9G zcHJ^@PW^i#dZg&9r)E!HlvWzF>C;Cm;4K#?(l^}PJ;abX09tH*rfqIu%zK_Iu>#Ln zZ%)@TlkeS_(WlJCcFI6l+~2Q;mNQA$=UG;^Ud#Q_UkWWS{{HtJ-5gj76_7#qLi}Dn9iDLD$Xf*<#;ek(9$JqPVIwPMw?tKdF?aXqlMv5<#b81 z-^nsnKg(LSN&d{6H}Zq+Y`FW~!pgVuLzr|zUj}+l_oRhhQLz_0*!$Xo+)b-Fj1eW? zS(&Y0_j>4b_eUaofKKkX4(_|ImFRCC`9flGEsw6?#pO%58jsKXrYzRyCPDoS{nL2> z=LDQC#P6qKNW4O&rU5nEGpn$7=@*{d1<3@S2WcM;^5SHQ23*Cn87v)p*0AC%v=e6^ zQ3x5_`bQD5a5my?#q~oHuP9I4UN<|b8My8p>BZN%-=F#Wm~y&0`)@-|ex7H{*d{>7LP zS&jXb|DsuzW8lGJ9d0CWBmdrm$`n;zlH5h&1s#VhtR%1D>RWG9Ia;be^BaSmEv-__ zvD7f;(JgCFMVCDd_wS~<)Ys@$SSdt(SnRcGutE~hcy9Tuy+9DZl0nG6G!+-E#mwvD zz}43h(6>!F}NmyfqFHb)QU9W%4%!oGVBNQkRanAjh*7tjmv%@_4^ZgN~KES$Hd3S-1wR7jZh@2qgtCS3fG0 zlp0((Sw8wRp_Qh3DNl7UxUJ>~#@D7~BJ8r6(oEJ0lMDs^ z4m*A*;~{+@aAE;VG$k=6bk?|@l&3-}pH|xIn=qjlg!6h#^I*U^uxzh=8sX#R>yoOQ zyoJP#BnwD=W3Yh0XaZbz@L#4RPR7^)H1QH9u+DkHket&Tn zTD~-6Xp<8cF+FQylhMhRrE|E2yqI$l_jE{V<$*}0;N2kKOd66e7(tV1)NpAXr*oHo z1BK$5nc>MjP0>FZc`7o=6Fk$S@MjrjV51*WXm-?0EVn+H7ZU`k0-)`EE6JEd@OAbG ziX(k_&^+DBeuhYwMtjiOM-jd|d_l46UN3Y!xX0Uc?shHzd=W}h=JvteF@?6zsefbw zBqo(w;TCi6y!`dgy`EHYUlo3iIa09(eEMBlnZo$``0B?rRh7UHKBlPc>A_Rv&yV-o zo*RIE^h`G?EM-yWDEjze-Z|33jIH|^m4{(fFkjwSVIAzS_tQ9Llw7XlRmK?`h#_ao z%SUkil(fn^-D?`5A=ZA3hf|b}DS;@a3$h$Tq{@S_izZ*cvvwP&m#5iS>ir!&|RnS+BHT00W{Fw9u)3izZ zRdUZ)P^aYhmbc-Y@izAIP>;!sP@w54rCtM3>@x;eLo6lxMzK^@jtAI`QjX{$hz6UW zvD7hpeK3L+rT;AUReL~MW1FCp*hHw$!i~xG>gcd|Qf*1JfJxZzIw zn&j6dJhU76lI9gmWfr^>Pa0%A$)llWmrDfHGu@H>29Q2rY3DRnO*Sem6r@V3W;=}Y z(Dj`-UE|o-nHw=S*T{++@~67SdmD}0khop&_WZ#u)x+tV(WIVt_U@-Jv%Y`zIrzX< zkN!*=V?nSj_UIkSq}iBm9+7}Ci65EiF^ZhQldb#2qjbsAnZP4owcr@-S?D-o^0BNMdGcZM=rl!Sb+v}Qa&9S&x?cU?{R=vPs|Jl zo%pCO_a>~s4z1yRaETPxDptIz;2gjH#*sqVgf$s64resTZ0!#5xR8MRqacUu*vFWA zsif4l`V=K?RD^vK=#Y4hoWNfuUn~o-YoHs^M z7E+WsU2=EtgY))c*?WmyRs~q%`=e#)_M^mV?u1hTW#f z&fAgG@t{Q6PgFDU=lzzYh4b_R?M}>N^Pm`6va$tzZ%l{sP-^;Ph{egTh`oniBMdqH2LUe@Jae?!Qd<&7-)4 zK@S9nN4g5c3%&C`;H)oIBm(D?jH>iy!gMou(R887l!IBwTZPnZx2}vZ{fWH$L!KqG zKG)7Ewr5EW1KOVpw9V(GqLuz^?4F8wMk}Tz40>Y$2di4_p(&Zu#N86?F>KA@UP(Kq z8hHwZb|*jQJ zXDo>mfH&U(UtJ}a(BhE`Pft|F%eSP&1@i_drCx@z}Hwrm1oyznR;8*Q;f`u`pj?jA1CK1dc*rL zhcXg#xIMeAVBSA<4hnURocV7DMkWtiPk*>iF(Pa&A1Q@T7T@lxLN(G1Ja(E1bfBdo zR_)a2g?zC!(dO>}2~#&6@}qMG*Hfr+doR2-HZdL|?=5$yY?C!W7;T|k@U@uB-5j3{ z>q`+Ud2TEx?qZL}LdQdRb?_d12-~g$v+b0=DJWrl(r3dVJganam4EeYZ_ED^P^=d) zXn>!Ks%fu&bOyP%fVj|HL4|q?dm&!GgZd99ZklFXQDMnQuh`8Eb=^xkFHos}tqAfD z48@2FxBvdA)|pR)AsQiBosKpNTtg>1-pcU7C_;nGBE_dZ^WEE!@>=r3KO*?NgzbXM z@x}eGe-)qkDs2|x!X0ZSmELimJEW#()*I54(}`-5&M^Bx>ylxf@Btamr}AMvI`{Wuhp4fFZ4 zE)<~D=T$+VUoRfH!x+Y!#rPe7{-`Fx*nP76x0Q#pEg6(O?>wf6xCV5xPdb`ik8Y-f zhn=>cRgp-|iQ5cFpU?GwF!i*PaenyY$6Izm8~zm)qpjVJIw7 zLMt%B5eIvxzpLtx)8{P)Ly(g5#5T(Pdz9n`HT)1=Jc>fa79!$DG5eGAX-z6|L|3tI z@uxxyC%*$-bWnXZN#RLwwz7cuvaI>6=y&h~hMzad+o|T;TG6P|v%RakJ+bO*Xh9bAWESx5$&Va+*& zX`iv1J8X6Tc0{~FjC6dIuOOl|z{D9bkDC zh*RDQN<|vbquCQ*Y3?vrM4I}t-~ zdCasJmJ8wOhAwxh6zm93Q60)*%%bhJS%z89cN{cI$|^wf1N&!=ey(RFL7=Gp>f1*TAaTH4Dh#HDB{L|AKuzkq7%;V%Imp<^iKIAhY-5?Gs+8A&Ny--gnYl1aYR@|mMaDa4NNGo>v#h`y-tXY z0=COi)HJP7ihwM;nah=^6mR%M+GAMV!E&edl86-NX;HL5Bu^mW2t`u*GvlsdGppn0 zpnIIz>F)bvKtFn>mUc5wp&#jCaPrc_!xpyLy7yS*d#!uk>>k_|EEn1koWtZjK8Z6Y zRc}31x-F0_akZ=QvX!=@eZyy`mKQU8r|8b$rP`yv4%+v2Wx191FT+fD9_%EbB!iiG zzPD(d^WJr%PB6|P)aFzKIJvr=B&`>PJK+)$CdD0u70&c8ZSqDP>5 zODH?=ZK!sPk4?Hx2)<;_Pe`#`)}7wS_|dwIr6kTce1KH;YtrkE5t<!I*yrN-<{y;jhY>gaW<3qi|IvTnN_ z9BiH>nmTgN)M)k9mJz3dsyNA#ZvstJ@=wq9YA6(e`O;pCS#!KR@vL$!WlnOk z!NVPA6vvlWTo;ZFy^4%pz%&{zElYkZPd2PB4~Z^Ekc}X0b*LLe+-4Fur+O%v5>(Oh zv|ajX`%50oTc7ndmyEwSw$MXJX_$%)?;S#cjs`RhqPg|zCw#kq)V-n0zM(6$p=)k# zZdXXUv@-PIbn@NLHvdq7*&qTeHa=_EfdcrIE%8!5G+$EOE}J_?@szV}Z8NhvOv`SFD|fa0J!!{Ixe{PIc7an6}XqKC-SLvW+(=@Px$OAe89It>`sQF1n-;I!cQ_Mkcj7Ae>38puEK^f? zHzGdeE$DVwg0ii1$`LMZVIDjL?tZmE2Czut4DKSS#Y!SCL?_HINb$zzGvMa7nL?B)OBCk%`v|_z#9LJZ1H|4XtMkNZMnKOkf9}08sWWCPwXX|(7#NgS>|N{V=bw)r?k8)QviXXXR>Uw1x!1&A^ext051*om z0SP2YIM=81q1vQXkA+l)!*nYPiLRZOj6ULh0`$!xX}uhjziR~{@d^b)_fg_bg@PA% zW(^}UZI5T|)f-gWLjQBqJ#i&d@ike8S8*=g3XT# zF9I92KM~oI?4@#|QkLtl$CW{UHzFQqVPRBwGYgd3eHqtQUoBf6j**ioavOwT(WM-e zp-iEasCfggL^{9jDsd@18tbRP(a?hY5{>%i)9l~yfNtUe``DN{{L)aW)Hw5G)1-u! zKmFEaDTbkHRnARzz3J7pS1wJ=pCc|&^?pjFo(?l=>rXJWc{v;6X3%usr_Sp~{wwUQ z7&cRFq32+L zS%sBESH1^e0UlGh@2)QZgXGZQLNszs_R_pcH4V;|Hb#!@u53vylh1Dqp)&QxI&T7$9 z!GE3YIL~BHt;ki}VE8*sQ|ARLY8-kE9pN30Qdf8@a0|oGhNDIY#h*A}?}6DZH=k`q zR-ZXwilDA^cOn;owM{ev55}%hSr(feGs$3$wQ#jV@i0Hev7f&Q^{heksav<@^G|JL z2{CF}%%82&pdaq#(4rrf@jT;6bOQ3d34g%kq?kn3+pbJmzyFTWM?0PmEAL>U1sMwz&6TB~uA_p(TuQcQmlbf3VYN zcn`FDV>5J9ktlI4i_4`f<<7vcUi>&O{Q{c>7uA^^2hj&dtbNFI?3MG(a&!R(`bR9 zE#A&M;oewlQ1o+a`oE$avKHF(5~;RPm>KC8LbsB3;tLZ$Ya4$L>Q2<$wL_$hg|nWl z2~JV!{+#ST^JY~0=wQcZq?WC?>6U!@xe9v(99Ay?F$YZ7(q>+poeTakd~i1w^h5yd z1`X-jn1ZTM@MHBRQ}KLOD!rJ=yvV{ZCeaqI#9-$yU4fhGWY$e(dw=ZScBUZHQf!VD zw)VQa@EXS$>F#|+yw7hQ4+>)F7uAY&Rr!lmP@UFUR#GGON=8yxwodUhrI}t2z*MM{H%2Sj$Zp6X3H`m zYN03HOONl=Gw&4AsHtUN_`96GL4D0Sw5~P8q8!1?yjrDn7XW{s3H${_m4;7^SON;O z1ci6`lf`tZf|x3MiLFT8{10*Q%9Y=_l%d=xxdq}LlSlE+ z66hV#$nRT3oMl2eL6dtSITgiC;@-nOsuy+`O!&fYStwDAr*RF6%)`L9xm_0=y8r*6 z4)T&r%TNwG95KTM4x#gy!zLyXlJk|77b;8Rq(BZxU(CX8RFL!H z3-jj{%}7uR7W8qTzYjDl*wmDT=S0?*$GIBBFN^;f(kA8@JP@_>;Ynm?YsDov5~lTi zK66z-G4-5=DgD~gMuXXl>c5{RZ)`y_KYuJAT->dXE4ln~?Jw3aQ>H&D8SN(zAF9G(D=paL6+Ng(_hn$2(V6uVXz&JI-2ctK z!5nH}o6ou|Acsg~)wn|DJ$R1I?awJBn%BLE(d=Jn8cLLSu+w+4Eb$aTWNWThsRG1F zJ79oe`GMP2)dIkqT)??w92&s#JQU?FP~D;?JmTB=i)@Mejz?FwCT9+PU)l_GQPvvQ z^CAjQSXb7Sqfi)3{lW!4!QzZ`zs=ohyFWRnLisB>r?+oIzKgS4=nZoGBm$lTa9xo%67)^aE70`d>Bq;k8fdJgN|RB*>&H$<#QwqQ$d z#xKpj*-tg8Bfuhg>^RWb{rZot?ikJ5I}2a9uD{z)K|U|7l^PvZdw^&|C>Gc!xAsBy z?=7c4PVzE7NKbI`Se+bZ`{cc=D?)#1k+3Fq53x2J#13(@3z(~*amA8e^Zm_^XBIg3Z>3A$_7nJ@XcjF;qinR==6--N9K(M?Swhg0w7 zrcfU*1MIF!tB=kicn&wx(=BUxbKhhivU*j2*24wHwC736o=#aTL zkwHfiM&~#9Y~^<4M@iBV2&Q3$(fFIU!dM5COpD9tf9=aV%wM!~DF}IN9nh8L;K<~< zrE-5ZCjJONr1KZF*(SZM31~tC`FQ)Nev^NJM2n4A_jVK-eU(y@1AgD|!J+F9gID!H zlZQ=w7>OqJ3o6i*gTL?zy(456Y6p)QW-Lt3HFT%c*t!+WZq~?my^>u)Dr4h54w+S$ zf?{_6or;9nFY@qa4O`!Oz@q%Nidq9WE#=(tj+Y`mPvM{&)9Z|o2<)#f?x>xQ2uJ+h z0lKhH9u_+M+7~iLl4{ZAWA>h9!+-)5kW!VV#}zUwK8 ztU=|bW)D5y_sT&G(TIAfp&w^JZ>i8baoTffA!h555wq{=uzSZ@d*sHVsQg`nnC ze*1P{)Wb2j2~Zdpw0!}$U7;2BUcOtC{tSV*6hU#J?=O@Tl;r#4vy^2$%W@5cLODb@ zX)?GTXk3U^(tnpG9;h4a3=e_pN55j);Yzgv{oC_;lwq537>7ZdT*BkO_#H@)?o*M} zn*_4&aM+~JOy(~l52if%73Wh_#XsoGz-@e+7GUB#yY_|PI>g;C{#+Hxi=9w{g3*1-QpWiZKriCy5^JaWq zvaWx|IQV!9sc2FGyomCeD+P99V?FkVBGvPL7xNFDEjakuskhIe%e?Xhd4yR^rz;t| z`SWy8Ttkihf<}syIOqK?Za1Dp?##|X{7=vhM`Cb%^;>0gyFqm!w40(~)2IkhGpn7^ zI%SK2{N-2;VHopS_23O)&wRsu<@bH=B`K2Gxo~?Zz{9OaT0kH&|uDm>m|6 zs<(V1L(&^M%xJA_OJPIw#}$W-!)-b7*cGzMg?l;KlqsQ1|5&$;q z$dYB;2^V`xGzP^IH6!m%yB5mN;QyCZkx!sp@xB232gUfr<8S&k_rCbJAX3fDpo$vw zaXuVTNn6<#BLigN31e&)4D*qqKD%xfH3OwxCng}FybrDSB0pEct`8J37jIOp^_bol zZCjOvmETqJXSG(Xzlis-J8$~A{^>5JWq!v)FKbk0$RNzprh(7Zv7{YU82Wrrw~6SI zg-wm{>J@(?5z@QSdGo#O+{*9io@Qn8uNL{Mo)L=qjtVm?HxwTz>t z76n!h89LSLJENpdwRnHgWv^An@ri0kf3CF@8V2`Lk4!NiF&vrZMhgw3Nm!SK%=&9C zFjH9n8MCqMhBMv>O|tik$*QTo+Ir+8*&fGvQlW4sa#r88{`Uo8eoP4_S(3&ouI7^*@oW%80N~VHl;-nHkCV8VIHi78()IQ=G4 zj*>7rP?wXZPJzl+JtxU4r!IKLGJI~6xpB-$vayl5c=T_y1ss?yHoUq>1B0;-fm&1JQfPy zo(Ct$Mxv5Foj!gFtV8&h7>63vnNs(SVbpugY$x>5wW%ZO7{0*PPl4oh=E_6rlPsJgZLq;qLlXgHk(9$`d;h&Hk+#BXBeZGMN{9f*_ z(nnb`Euv;!N-di%%6PXNlb~_eP#1R*a*e{hbnRUM6W5UAJL5 zZ2_X`Sgn#Qmlk7T$!G0yzuBa~&IW7xuewcCGB%~J1@4~EdC;j&UN|oOH+ePD!P7TO zNKR2H&86j%%wM5lKbX3>yq{jEsD$`VX+jTiWbI@4Q1HJydJ#m0eze$$bqbA98#Z4~ z&^t4AAx!@XU7G!1Z$Jc5Zb<1Z9*;*whg`wsnHgp4p19P+jnU9^IbR>JyrI5x5r4Ew z+I%~Ln9-##zBs%|T%I%My0GB_ALD#u3sY60y;Z;r|A_ZGdV8aOwXcK-D+u}2+(q3? zRLk6VZL+!R+;*dANHNoP2AmKH%>BG1fGfV2e^NA6 zqBOv5#?&y)D&o-lxEPwW*Qn6{Va3R#$J zw|mr4ihuJ(31ag^mlEq2?mmT+xWqR^LHvcsu_L zhJ|aBJpQoz$QQbQS2H|e)(@-y{!$R^UhW=OaT%XBTp=Lp;h>KyDD2{C%$nfwg(SWl zyA$~qh=uega=Z0pIQ5uMa*c1=pofqShk>DQl;UD?VYDei*~wm^qgr3fW#v{x z2!J@-*U%$Wdh~}+S!2V7Tl*88BER0K4rwdxpJZ2PvZ<$|(m;Pwn+kr#7Ww94B-LXG z#C~Es5zYOOzUz|9;hoRgj~H}mUh6-_)!3_2QFC|OLo0DXO=oWtiRigES}RN3O6I7` ziBFblfG|gUI2-f|x%wd0Er+g~cuWykNm^p@iol%k#r#JUo7m7~Mzc_#z(YgMJRgVv z0Q;(4j@wFs89^LSPLh?WJK_3W;5QrhS~#{2tpy-u^rgMFIlFzZPVd9 z&F6xRKQ_#(8C;TF@by!hb5(++G}!Pi=ZLLLO!(B|cqOrEV|4QB3f>~-cAZ=?zm^!n zBfNF^ybYKOjmmj0ucG7P?4JY#SMqSo3k>a8T#C#)8ZAt%mugk|@h7jAXV*=E$a2Dw z$dBPC-a8gk@xPmZ{UC*y0#n!?)ZSoA6{O;2rhhN7c=ZnDBG0QBILqdp|5Q6a3}Fjo z*RWvU(5$&Y_v1b54j6n{o@lS6e8Pb`tjoO*&Xs-eUtbFjksaIvv6YRC>kCXfp@Am0FuLRGWX3Fs@g8_GLrHAQSi=j=3{< ze75rjQhCHgEfS5Z96$!T6coGru<_CgE%P`mdDiVh0yN~FeRV4%?K2A@uegK7p-7%m6n$oU$$xVX{HV)l8#ymWeb-`_QH4WtfX@t_U2tr#4;+V z4sK5u`R6A_2P~xafDC^!&;D@>YQoqsprQQa`Deuu0%~#(JeX@@{?d!bZ`KZVQ43w# zr&svR90U4}AZHRawdK2+0YOUK7T95r8aI>xNqTC>$AE7bpAOPGT1xTd$b|t}*tN#g z_=S6cL;#v^ePxypyS%nE!sz2DYPJE6#?)(-cH$14%bm+N_6Eq|D94@oU83XU;)F*O zCVs^C)9B>raoRgSOZLRznz)te&5`-L%4bW%;^g=iw0n4cIr$_KGugsKEx<#!?1THe zCm^Hh-ATAY!yggkCZ_H>x({C|09@;d~2C7--ei|;4z^+{MN++eH(l>i)LTbTVq0cx>1hFlZ zs702q^iE2#uQ5|C_wa`i_a`hg;}(Et2@?X)O}8Rj%#z`>ea`hBG@5H~-gm~n(fk(R z4lyfnIN?e3364%=RZN4L5;$8Ky3np-*`oxzL&Sm*%e^F?HFk~Ab_;kxFKf@ux$C0p zX`9SkDx(#^3*=&L<{ge`ly9YSKF?Q_^k8D}K@9vN4|9yb11G)}DI4ZlSf9h~4Fh+{ zi&VeCG2=^~&*Zz`YwAuVU7BYW`p+Z4m#Q4byvM`<|23t}Bc&g8>L1mQa!l^yyHO0d+9%sq@F$;?%N8xi>i=f^q@p7HoTg~I zlsn_02VfCjWy${NKaIuR3^LtlGrq+Uo+9R_3^zi2uDatjvo@q!el7W*P~#3%nQMSsO3_4_k0&n9zvz@p83d;8!OB^-9oe?0hv<3>;%j#ZTE_Kou5xj`EW0&S0Bd(3ygNy( zWJs_=h{n<2oIsVfzdT8B?b1$2U(SK_&Qg)>r2>9oWl`v&b1d0WCe1WPV&n_kE`Gxo zF?j(j$D*TH@jFAA+tjU(N3}cNo^5RHdLGhT<)F3mT|dA;#&^B{R?xo2S1a-_(deQpHNaeIqAs#$I(k#MV^^-^N1s289{=0&L(S%wB3V=~_6~wTG zJ%z*!3FW7qokm^VoMx&BT2{plWfMp+PMI@bT^>x?oqX?f^y}34`wbcV0NOaSFy1V_ z;h>|fVj9Q@fe`FsM>A=q9=*yaIL`XLFaljNa-*!7{M@nf$#>GzQs#PV*XAdDRtwlC z4t(DJ^Z!HByMQzO|L@~U4&~U%A&2GNK{` zIpwrDFNTrxahN$D=Qx|~_wxDv|JSwa;<9-i_If@}_w(_%Z?aobheYpRlzKd0ddk9J zM~07ynQSvmBF@wNPD=G|OTK<9 z+}lhC6DAriS(Ra%$SZhQhhH!Qc`iJfd*IE_xm%kDgFB-vUbIwrYDZkh@(+9D)AT`Y zbvuKm&$952=rJ#u@GYY}Rf)UmaZWSk5+q;SJI6Uzkf#J**U;S<6cN=mIB;R}!7g?? zZv}Q|Ahzj41trGj%qJxt?8&X9Uek`npM&G^$ppUTNQamQ8nEVQqKSoVYKbeP`<*-T zP;}0Vw)73sN6qBLlH`SL7gsdV(pW@pfk*h-Aq;}0p-Se%<6CfoqE`K& zKcJ2~j1L=9YyIu&7qU?f%#k;4x?yfxciX!A zqfPbCSIBN-H|3l6tlUYH5X2bb0w+;SZ08PfWqRpx{9RnW5( z7Xl=zZD;dhs)|$k8&^-*UEm!1BQ)mq+{j$Pzw=IaZfGAl328Qfx)#qj_h>ZGj`=w+ z(ICUr))#dScESq> z-H(=ORoquk>hrrM-pZv|DJ|@H^*?fo)6jvB+!m%^0IGmWsj~uq0is+t)1uPJnL|^+ zoxg*nyzB4>%OfuetrSZg)7?-jXhs7B+VEEZcLvi_umie6Z>t!*ym27aAx~LW-YIj? z89&QZavk!>6uG8z+d7TWcXh7`W;RipaXAiF`MX^Z>tq)sM;!lduRCPW(EW+2@%>Qa z*M*7Tff@8-rEs#B+ZGDl`psAtbolU3aQrV6_$%1c^S`#NI?e&w&2Dw0!wY%nx6--%-|%M=%C~D5LjnMb zL;6m~0ZvvZ68Dg?@zYh2bn27Bfsoxe`17QjTIemF@(Eh#2)`4B?lEljLI#ej!#_Bc zvIdNlqJ^eGLRpBm-=_OwYJ1K|R+U}loX!V^iSP^@2N!UB@^9D3@n>2atUpvsODhz- zWRWlM&iN|rKW*ZdR=Z1Bp0=@yx+#Sy@}AgRDb;n0Q2L>Pw(2!&nKEOzeULJl_A=Q7q z|3)u-=vZrERLj8i`E2^-+&l^??u8-@^N=frY zeaF9}*iq`IJZ8q%zNqBJEK3oh9OGfTSDR}LU6cbA9)YqH+l#x56mcP_6LXW5nM73> zquma%bXJ#ajZ)DFYhIU@hQ5Hz5R7^^CmNIGGO zXN+tqTh<06Z%R2NMW4ql@aS?ho~mF)RJy4{3$BXJK?ta$dX#;js^LPQ`k-dP2ubSY z1_Pa3-~EP6n>I2q<*i7w9}S??!V9s6)b?oTtxGL%OM_VNP#X@<7iCR`rW~yQuBLmn zXZ^UEiy5eJwAnR4xU5&?<9hPkaTl-iT_*4FZS{Va*I({_x&N|?W+GfJ1(UX&C;~Xj z2VXU3mD*QIVq0bcoy^l0>YgB~Wrc)CeJ<_&fyCS4VYV@_%f-eto>QPF9WbjpbObCy zj_EbR7d)}~jx$B!#AT*~#qUENky|Y%g<+218~;|93u)QlFA;+03sTKqgPAwCgD>Mn zVu}O$z;WBJy-zs~j{o+LJDFt-&#{wHzJQyDj%7%Wq+UwW2fbbpOumRKN!AVAkP3)f zHS|k6XIySL05%?QKU%4*sij(2fdY}!2oX!|-eF5Zl}j3K?(SY!@ilc$RY`3xB4TG_ z;5N38^$+sXA}+@o4;wprkFoUI*zy~mB8ko4DbGvfPl3q*YYEnf!hKN~mEgPhX*Vp^ zD*yyD2q^j!OS~@0TVZ=a>%{X`LjVd7Fs z>=IYGGIqW6M^sBiOxv+*Ns_z(UTCc^XF}XkJ7^P|1YJyoLHg{_mM{6flB97Fq6Z6C z*5;`z)M8USbWDe&Zo9ay6sQ0d=h|Hx(j}bH_;h9Zq0YAQa79=cIGVy7nfZj@IUtRw(cC}gbL2Q!@7Zi4gCJTKpj`UH=a)+NWU-^`v3}gpH6ikVxUqAfP>2ZTq z4%QNnamo}p6_+YTh>N6iCGVfp!1UVrDMn%a*9n^fDZw$Qmz#3Lv*j?R z5*C$<>QG+{{5#)tj8%kgE}*t;P#svmSuC+@Xeb`ABt<>Nks{>k_|uin;t4!|HI#biUHRLgqN zn0D3kk}q-{%!m9O?Fo8gnJZK1cVHWle^$cMO$Gi zZXsNa29%J6va5G&gqzLL8~S0@KW_AC#D+XZwnMyrhAkfEANBD&i@C{L(SP2?XGGv) z>>JVN*YP(#if0-rxU%B6ylP`xUr5p1?qvrf4+_*Rj|vL>rSYujR(^T~j#lESyelyC z9JgX{D#~>0UE1Z&N~;JGJaTpClI>XEi@Kyzv*AeWW_kLE)1O%KmTRGRxO4L0%%Iqd z459F8p?Qc=PP7fKkU6En8YAo^>`YOy0Jbmk&k0$gfAbrgOQ3|i)H_U6O?X)nf6$b0 zDaBhaj0?H?Ms=bIi#j>cr17mVug~bs@}|}d9C?0bGN0E}m=v*5uvHul%uoWHyK6sH zt~_O+3AhrtV|6Ktv>;MyNKD!#J0#X@#U3TUpIuN9jL+VBV}>TU>51K=A9ieV%tpUI z`tD%7#F^(f503iFU6c6%9*3lfBG2!rCC1CI1xi>7SUMK98+YpT5RF5Ub`;-PPBafnWc) zY07Vd|9$rcwEMQlrQw}tuLC@^(SHl%I-mQ);vVFiSe>xCK+IyCEp8boVPE_mU#t5@ z-ShQF+Un$s3kCq4A0_&AR^=BVn+p3Yv2jH$L?3aWU}o00o`>2JtEc4k;INiH}5vbeHF-)7i0V2vD*^U(!E5wsU+xjEEysgPTU%noOJWfX2Z~mR>&|x= zz2jbjH*~dz5Ylm%P}k1uY55+jWKF5^(WqP67s~T|%XHH8YVB^y?;bFj+o_p-cTN4) z(`?w@52>Figx+j;Au^wg9239WqZvA%x0|^`dQE?DUn}=cz8_Z|X1fP3^~LM7B<#90 z?~@C~Q-4GB@6-`;_x*?f+3wi<~AT zUnu(Y!vhbg5pa$O(XRlS=si5!y@kry%!rA4fhhqX(UN$)Qo_wYIyf_8Sp4fE5^|7f{%YW+r%y^x$;)#!bxMV?B z;;dW3lH%x;MEk?{0l!=4hx&Y+lfdi%*Ufv*!sv<-WWD1^+Mv^|%MG%#ErRh4Tb}xF zdifX!xVD&kg&wG%<7IyeVm1o;)bM7_hlfYvvQIs%DDu!cvXjzKox)`Yb$|I(@93b# z1ZNM#ZaO5n*dR0cSxc@j@tpgJ#*vNl zABov>ADtT2U*swG!6hIUDNhfF5CUz@#)MKnKH;TRmZCcNgB4n;y}4z9JISD$2zp{ic-su(ma5AkYVf_@vc0WF5P$ZPjt(Mj}%eCW~J>A zO(B%omQySt(v(E)Z}1;nQTX~O%eD&Pj?W7w`$p5 z#!|lTq$8N;h;1G@%7WXdJG7wSscbpCaPNA*qu8xe1I`~Qy4{2GUO`ITKF%mh-WTT+ zH>qIKT`}t>WN7?waNBw7ntZU_bH`@aOV(~AcBxk%W$3bv5}5^Tpu*#55(WR1SGrEa zzqq^gqY02-QnO$&x1urnk)K1{@5J?OBKb~8C9AwlnA>JCyhp~ygyCBy%O|*`9~wB> z;sfJCy~JY64`d{(fStmV{!792wO`^f4mGCUJ1YCfO`D|G2Or7+8Va+dlv}XedQx6Q zo{O9dk|m?3z@rhUBP@D@5YaGzpL);zmc(ni9b4e&Evm>pZu%JsOFv=udZfmj zQfN}0K~lC1=P$xAK!M$@sZ8d3`j)7^mwb33)N9P*)NFKOD^2&pZVu&7^%HVvw0wb^ z^2;Sfc>WUe`rhv?un$dQD%+`&erwPnm2dZ+J=OsGM1PC#?6w~5{RnS#GyBkYFrngs ztH+)7<}cHw(3x78kqav{zfc!BMtraKjrNPXJGPv6-9!=cZS;F!TJ+t-oCe3ygDV%! zf~q|yx~!NV7k(|@^%crP%9fajA5H|zohJxY(~2@_i^uV7Pf*L5)go#OpN(J|RUmXI z2KP64sJ6_?Qo3(LlKW6xi-x<{Y?#;53qs7hgIqD$8qw#?mmEixFBbIKpIRWZ^9&t- zxy5HVCQ4$Ob7*O(R`bw7-XoW_$H%?gBNi0f={I2kN){8PQfJetuAZ_2n#`YuiGwip zW1}T@!{YjG26dD?~z-fEyJ-_j=JR*n>c0nKCZ|u=%slT5AOT{cZPjk zTU~SX5tdB?IhcRz|FvV)hgjNF=%%>W4yd{AIpT{o&y4;VFUv*6c(4R}^-fZfm1OzK z%L6iAc4}WrO|)1jiXPG8)r*jP%IB*3J?wuB0|5W6HSnrNtQ}Oft*!;gfuZiwi+*|| zqoWhAw5M2TY}Fp1tATvC%3+9bQl(@49hcTVhH7*Mm5!(()oBiP*(Pd2wO1^YdS{f& z)iOwjgGrAJ%I&RXd){atqNR<9MOHIVr;;U2X*TgzX6_&dYC7`7mvCC6xq?8<8-TN@ z>tL&M#~m}-O5xFI$$wiqn&o6q=Ayn2c?{6oVsja6zfq+&1p$!0b$ZnS&19DF?Zd4` z7glZv6@YlAf=B4)xK{)<6VK~X$gd8cZLC&EGbFR2vaMYB^7^F>*Hcj1tAnXm3@hT- zpDYZF+Wa*E(Ym>DB)Hp4nYKt{>~fdY#9-Fex&->dx6jMmp(nfh(|@DvrIR)F(k?}f z>tnu!tz~`e#)XfBSBpFg!NklZX02{$Aoi9rG&LE-sN)UM=Griq!@s=q#!{jZu6dzz zmZcU8uEOH>R?qvWm6X{ml$+Pl`~Etithgys6edf(8YI)REgIU62KKbsU)=4IgYM+K{@r+NWfD)gn!N#-i?&Dt;YO%0oad4JP{ ziRy#cbl+>2XB}nQoLq@hMIBi&jK7N1^f3pu0r6Y`1z6~2F?c(oNqJhNxYu29pj|8n zl(OdLo0~#B%p7&QjCSqcMOTdqYf$%RpSKnVSAQDh6b~=0&i5)-BN@k3q4-9C;B$?> zsaPGjcWMH%M__$-IFhK&A5eGTN!oT&!a6{K7(KREiu&$i4KP)fc}tVPwo=pA{gylL zw;Jk{3w*X9X~4f~&|kY#HxXW;UR-bg3V1y=ll>3$=&;}6_*T1FBR;q0svVo(af$0Z zJl6o&K%;TV+sz{l=DjXGx-POdxu-he4c$3rurneqT1Nh~812L5wic2?OP8j~58XN4 zE-yM9h*INY$7}afrH=W9){k8**F3*>e!MFDhxC->W_se1`fAIEax3f#JvlAS;}IsV zK~te%t&R5Jr@wHu<|~n(2z+LxS&n^{PuO&-G$cv15yjgt zp8JMaJT9ym*_UAYb#3!BX;M@08D*%@yLswVPtk*=Bt;nJ%gR0c+>03>MGu}~N8S3l zpzz&m>v`jv1CW=h3yzobJ@$T97PDV|So=lM@vuvM38z1OPxlM zt;L&y!>wMH9&cJow@UTL$}J!FNqtwAnYlRwUg)5noRaF6Yv$kR-X6@;uW^4s37PWU zxRGw}6)o#uF7@={Iq6;f=NM%4Z%rk$8CW#D2OPTRcqxE)^BvFVG`GMOyli)Q*&Pun z+u`Y;K6ta6JSMJ%KZ^@rtaLwy^w3PUcV(Lg2}f`*FE6joW#cAeObzO?ap?DCTl9NV zNYJ}g92?3hZ{gKY0Z9tE5-0Ft7D)g&u z#>udDGqG0e=$~oDj}O!MOl_{>{u4%Fd*HmObS~O)H~ewYGA;h$?T?z}0|ppSIZ*Kf zRsYcy)M@~7?Jk7h&HbpBT!EjZ!OwAq+V@9KY(V1H)R^|81&2ml>-L@>2t2|?x!j4@ z(0}c%?)0igX!B><4>K1d#(*=gUi^3@_w*(!i)n=s{$(8XWV_=;X&H7yz~y)6)VYr- z2c`yp^Ii!$(Q$U;QW>|t?D*$x(2I=}w zfiNNy943ZI0VvI0IJDj^zMK8~xEqD)DYqir&l|!QZy5*S_7j#@f!!Ova3CKBh2ywa zPyBZ+0FzFDFAXyFFH7HEu+`!+*wq=8IW^{qhOK&_FkRu$8_Ak zUaNiFM-E}gsCY^(7enBi4lgOKsm4iJF)n2W2~<;SXZ@sIo>v-Tv3I`;zzYtH--BLI zv5(2d?&gP!rIjYe=m2B&dC_U|0Td~%>Xp=PV1IEn06V<48?u?yZBece@qn7wt8?Up zIX)zxr63;dmPa)tZa*7~DW5X#P_$XFIpR9?>%OcNLgjETg{~X!2H~SIN^^Be1~OpA zzG^+d!28^nIao!sOhI&$1}`3cgK>S?Qwo3oM{H5GSle2lb!p~q(32Xv z9@&mW^=g!em3x3+&oO{uDu^z=!AE{J?;iZ4?C-_vY8;e;+h-fqtZ1wsiA9K zL|s`o(e>JCuhS6sWd=g%AfZ1f?SgpVU_Pv|*!A$Hr`p$jaV|i9N@xiC<>r9;iG(mM z8`SxZO>2R^vaWhcedtMK!qvRI?;y2dk&Php7+-~Jw>=KJQBE#&&TDvpZCEMctg4}} zz+;Vun?|$8BoY%am?WnkGLW{+O;=y=3!CO=QnJlk)iR|t&vCn>n`djkFG$03gCDm$9 zP{ra$%5}r6DAJc_p?#(si8XN|Ax`yE;!hSluj>9BmN-5e5VPl31D@=VqloTKuE9D@n<5tt?;B5Mg@~ z;v@C#BS?q%%0kiqRT^A`X1xwDF7?5SM#uh(O=k_Riz8M-MSx_Lr?TA%VIkF^8EeNG zD9AW8Vff^h{-i#gqGl^t(@q%;WXuOZ$Q~G$9{;9C#D1B9en6IK~ zFp5r&;O7ND48%e06~C%(Y;;xO4X-&rL_;>60?67iVM?}D-}h`XL!B~Vmnx` zre@^*v*BvEkj)?Gs8oyOKTB4&E@PhGz`VW1H#G|PS}NoUAMrY8#RnX;X~^j~-@T+aOr!*g%nhN);feN9y)zp6J-A@JQjbd6;cHTQkF7jTTJt*vW zZYwy=T4~&h(auJLosi&#v@eq=A zRYpVTiAy&3jb>yB#$f@{+wE%A=_$+sH+xHPv(?ZgM78(j0zr+9D=#2-tn9g0KWkI& zH=RRTaAT9jawiCvadlD@*YT*w%%cwnt2eju@<%H)b&k*|Vf2&2ciKpE|I8R8;r(g! zm-+_f)$KnRPKRa5<9f=7t3PaQDPR8)yb1DOwqFc|$7|%pnYvh+(p)M4>AaY_+l-QX z3xW7*)gNe&V`&7lfW3}&xqhLfL|j{sdNwweX!O+uw-Qf%IC z+wk^_UzzTb)5r4)E*G@54&GFDHu&zM{;Jc(T1Z#tN2J_j!zO7 z0=)jfI}}ZZ%ZwW%EMC0pmy&-%Y2C#eVEO(5(%q}pPEAJTEjVKV8PT$<1ONSZ+70-TKI~pN=*Kv2uQpluzy9SB zd7rnl*CJF7`6Scbu5Akr4~x&bL&vvj$P(E1PCkO)-_En**>xH{l2TB$4?(eQE6 zUA*`#D4bnRL0=IAjnrR;f>%Dw`!X1H~Rm+!ryzR!7Kzu-Od zJi1WI$cSCH@9bRF*T`|!pHu&Vv~DpWjs^ z)n{6#(>DjStg-@{p8?paIigy4j^7()71}Qr3drQ<<uFg(8C9frzIh%#j}U*#p6KyXu``N6rlD(Y#W#(3{O)cUy=0QTdq+ zD~d)NZxwyr;iohlN4lzBw(;~g{RN6FJKB-aY@V4J`xDyBk$sf4dwp|xHq|RW$cKFa zidDl!sOLY;?~kTc<$D(#eGKI$9riMy`fY-p-uXIMPn5K*)MWpQY_WT_3f~-Xezo|d z#xmkWP<7zu5r2RGtKie$Wp5o#z3B0m(Ixpy;xD2`pw{}kZpyJw8kMLyeV407A}l%t9}&xCcJ)hPh|T?JVsH6XMm}O_%DAhj!f?<@mvAoP9pf@A@O7)T6m4{}8 z_BlXEW~Y=j@@-F8hvJotL|@VE2Q_z@#pLVXK2MtV{&ZgC7w3z^X7S=$^Xs?;&+*b6 zaNEpWt}dcw2T>LFr69KBoqSlEm~XfJcc%c}SPbZpBo3H&8G006b-RyjIDHNP zn7+ln3JTI=IK1w)WGcH*^uq4QZO>Tw^pM?gM_;eok}fXw0mR9715;mqfFgR{ z-YZk$9<2FG5lNysUF;b!u0C%Yz-O434zzx(lh z)}QCPmXF`v?I{c|=TR~xtLd^&7r%XJ#7`4|$Q9r_$}WQB-Hlw7Z!hFXhtm1v(W~<= z6||GA`#K3hGd6h;`SO#>zP-YoQ zP0q9)yx=iKD~rV)Pq0Q0HE-M$yRJf_H@~loZn>aAi}5?!?27wiycl%W%*^0MqPA$< zeJupi+^D$vOL2g8s7|f-q3&w0-Wx8}?aXey2i?Cv2}cT)w0}izxxMF#eLNK7c$6NA z;O8F|1E{*ko|oTPEW2fs0Mb&Ruos!g2j!HNkg@s=U?`TA+G!ue%sn(se&x-S)f~32 z-nO+xjk+H?6reidCe4T9tJ;{W1f+(9Q4}hmO2yy0RIk~L@BwI3VjlOoK6TJ;p8Rv+ z&U~Glb%C(L@B0U^nOC$+(T`)h^w&d1%o6%2^ei`j%ufLNiJ;KXBidK~C4j($P?d1S+{?|@fBFzDwD*gLRl z^xp1;n!wGMxBh>eYX5&Pp4*q~(V|t6e~z%2vzgd^fI-;*#}cvQ;M59+!`w0E%z`G$ zIfIa<8G~&dyEVYq91T|9(cjR=Z`Z6$0Fp{_Q^{XI7?zp=W9m2N~o(dOk5YG*W`Lv~a@;;1}nZ^B%{~6S6xh zeBGZ}4|?XU+wa*k?-kThx9K;jQ(e`K$SVP|iTU6$kJCmjU$nj$JD1Foks^n;K=LB3 z>t-#xEge{7S3%&~re=avsw;D1p;hYR?2lH?utD)0O-N;!HZ14 zC|&;?=b}^Y0X|Q9ZGQsgmhn=hT%PeqdD>pT0xo;zw$nrnngpL(DC{qQp4kd&Y%svQ zXL)dpF^m^yr!})zBxmC@jucOZ9<#4(#2eDSJosWED{{}YBXw!WFIXeF9o!008PR+* zUx&?`xiOL}%GDYy9MG&wFdmchDZgdU?PLAzmAR}1`9RPSIY|8A_P~n&pP;=Xo&@6y z6zJl$+N`^O27UOGtw+EZk#aJnp?zIsbuv_yy4A$osZYSab|ypk+~{Xkezt|lR|a1R zGN1qS@amnb6k{%`-9n}C{{fqpcpbA&lHfqM%z=J>Eobmtb$dZ0BE zf`~8ok`Vz&A}xlmVDHqNs@`4qkV<>Kphvr_oxRjonTNc1ZXbK!hZziZXEP^+w@C@A zYYUha0?}iy&tn3*XTf>Q8X@mVU;FFt4aFnhaaa%zc`bDX$%g^Bd~ff+H)6K3wWZ0| zfMZHui8e&qVTAkzwKd1Vs99OeK6b3`1|8zT&1 z#6aHdp|OB2A(gQ{)4;gc6oy%=`v1-?x3$v*-QlSYWE{b4udr?Qt%txO5hAxKfFZU6 zY*^!%T^w7Ul>}LLUY&>lP2xD~7=!BHFfCcYkoGcnhWhd0_?0!l%$}DI3zY$*T2lOK zH<8;_lqP@yvWwBUI}w4Njac0*z{sek5o-Hv_dF5Sovr1(``Ooh!w=x;B2QXsx0Qn= za*Rq*8d!;Er49DY>W;-d3GD-Xv){yQlVeMT?4){Jrdd#^clS`S<@SsZV$m!$p)j7n z(`Vt=Mecw31^jA|SB|iy;q6F^(g^M6#hxpWK3N(4iMka{tXNBe{23|B6-n?vsXspO zE5My_K9fhmUnqWP6Q!+>Hat*DZ%hP8JtP;+;mLb$2l84j?>ZG5GM@IB8P{#*j?7zE z*;X{g4du(36?SYgoL#&wR(Id$3#21vhlcm8j06M*Q3B{21+w{RH{Y5La> z7$#(wbUUt{ua;pL5dS!fb=NF^^RbFv!?Et9{;8aM;cG9_$}>~c=sCR_ks5W~=_$TN zd)oQQnulni)sK+>Xr>w8RrqK}%LAJ6!(29LbN7?M*0Pn=ox`I!{R0W-HmMWA5+VVs z+yid9U<%i8vh%>00Xc-h#1TJ;;KPPzR;VAKyGPRWIb5UdOAcJ$!zQXeh7#_so@!O9 z*%KY$5_GKPsnm$OG&Xk2XS`7YEBvv7tua*~Et~m0zhAxG?w{!3V`;{$PV888<$ShWfGNa!wk22R-;^y_n6oxQ*c?K;^ zpeqtyt0E@^D%z3`6#G#QO%J6iT7NKiu#{POS9*mG9usX|I5|9|mNm2a-K%-T^Vg;? zC5Ff^AWnTXWWoMUKUI=emoI(^CZ74d#%h#VZ&Yq!^!Wp3Sj4Bj-SEAZEbmyNDCu?L zkg9I}V_pS=wH-lk_zxu#mg@9S&@3rUvE5+$L8VQpYt=O66ns^trSedT$w&90X!J);mw#vH^;VCBl z{l2PVtv~I-w!|5CclYpMa4dDBm~7Bg&KVc!q|_YxCzrqpBG8HGI4M%=E2?>+M)jka z2e*lxb>Dns!D;SSE~zfORS#}PUZCt4#J88IohJL94mDJ}s31@fMQ`2?q?aL~Blril zPaH&SYy)ZpWPoIH*I=>`Vn+Za&{kmA?_KrqH`PlYb^|azpus4-9oU1y)zkt>@(O14 zK>hT2$bVQbsq?g2|5dH`^c&fBfnTO+)--cabm4)+x}K-VL->tqy_t2f0qTM%3b9bv z-M`qW-mXz{Z@9 z6Wz%GvE`6#0qE88xqWy0bwRh9!)ERPX@?<_L4r0%fY5KZ$AUzlY`VyJ4wR{2u+1QI zbM12!Q&{bkMk0kN!5JcK5!bTWJ-B);0>cDYtdM9BXU$`rh?$gMHHU1Pu%@&K?+Q?R z3x8@V>Wl&ObU4`>xt>gE6k*khoCYTG4feArEv=mne|#tw&ji-?3QrmV5dvlI2P}3J zrM7)Z$hLZ0cAqa9$mRk54vPZYi7LvT3VPcg%|~I5ZLb!v6gjP;u>|0H_ocFu zdW-y@nwqBl?H&&db9n#v|K(L5Fl(x-G*yfwW(vzXx*y&+>p%#lEM3Ph{wtXSHL2OU$dU*k{&2U32DQF-8Y`DX8OI9oy>MCD@ZP*!Q=c?dT#dGM~gR& zO@Y1ZP__mlcd-zCLLVC>R;W|WlF69L|X{8;KRjmZI9`qNK6&LKYBz_AaJ`^}l z{qB#477P*&p=UzggZ4gw>O|O` zA`>;+V+~ADHP!>QU=3t6yTv;{al02;1?VZ`)!qX1Nq~uYe-WVBGZL;Yyfz~R|p(5`W~wo_&R0)Kn%^eyORfGfU7k?MNDSD zgM{Fi3>?uEy`H=utmclfFx*f^tHo*;U}->@_}>k*THs#;B;-Izi)4G_8j79CtE@q9 zAhlzwpqb?uDX!HPdEB@^dd5Gz|JATjO>tGvFK}!vz8++bFt7Zvq8)56`(4N}(U#m-tA6<+ zc1h#12fu)++0N45-8z~F1SnGha!w^C2c8=+_bDF;;y<%Jp z8T;-Bjfqe>=T>RLKKn(-DD_*Rmnu-h3-$5JW0&po+d}85aTPxX9G8rh76Kz(B2GO# z1rh*Va?v>bU%K(tBR1}Ov64PBss9B@U+$*2y!5}Ny-|xdojpUJ`Pb?eC@qXP>FRpO z)$0E*PpL)bzhcx+9Na8h{d5&wB4YRBdg(W_FLYOZT0xgt5y>ouOl``^rrtq04S%^G zUZDwqZHT`HGGfjnD{D0?C=t#mhC`vQuu=)3K&DULW05Da(bL(UK|umsJ2M;>?*HD~ z&+rq_a|x@Z1t|DaiDdwTx%thw*Nx?_DFqBB{x2op&x}@3D?vK|SHgau2a?025V!H$ z`bgFqaTW%tmcpy%q)P!|n0FAet^o}{`aw`(Qa z7nO+^>~H}Jxx{dT2B#0ds#%NY@VgDrCNg`GJeiH2&tBcnkDC8yXbmEe#t-7EtNV37 z5zE=ZQrB@D@P6Yy{u-r`Bv3XnJgN8Z0W)&vk10pkSFfP(SR zZ3+?a)Bw%;>XHXWim>HM2*+$-*+=&i$qibKkM-0N(EZJHFJv7F_wPWyP@21B5q>-x zzPCh2OKp_jTDVX!T^el>KU>*(_eF`xg3F#&n(CNNKp;+_v?~6D6&m`^9$o02%(xwJ z;=Jo0zZA>dhXraCw#osOURBTBt$WRh+^xP^p_^TrmiX4RVk6&26-Q{Uaph;NX)ZFV zE~BSP#?Ce|)~_k{%zQLhyHslbvUtWK$NX+~{rr$lb4=1hG{u&5O$R!7h3Hq+1eAT9 zi`uk67X;6_1US0}!!%_nw7y)(d>D`9TR+fXjn#~e`xzkZTU%1xbF@v5&lNh{hYIg& zIS;~}=30rK+_`0Rl7?C{b0TD~)V#^aW9idBi!hR@M+0aZ(h?r)|9Omxjc)N~4BKJe z4y=d_fC8m=-!^0zgUoN!VjM}uuXk~_B%!M%^Cu=+gq#Mo6(jxQ1j8Gz4Xp9he=h8&9mHfM3q zvin?%@40XltT+B5p)FVP%ITcoQ^5+)6<#N5Usk{i4?c9{Z0BjS$Cm4{k1Z`>PlPEx^(#g@?~6NhhpCMWI_cVh z(S=0oFb_%)b8L8&8b%xsNjWxF?Q9prB`Do*~yws@cs_h=rx{ zuK35@I$abBu>8mntd^=xy}hOW-uDFC`VXr3w=+15EE&Q9+ae}-syF`KMJ)C9uTBc6 zj;H#M)g^U1uMWVl6Zuuk*M`gfpqEamJo^*uPeF+TY10jjB)fR)#ibQ$OLkhlYe7wT zO^tpvdv^gb@kV^LO@llRxcf$LvVMexZ2(UJeplgss_vWpbn(w-pS7otPguEX%^Mth zBU@Wnx4xr8*r`}@xqAIRc%#z@0ffEP<*Jp*{b;fJ&J=mDeAG`0_&^5^LMb@GX;my3 zAN8j+0Ri-bDQezaTU)!jDHeoT{S;hvD9d&haOF{oqO%LqLey(e<-t$%wTY7CM&j3R{N~h#@1;h2)bT(6`AHSx$Hr~TrPLQ+vVsn-ww{z; zT*-iAKXTn;(=d!3!nJw4N z*vk0J<)4FBHIknHt_aOrl5z9E0guhS93ibLcx!mJ50>qSK56PW{QK%7760OA*s_KT zkDk^PAPf!$Z0ON!pLA3Gszhw3X-D!x)q1pVyhY5Y~jqS_6nJ%9(($ z_?{v5e32}+x3=&62{iiy)aN)u!}%l4dQ#dbrsa@xM@n5@`Ph3?%x?F9e(8sXJ?`qA z$qzegSj_(bD!_iU#xNWdYt|T2pQPcu`tPpl1f51g0Di@j%?fx{>2p|0N^0MX3d_qQ z_A#yfEHFAc`V|GWJptsajmyg_V}OYxSAr-FZv@0wTTIc%tgWs0&89O}AgzGF)=fig zS&pE#M|T?K4MIXgxt|fxGC*N|R>h_=j3^r<2wa6fLG+%K5`AkST#cCfqgp|{%U#r7 zUyG|(oH0SmxfISJfavOf5H=!=qubUXIQhnX`OkvA-}7>_uI~mESsJ7w6V_ZgO-wBd>kWW*p1fROX_i}F&LzL ztNs&}?x70!{>+2yjJE`*tN%TNM%)iE>xj-VWDE7MUQ0qFaA{OJBRb`e2QQ>1i+N&p zmL`VWJTbXjLP&*k3dKmI)Z569ch#@@a7nz#ha?^p+^mhQ9H4)Gp>nR_>L}-VkC~e% z9kw(LpVVPS&TUn(gd*u{axO+MShwC@E9DNWj2(o>1{*Y{rx?I$)U1JLH*hpU8XV*!NjJ^+YdN7NXX=lfJs za_^_4z*yA*CE4$=Pvs53zd0C{_nCN4)?z8c3`-d>hlp?qbo-&*eE4v?{2>;ynjent z$_tr~X+^_mLl(foL;1?IRPu_Te?>eNm$z>@w}Q7n0+n%b9UV(V#Q-C-^9(cxAf&eU zyb*g3DV6)^7d2xbq)ESQdVZcVOsmtC_SziDl!8qexOahfb=SOlX}%>OlrU-j!RRLs z@Pd>@h!M0$#T$L%m#YwP3DbsWpgb7}c#E!cY-f9nXD=x>2=G0yxz4ciA$QaP-zl}Y zeMeF}Z`kF3!#(;GJRdyg)r=of@*`}SQuGH(2_VHSG&VKWNM?zCGdkN&GL2*v1c!a= zR=TD}AW4Q#hPq_oC!Ca_BWI`}W^zIJg5ll6Gi&%RQbw7hYrwy%8|=-d@-3|Q=c*28 z(}G-k(w!TfO)&$mDzCN+le3{Sx}o3~XD1(=#mZF*-lty5`ouDE|I7T${`F^c`mYn@ zikNxBCDfQ8&(!TR-yuC<0=y^!$S{#hc(v_I#fyr5j8_{&?jruG7}r+okLRj_f!q~I z82tH@1`qi65He>9!{O*q%DrR0vr386$~4%OXNUZEKj*wZ#L>p=nZyQHfE*zmATJo$n7!lq|fn8@8~6wa+a4fZE6+w-~9 z%mB+s{z~J*LjUmjSKq}Gkq;T$h`TBa{W0ZIhDs9rt`a@BdES_?)x{DDJKCNXtRoI@ z+hv<|fs5M4r+SDVtmvcZ(3;rFy(6OH?v{m?izr5&Vu=;3RUzMIJ6hC&WZEq}* z@-E_tU^)+Zt8LrgGJ5X;F5d*_cFpN_Yb%YbocUTp&m z>4@)X)%LA#8om71_iuQ5dO9fP9fXiE9VG9+{lfD@c}j3-Q52EOxBAbFJP88HZD`@GH_+QeiSaXITR~!ijz3I2I$7WyqXYih5?-N@2;U%%=5#E zJD`)j?&iSFYm_&>v%9-IR{As)+ry6Cs)ViptH^JKRSGh)&J5s%XXX$v9yyJZuEo>CFvudjbN&GwpL zO`kMats0dy&^+Vbl52`AaOKx04he5CMHd|yy5@0u#G%qPNuOSHjwvC-J{naPYgTrF zmGvuZU-y-MYyB<1)@I`uQC2?w*~^2G#E}zrQbZw#t{Tt#Fj$CGN%mwcx>l zMCiG7`Q7rrGx#pz8NfFod~%WP|;k$dC|r%4mZ`pEz=Jb1wlHSDI2EcWgTs7Gu7WU}VHnNbV<{@*^DZI4)yLFH}i4762d-#oqw8kLF<)xA{Q< z`;?m{I!~mcu!;6dC*%6XUe*y?#syo8;KhZ7USP1Nv|>8AC|B4;lY=PJ87R|X(d8!; z(Q9go4Om8|_cP-IzB!C8#DA3*y>d>t(<$Lo1!<6{}OmB&Q0w`tK zpunPrmVZDVk-pci49vNG=swkk8Eg^JlRl6ssigSR=cLzX2^jwAs$_e6I)dqQI(TSz3laq*yI1BlAN-6@*tKRr_yC z^3v?4<|YNSR{Y&p^2ymLT=8_VH@2i?@}|)Z3s2eV%~@6IjcT&AsOXALA)&hd5p;Fb~eui7L%qz z@9!>1EUM-%@43E-jaB2$xVVtwA~riOwx1F3Xy{9XrWf^t*gt{P)^p|G+uLIi<1fC4 z6X|5jauCH2Hx?e2Ya3cSWj(&nhh_fK2LB@_!Qm_ODDo`ex)Hc7`r;c;|0Hk(f&h*l zZ##H_gi9@&hazN>kd^$^WKF+XD}8q(v{&Fq`RXBlg@zE@R>6hqI=_vd0PO7nKrbnk z@f48p(#`FA05WIZNn{1#fLce$u-+SqWn4*RR7tQ0ig4)4Tj3FDdX-Zw#4#X%1t_)R zK;0~Gkj)7yI3i9fPj?3Ms1gCZ4mn$Xfj}fqIADlDZQ#vo`4L;%^({T+d}L`Ct@|eg z`!g8|?fsjQ^0M(?!Cc|;S(-Grdw72DcQ@lIux0>&gMvl!BXHVIRQ4x0pJ!58OD}-= z(d>`BN@19*O;OuTd%p!CONH^gKLJAW2kl1;K*9PJB<#_umaVxDk~?4 z1s05dd<8&8;&yEb0Lr4FEIyR)fo1C1ix)H!ZF}br4iEjv#30bw2|g5{A-(`PT|FgX zFz8-jWHfpKRVQ{h`V4caiW~4wd2!CFrz$y z?)VZ#xB{k~QfP!lx{7Bg+0pC20vw;Y{*#8Mr>h2uhl;8GO_pS?k5w^Jr=3cdL{`I? zrq@Uf(rn_W+AZ3FqBFhHyGFn71HI(|HqLLmVE71SGhFL%^O!vB7)6xi4&1%uY9en z&%vqp2A-~52(D?nvpB0)Y5XNq@#s&@E$eI2odgQ9f*4O z>3x7T?n-Kz<(@Js>A>~PUq<--&w1mITT-`o9v$*X@3~vD-xqJ2s%p=YyvxWp-pkph zLnd?-Ql~?bao(q2!yc`PJKZTimc9Q3X&zz!JJaM*`+JzY#I?C#vxR3{*ZMU?^bwP7 zL*E}WjvHic1&3u`A7igWYNxKPCaE6O-4;72t+r3mwiQe4bgo{m7v(yNRJ+q#Lww!^ z^Y89UNR6K=aZGIvBZ!;=QaeL59>@Z1rQdDFrzrU$aWqG*d?#XnlTL?jBQsD6>rwLJ z5j}+&h91nk@H>qN=?nMqVGIusckRbIDdsr_?mW(eKLKzBe?)mFI_D_31bkIyg9>EDnT_{u@w0f?MffxBu0X$WB_F0~Rsfu#Lh~ zTm0alZV8TOyv&9kg&IFH`3XQFAJXP{IjKAzSbG7ApP^?q6~`Cv;l|UvBceRSQW|BG z;dq1#-8HKE3%P&giZ&$1>o64k=$Z-=c`D!&;rY93&207i4hoyX0)yhJRcm|n9Xtq? zZO;=r-|&ZX8)l)>(Ic|H6B`T-U0&-gdE5RsB(!b?iwESEz=|T{XA8-p$44L8KIWYl z&8rLv8fp?XSDESwlff8-&$|$8y|kqyp53@Pr1BzbPA{ypta_$S!X#H^bP%#j`mJKCG~|bt$oId5d}>X^bBGWimze^jBqiTt@>~M znAW8JQ#_RidSczmVP~|r9%RuCYuU>+Dm-rWADZfaDZx|Y^}_YV{k2A0#mxtE8r4c& z8<(O|`}>KX^!tU!-QWsFAs;?le^K^svf65Yn=9eK*N-eoWeMQ5aj&&AM{`!vn&z}p zb4LEXLZ5y7x=o51%^~}1t()ni+U?s_o9NlaP0loN0uBDw6tOE0nYR6P zizCuLb(~$q;`Uv#iLu`Htq-~z;@|Q_*J-+~RD)+>z6i&CjR+#mb)H!G!<)M86vxRf z>ezX$v1bO5W)=Vq5>s#_8HI(Y_LV039I)@JkT>;L2kcL!0A7g&s zan-ftDaV|GS@ZsI4SND z-M4tHBetuW_5h^w%+ZmWA}sx*(8}R#K)Ii?_W6e)ha=Ge%>W%Hsjg6F8sL^iCk#id z-=ChH{pl&IHZCn6uug{t2#Yjwvq1Y;CEQwEMFo&h|7E=>cK1=8D(Qa2w)Nsqis>Ce znp^A*IU_<_hYPv69e~#b&X#``jXgDZ+DX?;U%dN$)o7QUcUz$45&TN?fO@K5y7ijv zeyn5KY4SncgSm8)3YxWYANo#apeW3e_m5Uz@nv*3)0x4sf#vJpE*HXjr%hwwHBTUL zsQ2P@FsrR48uo7T?4hX3k7o7mS&=VN*ieK(LsPh7LFWV=Xp z^27atSyoD=RAqzaax)niomIw^Wnj6mTFU_muFc%i)cE`Y?2&O}uGWK9&YKwxVx4hf z$cH&1ATwS}g*{woD=Fya>WN)uMmTQ_Prh(HVuB5-6{O|c^7{q6lyv77P|$)CYceEC zd{xY1x6QMsMl)!S$QM*Y_dv<-CEy5ns&@=W-?Q);HeP`x0*6o~0L~ z9ou+r_7cJt_gCAI`;3z({hUTra@LsZi<{Q5q%ZrDzAA7O640a_Yf^?^p40?EX!fm3 z=IxAhIBmwE70ZUwn(xYA&ySJ@@T4Nv-{^@t+u|oz|PLj(!rch z-wPG|jfl;s_CI}2*L_w(Iu2w`mVwBE&k5>Gp#xVcvAnZ6?%MzSMFFsb*xvBzjA}RK z!sh`2bp_mbv=6ZGCW3OFeKZPrWY|F`Ie;8j(v7t|7JPDt5R#-&h*(^t86m`^U300k zJUjk=ll?p(XMV4#iK(g*qago(`YOdW{1?HXWU>B@zE1L7Cq>(RlZUe-ilWey1-KSj zs^--cCEr6t2xS)fM>mluk^mohly8}`@L+#XGdchZ8o=WVj{m;cn;DK`qnHc;Xc~tU zl+C-*@Ts*1(`#vI$;*RwB3=7!1Bd~>z9Z3D&w(Ca;KuPyX`7{87XlO{kpyt$N}r;4!qYtX4Nz1*@C}s8<*)-3l^!mlWOFp><5y;s{Ho0=8qE5)~HDE`ed&Z|1jQogYLg_`ZZiKPFAeL ze6-)zrOJnQIDdklFsyYLc63wS6N9~1GB7ZI5X3|R6(L@QDMKA*m>A7Ad^aqV+~yPX z(g~75f?<6QhS8Ud=5F0!arC?INeI2|9@xW?pL(9OJeEF1mFua3`z`nnltd863wm1u zPzquW4v#DC!;s{Q@!8)j_#M^`RR;?IH8n<^RXa%ez9+ zJz6ns=j9Ug(h?N~(NhS+(rklAa}-$-@RT3` z3+18+q^CeAkLq7i0I;L!fxJOy{302#*n>G4!Yp~%lQ=u}fvIp9g*p5e4Fwd?=oi-B zSfJEW^qpRyfxPo7$y2r);J&m}t9!=B-GC&d3_N3j# z-az0uC;3kqaT^Ii#Gz@&5vX91yheRc$|SQuUf)Bf*l|#_;Acc%O@X9wxHMmS>gm9P z-B6xUcaB=b>Vxlg`gYa63%6Uo_2xO81ijj61No@ueCTH}Xqo@~SyfiU-@9@w z;ijBtz0K;&C-ba&0oI_F9j5sV{$@0%JIrQ*bP+tyh^pMQzX)C2HiV(l;R8HY`2#Fo z!pXmb8^%}r{Cj>nMd@;R)I7#*s0?ti-3wyvGq-dF(d>;Z33m2xc8m+SmUE`MC+x%5 z=F3VIMW}zW41h7-;@9W0V(dahSa3;~bhAHa>5#PB?6YjD@Lu7aC%OaI12Fv{8MY6c2*$J-;B(#_C46K%vFix1b&Q! zQGO=vHmwo~+oAYDxqsOh#377z?%1*km^mSftYT z6gV-Yg!wO1AH14Nc$>jLs8 z@c&rL_1l{G5*PJdFIxUfjbCx|jbNY(^ai*_C=4?}pVKj(a5SxSBIUjGE6^3ZaBW`Q zN+#^&^Y!DLD-CZ+cAb4fPx{MAAEJ5VAL}uXzFBJA?0F&)U`05In93;s^D7@0xS?Ho zSeitu~m#|7`#&`(33#yyp5D3TP#f1c zLp63i_0Od;E<}-*R}1ZA)rDy>l^-nK$BsHkQv*APEKdh3Ucx*eJ0r%+{nmG8ys^bD zMhn(=@*ID3=3HcWz7RBaB*w7Ul9ZtZUWEH^VfxugE)(V+90RS>(QJ|?vVAARu5_Jq z$jGq)cd!K4qet+?sgIURc}*HV67UD|UDx65moqd#dib_+cT(j8JD&l5gYZsUL;ELJ zhE+(S0Gj-AI$Z-Syp(bTlrL2k(#-IRaRKqVY*W+TY$bG=2Iv;Zk6gEGBlZt82Vw;{ zsTfuF?{(|}Vn+<5pgV9Z^6m+cn@2Ce7v_I;Jv;n#K-dnaxJ1e!(R2`HA_pfG<&^-* z(6-(ykwVTWieLI&bKlU=1j;aDR6V&z`R&PmxoQsu2veE{{+XYE=0TAv*zLmUp&d98 zyQobK5b1ASC=rkX{~nk7ol=C(V|?iPCa`xw)(h+oAbOsRqx_nqUxuSwXfOc7wF4tr z{}Ib_6~Y|~vRFJjJ*)woUufj6N9fuIEh;J?>!uQQ)XlfDx5q?5Grq`v))Pu_JGyYC zrjNH<`EWkA%*}UqR)%svfFmgDfgjv9WEr@jX~CZsP+LC4sm^zvVIkP=PLk5e3Scz@ z5ulX@K)C|2eh08wCB@+)U-@D2ZCp#m%3^ml`Dop-;|;$`dlVX!&-V(i8Kfy4mkB}YHp}b(%f}344K%^w(8a@-?W+)VK+&0k+YCVf$zVOpN zu`m|YVH(=bBetG=F9Q-%bftZJ_duh@zPC-e2=}zAvznP%+wpVNe)M#aF+=sh`-HL*|nd7ydeb1T_v_th$>0dZGkv zy|Q6u?gMZkzk@%rvo0*IWuJ89TJ?)$Sn9pzIw7Hf_454op-q>aSvy_cNoPHGguNZC z;_)m{jJyA0cxfN7X{L8N4D-PkWucLxRj*FN%O^V?Sy`Jop;mKd%ZZgnc^AGv5-xNe z0vfRcW*+Omu8B~e=)7*ni_9c0U#0saFV?T*b^V>r&B}x3)7$~U?WeT^Vb=|ne;M3h z#@-ea>Ei@B9!R_EGTrJAfs~9aP&dI(aLJ)TN1j3{mKJtaVEPK|BF;Wj4g__^APA% z=~D%^*^5Nk{_uPQLHG&tMy?ru8g!x9JKpUCGXPJVF#H1(6a~?DK&xswcKxRrmlH+N33~uY7Q0% zxSy*)vW6tqv@+j*xJ-G{7LrOzH|{c#GR;WnRz`|;vH(W$04W^%A3)SD1#rPi()Nhk z&{awzi1LyW(Z>Unr=&R1o?c$dlm`N?R5^R60S-K)kjl)C{eS)(fW4YA3*nTh!Jn?W zkb7U;wk_c)V32LYs|&TSC@CrZGv#_DqL={_5huj?3)WYZ^OoTCmb9krurnQ?f32GfWeen^VnQPytO1)SCgyxtyIcokm0KG`JN zq!0F)canWqNw;Yp_1cE?Xei5kg8k@969WIlTGV>#;wphCfOy>$JMjF$!d&lWzBvqk zyG^vzFV&yu&mC=(;(L4(6X5|iDc^SFS%A1ltR?qHF1i_ZluNW)#=&D?RZR z4Y~sxs5Z|T#^+s`=L^z2q9{@s9QrIc@b!Kk&|NVk@+qLH$Ujxlk@$Au#}BBC%}urU z@MH*1oTY1f6eg$+95G?l!DMpG(9$bd?O0u|n^^k*zypT?W9f4AV@_9f%72g7eUdRQ z`6DFTr1#9RGUovXusZ={NXayvE<9^OEMhtfLjp~Bt^0x1#cj-?wkCNu&v;rnfY!U6 z%n-4Y-|Ba$eCXiug62zBV1fAn;t6E*U0sTm^3SY76JbRtM9R!=$*QoWfEJ7+W^(yHTx0;C(kza00~|o5EQeF92$O0e~sa177{i!-E#UI^-hHq#0m>y#)A0u42$tKr;Cp zK@+8zS9GT!=;@th3VZ^TcgdW=_t}>QonBc3j06Dq(|T$Z$4Zyv1h(Zfs{1``e!&#$ zcoy4rnwWqMYx}!{Y;5@qi|i{Mq#k2D_;z@ITz=w zFeRMx{rg`M<7RaTolWO#RiA4qyzw`p{G6#~lh+ekeg2QUJuW&Oc!d;fTZ95zgT;sZ zR~)U}%Sc0RRx28O^AB zfB(*bwV=Z?9GetZN=Wh`#)TiYrOTxs)#I{EGPaHVFzqBtm?+4m6FAqX;Oy)al?3K~ zAE23Pz(&O2Kuo?*)huIT-~3Q4r%`LlZqsQ}8&PbJg65DC!%#F83hm)(Y*RB9@w_*c zh>zG>?0@UJ%HA1vJ71z5@*={Kc6VPoB^y0TpcHveuk#yv8}fHlr!jishk(Zs7-%B2 zj#L)7`Je8nj<9Q)^|VSu;yB=w9=0DYIfg7ISLN~iCo`$t(o}_FZD<@#`8_RZS2EoH z?pNIm-9OVF7)KJKGIudwg4+^<)DPsnf}%o=&?noU*-|eXDhF7>L#9T}spqJu9rxvb z-)c!#3kzzvm`OcVkfx>R?qA8YsVFXB`-IEUv4na$2zI*az{B&wb{>Z|E|Hk?>j#4% z0;?Tic`#_uhW4RFGq3LV0AGaWep-x6T)UX+<}K%|hlq!>0`}zt$EkU%jOmg?N&K6f zwCTn-a2itcsN?efueKkOD?g|D$IX@!n9(c)d79jPvhiC>XSsb395ru4NY@{d38C=! z+0Cj9f0$(Qt)hK7Nt$cr7{Oso@LeeJac8kMScC=t$7xKzA8Hc?E$}*c<(|tqOm1vU zczsjMxT4s*8Lu%Hv?P@;Jg+Ib7re-^FJBSOgQ;ay&d^fvc@EXmBcah0aj6PJ=1Nf8*8#mZ6O#3O4>m^VV0KS>>B9dB$fCcH|Wyg0W+=GOjaz z1W3<&9<>TW$ewacm>x?Jh0aq zsOZk^?O<@{4Qi;CWB;y5q#DR^=L>e^QZg%EvA`6D3tLf*FkydXVNifKwm&4IFB!Ry ztl<=~-y+Iq<77VDF~90fS~I_ea*Seim%URz4|Xe_n@3rH_G0Hs23aJ~hgQdXCVyjb zb%-kOb$ovf9^nBC$e3`D$}7s!$uP6iO2K#%q~ErmDM$~VbRUZIje_jelA(vKMniWs zfV?0@{h%b->yclty*drzrlb?}{fZCh`KxL+F8(#o#GhE?ezV7GP5DvF^3Vv|W;V7$ zQ2gpxZo%@$w-C48Bjt(|ic%kH>305fluHYJW+-t_)30)}w;8d2GuT=CYVzP$j>t4$ zZfo|F{hy8-HgYZtkRlao54pIe@#LGzs-Mv{37!FT*ibrm>_}VYoxx)PTgMu2ZUHXu z3k}2>Z+l7Jd-#HfS*q3Wl`G489GbqoKC~%+ zZ1SfF@Iwc(xpQ$O4e)81%zk#`)G6n3&DPFJ3y^w?WJuk`^2C{IW{)G7F-$UKj@y-^ zg04)U|LU8b3Z$VRockp|7LVPgrCgBvv5R+nXzk5I&O60z>giIKXgA4oXIIB$14@QK z)0dl{2k}~(GR)qkXWAv7$BVWWL)8y_b6#nMUgljp9$X-k$#MSG{nhd*Rqm;naM3sS zG|MzY)FYVbWrGv4#pWSS()N|K4|BP}Ypajn6%OZcM0w+623oRbaq@@LX#;Rk|0j`S{*wx9+)|N76&d9(3=*Xi3(s@kp2M}L?Q-R1)2I2#f(UzG#hVvIARg0?g(=XOy z$}-Lk2PS6EGE?rU{$BU?=MA(ftyvy_&2su$dXp_}oA-nLPFi)Prjna04Kgm)(o$Dt zz@hApoO^1otF5i!-}vtg5`Nq_dMu8C_;~!W$TsZ+<7f(FU3E@ejtoEFD@dj*cc|s1 zkxS9ua$Mgk2eJz^pcZElP7a%6+GW?>Not)t8fMXhvHu(FSwE?E^O2wT9DEwdj0o zP;odGmnJmZGGt;+ip%@A({(;Dmbf9@9jnCQ4{yMWBZMzDg-jkL_6y!FnBzt&Yy4-s zZ5~yAciu&Uk2=#Yhke&Rsd7NEckjo_*U0BmSroGeDBY>rA%Z60$usG_P_m-Mm?7^o z9{?Z~sp0?FN&Bwj0vzF+uJf=uaSS+q7TI{GcN)H3Q+Io7QgJ<~2x3Z~*m0}m*Bi4e zU<2{ThY4ZXfp^O@Tf24ZX5%93=3nM zdnP)+5ERrr53BeCcU5y}IY604S~uzOJ^EE#Utv>YkdKSF^@pl5aPr<7$wGbivxR9j z$3Qv9-Ekfb6)S_e)Dz1Fk0ZjsFYWyTQ-?Eb=){JjpG^xRtz37<>ehY~>??wN1bcTT z@7un>4pO}UM~-xuJykrn4--^+oOqO8p5e_V&|0rDe3wJZhuz!5Uv$;{zuDST#={%t z$n5`VqtbFh%s3ZMZWjhOJ`0VQ*nzBD~V-4OYL5!ec@rh+Gf11MGS1){ptWjYV<;vYRt=ZUKq64nVUDk2BZXJ60Vm7x2Om zq@sB-TcRmUPekvRh;n33c$dVL9mg>4!sLCY>EbhariN|p{T#0J&Y9*L2g#EP89ug0 zegydud`!*W>c@3KTV^lTwiK(Z*n@pXdz_lTMPUe!dYAyGtYXPbsgC<Q+wujD>-?)DoQb)*Pt&UEjfI@!LIp1_`N zU;1=-yd{Vv2u4{VD>EwTj7?~mH-jVdA)g5dQCGT6cHsqer`V?SvY^i=b~ODxF>aP> z-z*ud&X2TrCIM$v4ZM~qM=Njq7qqYaC)3C0kc6yr_;{^(xYn6>s#6DZ%`0GLL1<`9XURk)@owqsJABT!6PeX%z_Ux% zQ&W@l(r!^m;Na+eFP&zIN*qIjv#q*6dU5*UlH?a;U&-oy%Ic6-A)%FJ0&-c7d2=IO5(v$92 zQ}$0Q`}GCBDC?1edItBf&5D98HM^hBR`ry`jlS=(|JB2&87}&f5M~m<;Dm1n!ZMYV zKL*iZ>C&HRfUA-p!!7Rk=)U+>Jm;C}*AZs^^nx@x$ny!iEf$LVJf%5~Q0-sz9DztC90_pN zh%j*QtD4xwHqmrNY}D!e=3(}t>pg;6a0GZb@Q}=~Q~l{fkxk}&AD<@`ml*JD6C-V$ z%6`BZ2i`z9iyB)DbL%@GioHax9xGB_CiB{j6@uT|F@MsH#Ol^J{CpmLuS|VO6;PLW ze3f;0~uX_zwIOTCT;u=!`~<`>O707k9dX7b<+?Z$Gy|W*=5-;?N2J7nJS2! zhTrI19E@1g=W3&^ekx3RO0zBCDR!L4F32I&g(!?0rSOn0*u8ME%q&FpSTiruu-P-?dDM!at!$LakT7 znZJ^`{9!z$LPY{k1{$p|&G5z+VBouff%>#*!k3H$2TtENJ)Bd8<-=N6kqDd(8huEA zGw&zkSkyfX`_{k%>%ha?iNiAfz}b}3r(7{jgBBB^rhKouNo;7PEOQDK-hi^2UKwOz~$N-je!^ZL)I9e~F+_B^LoQ+SPMhh%I2VzPP3hx8C zD>c~@=r=nIL%All-t~j*ivgHl`r@*oQsD1f$#sr&!`jbHBkhLT#(geXXo=NU8u+B< z#OAh|PRVF;L;s!$U*F?+CLFo#@K~It@6*}zcRjjZ+`}O^z;jfxU3RwX%w*gwDHxR> zmx9J}tYA-=azji{yB}SzyKG38D|@~2a{pY0Fn6E$yxCK${D8w7_o01W`ye;(7bfcl zwuYSDaLM`yb{)p(4x>zOKqs6a#%zrqi9~jx&rhP9Uk^+ky;&mEE&K+~x5qvRY?O`n zm2Y=R%z(!7+ppInrHX=e0*|n%!BUs<=k=4>9W~eH5zQ*%?kXj?%dIvH7G#UN>Bo7# zVvy=b>IwaCwhlk@bKD%d|BG)DI=mb59Dm7~deU=e;aNzYRY=zB%o1>iH3Rs3Oh~=8 zLUXsGdD?rJ{^!BDuhx;abC-fwZ19`iFB@a+@&t@5liR)Kp5%xTe+0i{s7*7ER*u>( z3G(B%tetW)ORlR)2vnD*8#zZ;T^n^@QmDGJ-=pFW{iNmHfUsYwM8V;BMvQ*_@UG^v zS8~}2p9b9lf<=?BuF?8NOQQfbd8L?X8}f?Y(N{2dYno#m@*C*DOx@B+$KU0P9+l@R z^0Nm^-+^W-EHXj!mqZo^se(Lm+ic%>PLV~~uOlG8X>eKwEmlSgH5=Olh~n+9Pn$aU zMiVML5oicZzq3jdMK?DL|4nJtYyL_eaD?Jaj&G(Oi!uUj}JQ% z%oo5g<`H&c(<0RLHHEWttnZiDb8NGE!itvSlN`Lj%;>1$nUfB^duMM;_jj?Y5H(6~ z1=o1;@gpAvR4s><2*ggGIMF`S{K?1?BziGgKrLPCp>YMZW1ioH(}cO$utKKrmF?hj z8|$szqUYG4wXS{7=CjKZ19~qg=*CE2#AKsvR708apawP)aq+j~(QBYqF<5iigr4y%iNu_=M) z9XDNG9n>p0x{yCQ%MF}npH}elsHx=ZUA5~hHyP9ZK^Ao1KYN|3F)>_pdc7fjKoc~< zy7`S@KY8EDe36eDagVfbqqdpPUE;>W+==qJfYz_oiYN{%@-Lm-IV?e@`5{zhu)kO$ z^=DL|Q%xFkk?}Tew8)rbs}QI7%*wxtzIm5tZ_NW#CYKIOI?c=4Ioyd^PYQ<(rS=Il(6QZr@DR=He^&&*{X@eA(BBHQ} zB->>mU~eht_a#CyNIAmuQA^z)vQQ9CSK;=3RUvw;yXET&uMCnl&-UKbXW4ojYAd=n z>=ofzH{{NTMHRL_q36G$xpgP_`3Slh|1o(i%!$h6j1c*m?$+P4%FOv$YEraX5vkGfciT@SI8g9^AaaD`r#S#-V9o*5JcbPWoTTOO5%MWKHX zGRDV3ylPdg5kX>~fAsqUFLqb`%0@%)70{+w^)O8Zbb z*ou2Y;o_CIawqI8QnMeMRV8vr>UJe)uEq8Oq@?fqeVFzW@w$Yu#z=vQH@rP&-&y2r zb3bsFxV(E^7EUg+VP17)YZI|*mFf@vyAfuH5?q~B=KWx_IdM+FwxQO_id|$?v3WQp z9LQL7cXyv6j)Jy>d58Jfg-f*xW33o1WbBo_6`GA8MIkli>%Ibuvn;&tvgtch4D5X} zhQAeOM9mnS9V_Z)B)QORQBj@kYpXvrsU%$8t?bkpNtuC~Hr2U3kC#VjUgP$kpO{^6A@6gQhnaz}eo4}s_w{^i0-$E7eTWThmB=%8{ zr4s9)yPtfHs{GmZ-x;45Znv9x-jU;pM~8?U1C7c;Ewpnn6Eh7u@QF#)+zCY758C!R z;RhlQGid2_5R>d$!Gi&!lZHDi2Sdo8nb(Z&-+|PIu=812uZbyA9Y4$x3Su`k5F#R! zwuthVL{)?@ZrLP6ma-8TV9K7_8IcKqx>Uin5MRW3~gMjj^`>}zj+2{s@ge1^o z@AwP>)(KwqS6i;0P*Uf|kxw=HZi~;4KkZl25egTx&zM1Wd~9tCj8#zwtmTUrCZ3-f z#vCTEPAg3`MWugM2~_EEeiE?Q?RPUCRa*PbJ8p_)61n=gP@3ljbLV<7@iDwA6a229 zP?96vl&USfQavE7H)^&$ks5I^s8w&UHFs*;dDhP)bIT809&UL7^2cwP#V@a9JBnR( zU5T@4jlXKVbpzV%wm{Py1QWX8S4p6a8#gJ>0FzUGHzj2Bw=UHW5~fa``2!3R|poQtb1GRoc zz5a%WrgA@&TtCVFBA`PdV>7A8Jxx0pT3jht_%LxIQa}ukO%z8iy&SsEb5j~BWVq-g z1*HDPeu0k%-!#2N^}Ui*0DHR|g7S&&sS>T6dglCP}#G`C`2^#gY76DGJQX6;V z97zPIur!*N3weXu3of}?e0f&pz7oP4lHRwY`Ovm{aePpH@bH-0YrCms%P+eeBK2m6 z%khjO-`-jM6kI@PJc#HnCu$6l+EA=^s-B=pp{liUu|toO*TfZ$kBL7lHrC8`jjTe1`r{}Mjsyc=5>Av)z+^% zj?|OFO!$sJvB8(KRD*5se8Fufvu(InJ@n78p-K5{Y@0m4IgcERH|A5xV`}L2+h+z* zx9?y^R0^G$5zLz-D#+B(46R@ogLiv$*Nz|0LV@LaAdOqDaZ*g*z%DKlp(!grzb+|N zaU^%iQ@H*8iu0((g>UjgOlY+US!RKD1(dh)JvP^|ak_POb4PpGvE;`Q*CT#>=S&eS zzRczO%=!33Fu;DWUmOcTa>0xmo18B?!d0Pya|KtpIy2Tc0&y^Ef*1&vf9)mmaMoKW zW7meSz99PBT4F-udD349} z#eEG-iq@${{h7s5NBF5~VR!Vqr!l!KZaBxFG$DW9+Rsu;w@#)_xVpceT>!RJZ4-_k zQ-~RbuU~F}dxh{G4tR}8WoX^`$Zrv!Vf=>0C4IO{WV7nqIdz4GK4f}1wESjMjI6sT zNX72-*~`Yw4j)#l-Zikk=nEzJ&PE9Cec4E`i`{B@ePIbpz;Dt3jBjQI=z?uQl-vrh zHR}TPJ-d);NwcNu#ZeoZuoC~7F*FO6cgaIZ_2B*D+((w+rueq)AKSj1%st*xHA+Ye z2c1ZP!6xP~EwAT%7kzjp*4mjpBADw9i==;9_kq0i&};7^B#q(zYyDA4nG8FP{_r+@ zi?n+u)y?fqwz8+Xv485VKPYEr-&V7Kvm$FE3)thBT6d@&L1>Wi6`(SA<5Nr{Ys}1w zA#n$T%TbD2CacM_Si{$iZ;4v4jFz+fcy5-6GxHm?e*X=g*f&8mYDlJP6ZUDR&xjlZ z{nCO!D|R^nQsq0z4je)HLLcpVR)zlXO@P&GIc4EnbMXzxRMmO-S}n5x0-ci)PIi2# zyp=lFoTBndDy##Evc1}%wj4a!FKsVH3$vLbK@JPKVo2W^Y^LVGtkP^UVZVw4lPBNv zI-C}ixm}*=rN)aUK4>@W^b6;H0w+t7y%NftQ@BMRv|Qba7uqc!s@x-jFj9bS=qO09 zbIKnwkQJh`m0;jOt5p^8VPWR|N7c;sAD?#WE_wV^cTZ6eP?1^SioU+njy1I4FHUa% z#Hsyr+HX%fTnLMoz{Kr^ONTA*#Ix+R1{< zf0}pE&@sX00>g-)h_AWm@F?5phKLz6@xNZFIOkpt&NQo+O!BqwQr>xs1uQ(AZu%`9 zYPfYKjbsjKXwcghnuq|jnIFTtw)VxRKJ*vg z0J1VTnnwA{zr*X6cfVAOvZ7mOc?p?8pK6{FGVOvm@u z@X%`+y>D95?h3E}`i1elejEBfn$9||sqg>eHYfrrN(oFrnJ9uF(jn3cjFJu|q?-XF zKY|KK3>+afx?@tKK|mNV>6DZhNQ};nvEQBF$M3)WvEAL>oqNu?=e(cKm&U6m8CfJ; z)lkn&uOX|^IUaKoPghyl$*`&=HUrZe&4)QxKgkM6Qz)S80GOGVhCksFKs{6A{4A{ zlnCl)zDG!o-6XaDf!<}*T(XCjhF%gzXy_va7!$Q$8aI=ouV<`oM%NUsGWtL6iVtbX zLPE~PE)3M7Mx+MX?1eLRVZu{gD=bM#GH@o`4ASi#tJ;F7iWYV z-@ie-|Hc55E;0%>Yxy#Oh7Y;j?2dMVBZ3L8w`AVQ+0dApXJKhEI?#vvEed$3n0XGd zRxDa3(NMGydABvHXO3^gKT2EpYT@b-!_&mV?r|Sg%n#Lp`mi;x$ayyfFK;^zdX0f; zrcwa{JPgfS+rYXL;}QvRMi8@zNL@3$TLF@~Oh+!wurFQjK)qVtGOPac*Rf3R-`VwV z%zI4zj!r%tvRpWjNG%gNwSVhe+^JDx*8C;mm5GNdlD#@!9?K=;BA;7ncFEUs9ghmz zwIp7>aKfs7GhSI3e)*CACG2MljzKnl6n<3|X38IoR#fm#CMYNns-2sO&7S#Ew>)dP zV@-O)S%GCkZ-sTN5ubXe{)hAXx8v&~r^YHw5~n* z^8ZW<9g?pATl}w!>9f>aPpD5-ExPzG1=v#GGj99>pGXrr3mlRl(1B+KN1=u7HE^5 zpOdpRNO>IN#Ui#yk-s}~a>=_sr;L623VjTj&|+yVZPRK&)dKeC#{Q1rlw%*+0~xjE zH<}<5DnxWPD}h{kMt{J-v)7}j$3~9*yM4!C^7!B zuglB*Wy*M|CCR5eNc6D||5k46*>UHDL8U_^AFNTP_gU}zXFz!$rFt45awt`bz^T8l z{QXigPiRM4O6n)D?x(M{rfoO#JP-gszBL3?Ke6vO%EDK@!6>7}w3c&zEarh^?LyK^ zicK-)mU5v?SGEmkA0QRj01-zx`FyfTbYMZtpsKkQ`(6XPM#jT!H&@58GxwPqa>r zY;cdDKyC$y`!{#@X2{B&90!nga>7j`roG90|Mjr7qMfo3-|I{P1s%a*J*H!pPcN-R zc-96Ac>7Fu6_aD>c5BUC=H6_hxl8pperI*T0#DOVv9g5HAY5lo4h^E?vsXNyMD>4P zA4q5NZWKaJ5B4kY8v;|!U+PS}g=YB%t7H_TM3&4yeoOT7)I_F!_;>)Ro!J7h2E$GR3mVADn!+bqCbXZTwZU3^AQ)KBEa$Th8Vtza$!&Ae&k`S3t!(Se4XeMa>B5&nLmf6ec?1xFH^EHtU}=zq&!S#J{jHP z4p=7>$$g&xT}!U2UC7T~v`)7Ckl2sAy@U?j9;k=5eD31W{rlWV!TghA((%#^%-xu^ z&JNI-{jj>0^W?2i2jBB9I|f)q6kaF)X_E8XAvHIkPX;U`CQZI~fFiw#xw)+^QC+L?7ZSm!^Yu-h}pdF zTfY=)QBd;vZtTvJBiPq(*rhLmfYhNDkDj9N6@b_Y?l^Vj$8L8Dee$A3WXX8cd2t+j zyL^m3MsM}&V~m`-xwElJi!UujQv(zSGx5*>MduTMUriiBNSNM7ViSD0P_^~U(cmqdDc zdN<&gRG}pvp979S5kNUO6t`%$%1oi3{7*9gjByi3rk!7dWI{tjfeLI-6eUeID5xc| zUwzX&;GHDG2GE#@+W;i)KH{B5Dx6LdcDDLleOPu)z2z@ePxjkFwJ~4}WF%05=l9jY zIA8(&n_B(2hbDj3ca635-uD5K+1(k3<`%$`1m@gsaVj=14hT(RF>@^VfNw=Ww;T{X z;WFz3;W>M;`txXd&IPxCk3%zwXp_j@i5QO2?49CwU5rkTmC=$h>Paz>o{sneyeas8iRMV z*drNmTpYRW3)vOG#zuquV%7o7F&^V|ck<}t2Z>4(|2Hs2I)W+h_VD6m+ki*rm5WL@ zx>5F29WkfhDvSa%U4*Yv^LN79zNpsis$XC-?R5ImBRto)eCo^q)3vD(fr?wOOLk=o zNpVP|0fX(KkGS3Q0(N=-*r1zn80h$tI7fGXbH*9NbM~BdFO@ONCA5V4-hS>nh&DM+ z(2g}nmS}z^i$(0Z_oCnXgqjQqX}{X7P`ePrG)trPuooY8~-d<0W)ILn7eKZHCVoG^CU={`Z zEkwzcXGwA1F!6UPGwi!jQR;v<(BkOV6R`j+;HWEW*bb+X4WD_I-+-`AitB%02|vwA zHD-5}FaSDHHvr8$_hWX}%;#%Wq$KXl{E5qAAgzE|d4Wji;BF8vt;3Sy)18h|Fm%zsA_Ko!Z- z6c9vMeHfAo1QfYVIn~SqDtZh>sY=mB0tcsP$&j#Raf*@;E_)_MIr2Yi4n;i%pdn9A zVtUqD(@&ScJ5vCuP5DBAx24dmr!#XTc8c$28}R6O0Ld``&H`K)ip&jow=A%!-6~Hn z{SiX3UIBQ>hZUqBowzq*7PyfqOkz9Wu$hF{bz21kXVSRa^Z59rKWG;II=XZ?AF@cH z)hUvjRnIT!^rRxA}IshCdI_q;0;5yx9yJvuo0YH#l0h4Cd6L^!k@17IIX-jb> zaKojB%rhvmT8fqwAch;#Gcsh64^jc$0tLeaifIA4=15|-Qv`*~q|~PamwlKb4|Gsp08B2+$(VAX0cwT@EJpFixp?q1tpQyK z56*#s_W^*X9xyp;C&i`9%&=311LmOoId7o5`7=_eQ|pB{3KEnrH|9URFCH!~A+feT z1Dr5m+0x|yEY`S{C9)YT(|5I{O+f6&Cf|$6h{i}Pz(>!COmncY?Gq*hPK-WH<@~vy zEOf0?6<-e=&AiOIDn#fUfkyp{F8`UZdI;chVN0Nyl1Q={lKCn8nBkbRG2#>FBb0dS zzrF+#DWI5%gm}N_;|W2<<{hxdD{rUdwJtAT5#iBMOU8LbWKP@?x2du#Zy8XA)5|rn zLV|_kt^KK``R!P0iV(Qdx#+c!t^(&3PA;OSIo@qbA77bXY+3|~rP?fd3@3cmUSphy zcMo0L^|6D+jgwUL=6%E7xZgC$H5gBy`EQ!j#?02OPNixNsJMfKrEeze^gLsa&M6(%*!sFSWh0SDabyb0lEKNS~@ z$}X0yF-$OI+9S-}9lbcOWy-702*!P<>$<3{ZWkKqT!GiPofMbIf!X~$*qA6O@QZ`? z=M4*v(U3rGx~0HUZdA~(oK#wc`)XLp(i!3+X>kqlWa<3{VV#^+ks;5!ca4p|spZ*D zZRSwaJCsfXxY@~pykMNv&erCw-sLT<$nYmgT!W2EN0PB~dm5;LCjEE`Twdu1P%0Tt zAF|sw70{BrkBfimKh@Q1kLt2D-kKRa{@3Zo#)6kV4a zMSciqfymwzbxD*ItGYB$2S9f z=qD8255;f?Ia`8IG(f1pgW_WVAC3CQXAi;yKc+sTID09bSUPzxC|Ur}($SRv2Bg=( zE1J{wAQFm+uC~#^pK>xlakcpvd%62O~N@$FZS1lq4N?ibCaAj}CRvp-s?d ziZ=!L6)&Bid3i(T;b-e))*v7TVu1Y_9sSPCRvUOAUjX? zWXT>B={+9oRL2IU^q~}fZtl2Kbo(a*2j;DE76J<{%H~@#BVbR@3MHZl8ym-CHFqcA4~>*0tKZU zsd;0}Um!&>&P22v)0hC`DEW8LVfy@_GN^+-aQmVZaij7-HRmAmQ4q*`2Fe6@N6xMQ zHbKHmbD95%zqbJO)dJ--DTYMK+bJ{5Z3uZ)wu2&kV%A;(WXw8JK=lQwBLo;$DD5X; zx=?Z@l46qZn5LJ2&Mm7dn(gO*Z+l9P{Ld&2=GES#e5zJm@Oy|%#>gYR|-iCj0No! z8V#6d=eWn>DGVsZhB6M2&%nR5DbP8P|J(616^GBc|JTD&3>5%H*#-=JfNMb^r-4C}V#T1$TgZ8Uf^s^Lh!^u)zOI4acq_rX-t@Pw{mY_|AdPdRW&)bG z^a^hDn=cD~M8cpfK+ZZoRCq?a6S4Q$B!5WdNEO{?XNdf1Df1D00J>?RwnxLx@JpO9)2Y^>KJ5^HG`XaP}=449d*H=j5!D;Z4^iwn! zGH{WcTW9J$**IXV*gEAew<1v!mgQj83X7#d)WG{+i2wE2ZKt_aD3#S0p;{HXcvc|& zGWotI*4^sY?sckAV3}rvf5ZtG8N4)j5NBnZj$>RlfByWT#`7JS=XZ(EPgZXpgaByd zll@E+9!E+VOpu!^%H*CuFb{GCbjpDKmZD$*8b3b$?|dM?_r;XF)vdg}XpvT5&CSm( zr@L66#VAq6&4jFz7`2v)P z0z$Pzms5nOSrB`_G|@5L%zyqeTin%b5FE(Xj7&81y(-YOuT3^;FGe+gp@`#vY&T(w z>;;J700O;bfCoTMit6j18<-yeo7Qe^Va`lUjE_johWoTGGk|4gK(d;J2_ZZ@E# z{~_GSK!P2`2mE(^LFZgxB=C@op?r$ci~qY0lL{~a!0AZ&Cg4_I7x)TD1w1I?%72}> zvMfO>9qF@h^%OsM_9SpS1oQ{EMgc9!@#FGHKx9ohBN_+~FeU-+ngvRyNl7*U2v%T- zlKDaTq82i-1p^P<=l++E!T^!_0Ek511_gmAbKf0Gzz7hS6ZGX@F3(s^^QRU zJ9ZP$U{ugW;5Fs>8m2|-AjB*}-Kfv6B`)Y(-a<{ZY3^hDMaiQ6oAGwio6283?oP6D z7DWtHOv>bxHkn1YcPVl0Vh^}&%}W`$+81e8E>!z!4Hgo$#O;jo$VstmzrZ-H2Ce3w zXunFZV|WU&k`+7(v`QCH!=IS_-}VlhYFom5vV5w$XCa z`UzBPZr^)81M*CeeKG4yz<{79+jc`;-G2Z5$N#lkbgMp@QUY!D?o#JZeqFj0jTqDA zuXlZ^Jo7yLPT{m^N|$91o4%B-4m~k8p^#Kn$-6c4sPiWxi0&Dz>J?O@=Mz;NCimdN z#ejjGViIz<9`1hgzxwITyxm!^OQYCtj9C@INTbUxeC`t+auV!&S{WN03MYIG^QJ(@)eQ4 z>))~oa{xj$C#QFQeho*7))INol?vHF_rM|>{cP}!_Bu9Vw%`!jX>L=M!_VfWYh!*> zBeM$OhNqf4=mB!1rm3mP^)#aw;chZNvA$_G%{*~93x{7^3tPx=lea!qaLXejC$_$+ z=r>s8`F&KP&-Cy7v}IMNA1|&;^SW4N2MhNUJ;@o*sK*S1X9`)|flrxMK6|7; z!y5E*F{63=+XC5dDcmu7HXz(lwb?4toJu*C_MqJ>)* zUVLv}2oO*WTJk&kuFN%h=7=J*GnH|Fp@*gF%ZWSb=RW?tf1^m=E)v+FLq*^>d0*=0 zo#dT{+P!iTWq~+}udXaf6*MRTCB1R=mk|eK9c=A^#BC&4xQxT{6WSUGs&Hm>uHujC zm@rUu8fLd)1W!zl-yT&q1FS^`Q_<&fojsB>_K^S>5LgPpZJ*Lqtwmpd{SYrWIx2uO83&gxRzaQg4Qg#-Tq6rI;_ew@V`IJ7_XCz@3xRv z*6-?eWg61st}E_lN8U%=B49ssA!Lx}N@>um7AA}6X03uVYO~D)zaZBbC37yMph_-H zu@ppaYPVJtbfTxWq45kHU-h6~hc5g%BvQ+N0MiFNw#PR0uV$E~ySh$)Z?W}{?g0-P z_eW-R$C5P-$1CYf67VR}f39ZCKy}`3IpH?PGzciuXYzD4$9xLl?)FqOFV}uZ%8PFBdkh9#K$K?ik;e|=K2Bib#H2FuY}L`NEW-_yO|-$_;%t|i4)IC)-p zRV#&^a$nKG(qY$@OVwTPNO0Wv3#O-^t>XRt^C5QqR@8JyzRUjVpFeL~!JGNJ%0d3vW?D64XH5HQ1jQ;kU>u4QknE_=QoYW zxwd!(Q^h(>F#^xRG?@1RUxM*#8PYeaG@=U8AV>i>d(I##)kBuY9cG2Zsq#O1sJ@sd za(uij7%YslX*m=TR~^WINM|WG^&G{!P&h~}pmTR!``k#nQ0sg0CPscLE3NvUz!pC{ zDqxHjtx1l%@=M^Qz-qr@diOeS7BS`mYb(K@oHJivP3xOpYRY{*{${b~G>1(Q1H6Gq#?&3ck;NT6-2><#7eaC zVE;5MJnNh-KXrd$oG=P1?LV54=^~pWrbYr@OJYU5yo)w@6wNQ^HcK8hq> z+Wlg3!5vZW^pZS5Tv*(ypHt@&?+&|Ad%cIh4VN^2d#87B6~DI8_#o?Zcl>#&IRUt` z94Pm-tu`!??S~`Ur$X`M&Ba-4?HdqyxI=lP_JBFWMhS-FrCODKUk z9UK+knYc411L5F7vVr0iTP8%O+}md7a=%_=UE*(8TfUF*Za#oK3qEvP^OiUo)gTOZ z72hb453n+Gnx2|kU(dW=bDDh*H5KPr{`6C4XoY&+W0m@~!PTZY4K zM|vC@;v1^cPe%}w)6#9y4vA-$1oFKT0V9I>SE)%aRr>dP4M;6NZZQE(P*d-8=PZxk zX%nya5?JM1SJ0!qYj98rNGO37@(Hc;M6nTR;}~ZSW2@@b`Ki+$le!Y`;@m$?Up_`FUM0~yAQ;on3mmcCoOha|lw5gI^@mZS9dFbx9 zY|>jjz0q;ZpU@xfNQGey>38}3Yuu2p-Iu}V6-q(hgS0_*KXn<4zMP$VgCcfHK{jTB zav7?qzNLQ@8!=l6KfZbduaFm_-iWpx>6OWF$~uesH2IL~=yGIvnycjJB@KT=p< zw?+EoYm&GU9_Gz1%W7O40!G~&(bL%HBsicMN(0WzL4u)e>}mVCLy5;fX&@!y=gUS{ zw*?_S0T0i%t+WJ~tqjG!27bTZ!PMzdVhUpXJgGvoyVghVfG^qVbNGD$lIyQ#7zghO zd$?tHC#yMw_?&%xWPV#hb@&*Sok)1d(2r2allAb5C&C!c{ozhI|MBlwWMH|kgx$F!2QD7)tz%5z`?i}<9h zS_r8bY+i0s4?mu(zQCj>zxXyGynrQV;EET9i=*R4P!&p;2WjQ$zju#!+9h<&GGLQo zwdnEh?$87~<)PByM5c(<=Fh5g^%(Vrt6j?HW7+p5cgL~L*VXI%8mV1mUD`)&8=9MW z8vBd@5o_2c;hVSZiNxDeLl?kCEhS~bEW6>N4pVp*e^&bV?uroaID^lcx^+c~_n?RP zU>=gk=jSL%)tYaRpQGg9rpKg00Gs|k7|ZTEDHk+iLv=z;80l)*R=3s;$q1;@=o0)- zDo}Yx|7y$ihUzUJS;F@nY?|uRngD@*g>kWVibtYH=CPy3TOy~d=Av-Yh(A;dq zHi>5pBcZxiy8hODf4rhC4h8uV_flcvvI+Ch6)Qg!35{fPwg z0_vb4bC(rV)0eWu7HT=Zw^ll;5;+!nc~s{wyqhQ)u`w@d1gY#DL@ z(t0jRYban4U3o?u)Vm0Q+@GHw)fZ+3B+7hw-HWt)_|;4XKHgE{05e59R?Y0X>!{ld$(Q)Ao=O-qD#vF6%5Xh4%{o&4Vg5BI^TxbR+L&A|T`7FvSm@9x>D#l}7@4W{Sc9i|eB%-$S9V8sjY8$8dRv8#^r7Xy zpnlCtcYj_xt3H^y*EyE>7~a5JMH>G!m5FK4?F>F6f8Ua9gpBaiQ2Siv(D9l6Gogm_ z0pfbWr)~OpjgyyVhuH`G?l4FrTV%sP6pc{ZCLal1 zKgQ}7>yW&S-yITe{Vq!(8tLDr0*F}CGw&yVX=CpxtHR!hWGnVzsnjkh*}kbVa*Bs-#LDL#usr>2x3CZ=RxuWDPi-IJsjTHwe1OmKuoqBIhYo{l$WqQ)r+57pB zR-Gx( z4txdzMCp?Nf8Q*=13Obyo|7Cog8E}PiYOSuFvn^@R^vJNMYJ+oOw&FQ;Qx~|n~7Mb z-t<62ABB~Ww_t)wpy#$JN}hzQ6Y3m~*JN(k_-nKHO#V)(q7YZ4k@nwjC34nXC{;2; zwl0GGrB&rr^dIJRPnA=XKvUz|mTSfPr5A7xzR?aL7~!EsryO75E0Me#3wi6n@TG7# z_M*XO!#JN@*f?}!VpC8Xl$Pge*iYZ;q;D+!`6j*I^wSp)#_TP}-$xw!N@ET_W%!Q{ zJ$!4Qb7lPG*_wX0mY__xD)^DqM~T~M->-L%4RrGnOTtdgJ~95~NYbEn*U`A`zA5u* zj4x>Rzl#aP4bSATR1b221ff=~{_;|CMQUQ|F}(UDC!_p}<56SVo@FeydjMGc1a{1> zLF?cnKWe=XYHX*n&lYZB8%7ewn(787V86JIC*BT~FHnWBW)V~ztlxMZqLgMq;}KlG zFN?w5r6yAEkdOV^JI_I%uPl6&gy%g&0HN_87Bvxs#Rc1W-b?JNP z$V>^bo29F7a)gus`ya5jP`(NL0bmJgu;UiEP;ouW1}NG=XF16_+8Vp2AHv9C zUw5_oaN&Z4gNcmYW)Qo8=X7j8OjypbNvmXN@lcugT)~Th6%sn+09BIEms>9x1r6%8 zT2J%iWeqzG(3a)I5$tatJy6^-EOf{K?p%kew{`d_UpuoF0ZxE_SbtNi{#@p^F)Y>f z?q3YEG_|94-9tCW{w0#z=saF8q(?#I(6@(sJwnO@)CBjL#g1rP-cyg5E{&Gqijq_E zXTs8k1^@S-3$=XXWZ2CtCB3mX^>wAI>6T~R&(6(QrEZDjk32cl>ml_&c&T7xc~oc@ z!!r}U?J2Z6m(8)edjIBc6`x;Rvu|+nf4WV8Q5m}CX_)Juy0s^}8=2bBI!jAnaYxSo zc}wr)_c6{LC`;gDPFc+3Wak^MkB%@QJcN9V??Y&$?XOyKVl9_qO;dAjIbPJb!g|8t zc4^JNC(EM>?v%2z0J7e{AYTEg&-|MbC%G{qA2d~w)ff^GZH&x$7WiFx<7U#9L=YanP5_ehC@N6|SZc;{qU8#~8%YJJHdZ2&zYsq0BAmyvJHfmnZ2pem-mnHIIush;v5kkR8CVs6uS3&wBcP1$Zb-TRP5X-H5?4BdG3MB~ zO-sd0C$?(XrE$TJf&@1H#j_Zthq9})kUIbumR|fU$bp0m+t9SC`c|!MyH^5Xc_K6! zXraH^-`&uS<;})hVS6(Q$O0n{B*|Axiy~{1&s*3zDmiOu`+&(rY4y)tJsB)^Q2 z*Rwiylo+$T0*VN*6fbKAaeiHWDl1^GdG)_6QLBve+AyK1yL@X)&fg@}&yt^1FAexg zi8rZ~RLxljnD0g4UKqUb*jM^Isc zS8~Kcf>GQCh{v#ym;ow+-@)<&cO-w&bvCEi+AK}8ciG@#yMgKi%I*PB%(o8u?G-fk z3B47AnxxXt<%}$JV7Ky>=W z%?e##R!9#ps85Pz46r^Bhbi+($F{i>EyCA}(2_CK|t&h%oK_HHn$dm-mn z8>B1qsoGiAC0Ow|EEyS=Ue(nZCVOG(Ztmv$3@54gqSw$XYI@g?y3J_~d6udi9(omT za6GcP%k5*|(dyQPNH^lMdyHY2_xi5B_vNUX`)^yU!dJHfQm0&9&bq!z#wurEBF_xjiYQh48V@)4i@+~fI6eYBNRTv1kf=`G)iTwv`LY() zbKA`2e)dVh^t^AXEEVQIqv>p4drx*6c{^(7%CX8=$ox1vEo zcv^;c<@i7n|E0_Y%5Wu^z}qjQ5_ox!)nPI(BQ5e`jx!ZhkYfbU1qlS#3KTsq3Zizt z=wvk)?PCxMU}v2HxtV|8MgFz9Wu7V{B=;oo~u1fC0wSn`K@DJ+LqAdxw6;T0NLO~ zKhx(0rpVi~n)0-p0LOV`lXGzf5JUFB-D%j=cpa$}Xdoeh>NtT9H zgTn|^qjjCzhmX5H@k#dHZ`9CZW|woeKt^H196Nj#w{R&2u}?2^d=4mIF#F~iec*JR z%a{`(CS5Cuni{I9Iaq|^t))v+c75-ybU%T-fYm(z-Az`LQ6HP^_V~(RP^!N*oh4oCA)bQ4;MK;i z@7Gclvb)@G{V*8fxIA?}*Q_%RrpV8vOIQp&;GHanF8%4gIKx#ybxU%^a_DLXq12rN zvRSP0X-aR=BNEr3HQ+_)^=0v{Zg(2;@+xR4N+*587vd*-h8YZHZmO_+n#%GX9d+(q z8H#3``c)ZyA3iAGnJ3%aClqHX8nA77^~9jLx_WdkMr8lEo>*Xu%L1+Exa8PP19|ny z3};(nytaP4+S}#}_~gm!zl`3B$Zn!V1hr}#!96920(eE$`Gt@%2+M-flodOybcz(_ z81i?#lz0c=hb)2lMe!);!J4*D;GNFh$L3a{M76VKh=C(-G1*Uwzb^1eaXh$z<;NMS z+p_su-{L{;hr=WH%LnymtoFWA9M|$;NT_6*Fx5#@U*>I2{*)H^&2ZPNw^JHrM%5*t zb8fG4=h*Une688$p z$6FsnfwV;sqBH8y1~y{4KGwaH!}s(}1ESbV6ul-XPy&=C0vT^WIg7w?+to`wODXtb z!4)fx4(Ef_hX!^@8P~yWC8F{967L`DCFrHG_5)IXz_Y$jQ!U5;P!K}o#fU6Tyk&bw zk|$ZQEY9?B1@UD&C9g~j6ILZ_VoRrO98-o+D*BR11}+sxOWi2VGgjMYCFZ1x3_RUV zgfWQb+QE20xK#m@P+5E#lM2^&_;;t7_X*`enl4}SRkN-_@mc?kwFas;^I4_{FN-5t z&QC52ay6O-OT6!+-|8?J@pm*RC?r3AB z?=6B+=TEo-^GDaBG%JT!ogL7sFM2`wiqM;8Hc`vWk3f$iG#B&PIT$_>i!x05X29V| zM_!{&aXxBG-^NNop`*)Q9XZnh!*|m@PG-8w^fr(_u7qX~Jd2@^sx~=u{*aSAd)}X< zTe=5LW8NH$Sqwu)ACXE!JKV}`%R#hqF!xpN`4%->g;q^PetnvlyQR23Nk%~BHgdtF zGPYpwO**R2)NA=y;p-1TCkaYI>7 zpj(96$p6r)BO=9~4{9X7D2FfC7AA?r_Mk2LwU^Ww91EHu)_Vu?&J7kr=LB z-eAlN^7VcC3`Q`S!POkoMr5~>u)|&D2e&yTeP1^%)#ZqKKRr#?$zjnjXNrvf)b;r^ zPnuwJHR)W1+M=AfnDgI|F*)g~PynaCvtLNN$@fg=oGXcNfW#csrpM(+-zTM_A7(YH zHm`b4*0AW@hB()4ogFj3b+n~%8c~oo%O`ZZJ3+=vh3Z@0!e>t0B-!jkoPia7cBaUy zH5;g#^c&xpTAg&~>gKvG3y$@A<(f79na|)jWWe{wanmzQeYB3tq{HFvCyIP6BeKCC zZ84pz>dze^ncD{n_U+ZKmPfiuvZUOP=u`BcLFj?!T!4W{$5L(IP2Mo22)`L4DLWOs zGZugjM@NhoYnndF7n=5#E`uKqQ?0Npj5a3TEW;d(+nVLU+Sa+ajJa4>s{xtrXU8`w z4*u)+7Er%Mmhy+~@{c4FZa#Vq%g_Du?KS(W;j?}x2k-s>se*t@b+ zXNI{Wc;VAm#TZDGTl;N!=p%doO~4@q{R8!F&MvjxNd4j6P3-(@1kL>5fA8yO?JaHb zyJ@9z0lke3qP_kHM{;*vZdLUe&EzQ9!B}sr$JcQi;*VvSq$uHfAfDe&!8N zR_&^(u*%e6qLsJdot(XBu@V>MF5G`oRZs8CIk5XWCIr5)Kn2G1l}_dCfazNU`z9XR ziws{8SiF2;as_d#Bc{o+^>|J2mYga0Q&(R>S=d(2(*-`oEjC`nQ8KkBWCE>LHGj^v!VNxK{x9YsA!2E2sK3tB{bG+j4{scblaTUcaG zpym-qrYPXeUNmd(s)nWU3p3Pwd5-`y|A+wTCpPP6y|A8{hOLzICH{W@swmsRu>f74 zDPKuR)S8i3%*VBF|cLSJ8B+s{U?8?sjicsRPMLAGBsv*)M_Drq+@p5>jk+bspW z+U;GE#1*Ex?pXzBo3RXtXw9m^9MNcI#AFtOyE81&3|w0`T|J-U&+5+;E&*%JtX91@ z4{HPCfba+AUH;-D$JIag)& z9(@nK3iA|rCi0QWnsNS^U)w!$=Bs_94OkX zQA-otlanf3-7=PctgIocc?^$^UN)zC>Nlc7*Fc5Z0a4%^-B0AMuet=qA~_ z8Tc$uO_UYJ4W4b?Q^#GMEF(}y>3_T*@vV;iCKty+sNff$+xEqsDZ)5?;=Ocn%XDZh z%ANs?WXTV;(u81dQm)%dUPFaJBz)|n$E}YU%AjzKRZM| z^ba9i(AU1=X6N6KzibOFgJlIkFvfgHTH46bTBm?ckM_H%bRbU z(7@3~8W;2YCa@;LDBhoLVK62dI@$6{7B+=ee3l@7?dxty4`TVln-R8>(Yrh2KHRk> zmR&ZVFrlX;^fvSW3Kg*nzF&+&1y99%DfI97kk}_yF;~0D`W+p5gQ+?J! z&itfQ?|A4?cLi$}^LM&xtN@o4V4sXvo~zrHz~|2Z@EfpDxPNDoN}P8*Tomh*3Bft0g_Hg+3B|aDy*jrY2&b)(Tc5+^tBD&tHo2 z%$#`L5SIv>M9pRK&_j#haidap3@4odw=Z6{{+rU7B=H+#3s`^ONKSbDLImBK4u4PT z!=f!pMs0Shof@8=Un#y@ttu{OI2LbpniofiurCSSvFlmqEG@CO+(sZM zaKf1Yu_FO3+6s{}(}Z{aunYn~KT!7aju9K?(vhKZ1=#5!YRAI-py3Q~k?g;FiJzCj zC*@uFF19>07U=fD^HU=IgG-nf0+wGgoU-v72eo~yU6t3I6PM$c!)z%L!um$|2>|t1 zuR;iVG&E|o1?Una!j3B&mouRwj{9!7GZFP?$2mnS4yw?4{!g6<9PH7byp@EJVDJArzr&O?!%=`~JN8TblLKYkp6n%Yq6bU5Un~ zf1NU)K5dJC!dVa*f83hXcbuLjSz^4>eaPWm)iU(Hy(_T%T&eteZ`uc({kT8ZFGw5= zymOxU-ud}}*V4&JwPs>u-epvhz0?oy}VHtYZA5Pzjklyue9`lm=u$<0RgQ%!YMvCnz zq#tIdruxkdBHTHK-IMMMGESy5gO`EQmV&5~lnj2e>-W0gZ{zLr;-7?inHKnJ&2``?ekvu3PME+oNM&0O_BEk!j!cZ=fw&;dNU6n z(^^EV+hKJc*gvaLCDeDyx5^kstZJGqW|Jf?GYRiAckGm+-O{bkB&g?iN4m>ja*Rn| z9fNB&teiMwyPw0lFup7TWB6uUwu9qVq0&S&>Xj(FI5#d**CJyl3nU>Iwvcb_gbOb3 z@y$X$1igExpeCabYJs zLqb^QtRpv5Ow4>3U{4>L%pSPTK_x*q`1VCUcI}wWhkXveULf8ogyWQuWW4)%zpImz zZ>-5N#QVSUE^+SjwjN3}bzJTBzd)#V%Pg5gchW3doE>0%zi&U=*Kf|qB`dXDuwM<$ zgpTglNzXF!GgZ^TxMasZjANI;0)g6EmGPP>E+a}7@3%fz_N({!jn|$2CeAzvM%!=f zZ!)i@Zj^=G_b!n#^Bvj{{48bP7hXREyM;Qlf}N(xD3|kqjyNIL?$u6Y0Dz%c*tnX^ z&3r)*%{xrL7t)V4)!x86L-*~zU#ON3$WMDN-pTYSQP4Yx%PWuKpbx&RwF z6Kqz4mlCq`0Cm9&aflf z7i#eq1|~N74^qmZTtaK^3efwMNjV{~Ij>yKLHHQ#+6g9q6Iyx8)MF*}<_B``!Pm=w zw@P}J*R%*|0y?B`5I6x!etE6>`cxFA6_%j03cYc9aUR!!|QBlH&oD0fx5 z%GmO;sBxhdqgwcFP{K0fBo@?o{b&^#QpP!Pn-fj!9%lYutXMm4hCbD0YHp8qFATl26D2-)GdjrHg*~qFL^ga$ftwT>+w0 znOFNCR6|+wy5()k>$xSMP%p&n zhJhcyid5sU!@EYdwb)Nsh%k?Ve>$&t z07C1$sj2B-DIj0oP40WtAM6aRa(z*0(uLYiHWc6#1D*)4n-Y0o8ZqP`YlBztY`-SI zsKQr;zyuwsRw}HPb9hSaikvJ=y7412JXSnzOYL|_?HqD~Djzb8`I6)9W;J9|BI6v5 z$Oer`+p}?^7z!(Ma6@g0sJ=UR(Lqk+^p^nxfZr~Q$n11!W1X)5bj{13Ii_nqu;iZP z=+wPZ#hpdto`Br;aL;8D5hl8Iu9w@D4^bW&Q?AsGz^X+689&)asS)(nGMMd3=-F*PVDn}c z&cNp(-SlpifTDQxMq^V$fx)6@XzrviAnb!x%eZ+9m-QE&(8W7#JJh7Sq-}S{S+)!( z3nsIME+^@H&s{Q0Ox29hMlg|)tISv0*T?X9GJx|qKx%WsRi$406KFH zTK|OWgUpOoi%Rc@r*ubz z#?sy5pqULeW%sBbnpW@=rfMa;(1dsw+fd*Lq<<0n@k#^$G%sMHkmi(3%kr>!`jT{sZ)pvT{9T?Iv5gk?spD4AS%2O6_-Jx^>7j z=cK_eq;1~FFTKoogQ*qyOgk2~d=>NstG+m|J;y-#&JZ{+&q{1MwcVAvRV_X=G8`WC9Y>HY zE_2dJMzEI|V_Z=Gu?#gb*S!O}U8dMOwB5G#&}9y|@{h?&PI~fg_*6!q;!=<|aRDu9 z=BX*TV%ZE(Huq^}WAzRox`b0o$u0N& z$H-K>rM|d6``6kYhytd$q3(t_NDKwBNIs`(g@Kp!$k20bpBpZI8^Go^=F?hVWGlAo zhGf*B#g2h%OnUHPjyf}eKg2njRaVYWMAkZH^R0k?MRDuQ9v-GBNWx+tt-3rDKCL2M zMhuz=KQ`3VTQ%t zIY7r3}cG2P}6!1^d$zUCsnz>9?AXmW878JgT%W`9y{EHLR++^Tb}Pp_eQ(K z$|jg59zI|8dR!ZA3sRyh5L1=hn&?*X&)OsP{-|}Gj>!=89%}q*I6EI-N}39{XikkX zs4d;b+H&z7@*pqXK4zp|qhIh1DchNA{&UBQD7AXxnbL((NF;qQQ zG+kPB*~f&fmCq1lu-Lfl3>zqrE5&zvl^=Nl4z;h{KfBnK1_3NVdz;LGeAVD1pDVsM)3}VwpAJ`?xsu z&qg75g`8JAmn}@G6pvgmt&Lp!W!bN}aRY}!)NSu#%q|z7a^ClY+4JPK!ExLyaEHl6 zkJ&<~yhlhaG)1~`#AK9ezj@K!cFlj3u!Ve-c<`PB?U-A8@vet^sAHgfapXjUi1RU5 zw`lMB4SD}JDHd3$YBy%|&5Rrol-Wi(_v5-chI55Sq6fnQ;p5tGt_`g_RQMM}G*jrk z)9UODf!cRgSi~5mxUbh4T5is0!|iiqh>%&=`h-J(D?jAt1bFX4qa5whMl_}D0WIP3SePPx5xBm5J>cTF1%>iG?hcyg$E z{fEk?9GH(H5tuE@+Uy!`k^JDHhCPOSgApth066BuGLp=9hMc2SM%b6M7lCI+I(xiyZgMP#sq)CWH&i(1S=zo{Ow!MGqK9pv&BAcM@0q@}VTMBXQ-vzWK zs2l3fUR{bo?*^8dBkiQJ702w3KRgLu{|lpm?N3vu8R`nS+ntDhvJAZtC2W4XQQZVs ztUdEfE8xlz0s9ttk>0PHm_l|qGdYr#3_ZkuUQ@+;Rg|8JKm*KaU}vQ``Q8C_7L&c_Mo8B>b4$v5tjRF1zNkLg)6@yk~aD zu1N|S%CMMq08z0uo?a$MsYtJ>Xns#|8Su}%Hr#Z}7BvyorgtR)=S@|{icZ66r%F0Qs}eym<7P_ax6z0W^tyAa+zTtob3 zdK!;?5d~dYF%8wer%b&aWy%$u2Jk3l@?}Q56+#*NH-TYC06nE#WxwO->O^W~ZYjY_K=1lB40FX^^df@>Jjy{2Sk-XO$}6 zzB^f(Jw{mWQK{HLT7rHEAWlStd#t(pD`Ko$EPm`+f*MdEiWlkxFZ5%}b<*uA04hf} zzw?tZ%0PeL;HK#%ya!y2NGM}#+dJcg@QZxD0j)ukVzE2S7}k0{q_t7DC|FI|jJOG~ zcC8Hd%G{8cnz!%}tUR3lu|dn+ckf$iiD2Ty2aLS#2N56e8HrU-b z@nd#ir<-M}0ff(k={I<~hKuQEw-C|Hx>STG-(UN4!&Wsp?o+v7_42|^hWAM-Zh>QJ zx}36BiO{pH%YNrbPx3y8I=EYKNV%yqy(;iX8(fV9Z%ubn{|$ z2>|_h7#xzZ;bK0$c^v&_aSW8oJ5&_zGc`>kYw~Jw&Us(N!&z5+->|g4T>|^{IFZ0R z7#SJ)NpVivRzOfcd^usXc_uA!g4Z#OhZo5t!+c)u4~U_(nXg9xLW0eEgwxZ``^=Z! zgwztmO`km7S+W1c&|sm^)+?3EuQeSq zBv8065Zabf7dR;@U^@10@8T)E^4$E>USLAY?1O;5Fx`%MLGF7_9{0iXE_6YRD`EXH zV0CAiILA`73yLbnGzXr&9~v5(lZxk`XZ3DGv6>OJq+0Xg*BEU2Ppp~P1J!!hF?2R3 za+`DC;N^iXMlDBvpZJ;<36;dFxcvH;_R_v)65-C~d}abYdcF*Vjv3>)&q|=7?$M=0 z**Psf*p$Gors`n5v88#yW+`Y4cD*!ZNZPv+O{ZJ5D%}x+9-CfoOjP3z0yw0!RlK=G z!u2`_3eD!2`?}+epMKZ_B#0L%d@8zSZ^vIVn8$c_Opm!NeMR|lXGCtF>%Arp2qIAm zP_jcuHa^mT)$84h?yiXCI(7M7?lCCcu+uUf==1HX({nbIDKK^fs&UG3d(*CYZ5oM>-Kf z8B9g|O&d;{S}IsYj1uy7^@lf0P4G%xDc=1q8`}wzm%JhD7dY@{m@no_)cgi7e&g)} zAd2hQDyaauWR0g7=xf6gmfYDwJMuXUR?$Kf!t*xQ3E#2*ZklrN2}9X4mRKL+%EJ@a>s{@|@CF4Xj z;9i9zGkqsuaTXXwI}z%K=u`g0%^hNecGzE=TDynIK3qL$kaGPkY2~W>(L8w*(*2*G zOb>Zy4`EFYbHnBi3noX$a;I(de%iS3ef$w*QWbd@TjHFi+O1FM`0`f>!YLSsKGxYV zIKmzy@(*$IFMkHRSqgIUUh8w7 z)_f%vS1nb>iEwL^W7s-Q55%-$)x@Zb)*n6kw({O9V*uc?<1cMxT5Dkb{?O^TsPN6d zg7yfsG^w&*L3a|$HpXqmn0+qMRt5NN_VD8$Wak5@3cE+R6%3KR0kkSIUxvwQ!T}?RPKGv9hd3g-zRwWioOK;2&BGQ0%%K>YEZR!h zmhGX1py?}V_pMpXdwN=9g1bFfq7G0k7lJNjLK$`OL4Bu*XVj$AjL+OV$vA{ue0XV= zX`{1f;_fIANgM4IQ1zhCSxdYG#hkxD>;WQ98goN8i)}g;butP!<+tV7yrU=6^#Z)zRyb*gv3wB4feUmk04wAD@|(O#w%6ME}_>P;cmM7cH(Y zrr-rxYJW9?h^=f?7t;HJ#lT%k1HE9V%iy1Cf?q^NuWc?7+e1C;%H2WU2#7$6CMB() z2*Kv7BTr_If;(V?_6-2vYsQ}Q!ZrdpQ8Tm zv3W(yaJNn-VBz%`+peyCt+{!b*9S!M6x60r%=atz?g6GP_tqut=#wR%$=SOUPpA7N zSR`I}`YsWyVYRG|5p^F@%=QcrIT_jyr(smKCxZP_Lt(NDMNkl*!4Kp8hb<^&TekO4OEl-w`Y_IL5038GvH5`&x+aMa5}o#xpfzKp z53EyaiF@hqlqV3o!}Akjzb{o6o98W#GYYRexvGE7@%i|Rvz5a}Lok7<_y}0L9?PBF zL^B|4tCOHM=Rg6oeJCI^;XTk-tj6<&Dk z?G2zyD;XUxK_ZVnm?V8Yhkt$4NXaOusAahtSMc}3H>lw3(dA+(cZ6Z$6fSo3TYE`IL&}ypVp=*E}LSXb#XBvWqTuoSW5CHeoh|XGQ*)I zsw{K}a(OX_coltFiLQG2U4;no8v>o<0f%7!4D!$ZybJ2Ir56^`E;sj8M7?_aw6-K> ziVu>qPW*;zr0yRqDPHOb3>?ELcxNVb0zGnsl)5Wku;<+!}cqjJAw!T~3$7#1ye#K4sPouNNNF zXK103A#ord25*Hwz|W=^K1P2c5_<`FlB9erSzvr%m2dl5dia>*>syNli|46Jx(af>;VNesIv*pf;OMJ zP0r0!_xiLC!wW^sX|tR12{(T#70lFBl)OLW!uOLZVIPiVHj*P0>O7^eG+GAI;4S2} z7OLj?r6_#xkZ|Q0lgrjl-2=46y-EbJran(|5X9s$SHDvigLA^K))aW6f9YxW&NqX4 z!@R+nll|t)b#@Q}tF|jHou5J6VH0|&p8}}JF8u{(R(w{_`Zs7&1^i%;^z^{%pc@D7RLZS9OsG-V;Y@^X%}v{ZSHx z*h|GZ2GLUShIskX>v7tsN@qdG=a1q=4*D94%8 zP|1tR#b$KLMoWNdhv10iU`_T3zD8e3>8_A6iTEaW%Y8L;F0De+o^OJ`!DMkA9Jvp) zUqc_|kQ{92>{C<4Lt(-FZd}!&FHJUQZd{}VmN{3&b@og%U!&^LuWbP7?+6EknOyV4 ztumRl9qU|`!v^lel=piv?13xdt&xTQB&;(Dr42s=4&vzvcs9;Zy9~O~vAW#pFFgGumyaqu^;eYiqV?ZpdMn`PKNmf@e9@8A>=Qe29Q? zBzKnz88I98k*o>fvUWc~i@@Q1Udol-T1a&WdIJ}0}UHQ4{0h)-b< zc3R^}Uk3MOKHg#j=5reS%&nk?lWo_dIPiee%c@iy5Lv+A`Qzk$HkvsfsFd4^|9kQ& z2UigxQ|Vbwjwlu5PJ@2((jWKrTdRv)S2z_d&&j-7E2t?-u=4h~uNuD~6i1k-HYLNm zV(~#=k>>qWw~vyvyUUogHnu+OfZ?_NhG0Oii+*U>xfr>|JT}IwuRNa^sohg1TMK%#9Qm*qBepfO@5N$oD=J$Y*8Idk)1K-p?ph(%-ziab z=}QEQY_SV|AgMk0ZI{EOg?nZaFJ4BdtS{kM^AHh<*2{c;JV-gw|4IF%)voeiV4DCT z4sQybTfhV5SA=p~uxr6joDfBwQYpSP@1~0QDy9+e<>_8P)yeCX`K_*F3U2}c*(XWD zL_7H=RKFxK%ABdi7_y-RPBO$e4{3B^j62eG?ABCX$WjiYiu_HNolvxMv_Ane8ureS z@0qHEeTZTc`}32dRT2Ax>sMi0QLk2r zj57yTM99K#?ueeW#e6x{LO#YB-T!uEdpYLwlN{&Jt;&`iC5C=)*A$q-Z9Y0g3hml5 zYA&&{c%7q17n)eVt}0~fYIveocPSBh6+Gc2^WD^+VnT!>eH+~N$+>EYX=ce#;0gFz zPtlYi^x>HFHdTu@r=tz>NPu`XzpH|GQlm^@%lO5u;JB1IAya<9@-x)!vP`o1)o14N1;X%O0fWfew5ho`0sXRLxfsb{C@24JB_g7!}HgHWj>s8-3BL z4edTE>WSwx`k=aUrY*f*YS(}}^L2{;5T7eFh!T*bsrJ}-!yOTmUPjh`K^!dv`R+OH z(`-gQOTl8X{_8q1?82YH7g?uuL*V;Rt%D}5hfQc~n~sb=%q$a>8`E=i{)U9?`;D0w zgT35jU1F#!oJvY8psEq25n|egKHWf74V@MeIk4fEj(*rYXrVL|JhH68gH4H*yub?@ zA1&ye+Lhv?^pxxU&1N^6GDaFO6A`u zm(Pk-=u99~WLt!cZYoJrR0xodlr{^Syt?R51#Xs2W7vcBY-VI*oHGsL1&zy~5+e{* z@;DzDK9CDva-R<>zxV_Ee|&RUWXXRp+H=ZW-cG*FKyLgl1JkdJtl8D`jK`}NT{)f8 zX@s!1d1?qRR`MdeQ3b`d?-y=Q7Y)AD8lV6B^@`xcED?MWnTzA<9C_CCW=>&z%#<$l z+k>{XZtk0gckFBs3k0L{qeS~$*%drmK8&*?iweuK&YtK1>509eh*J2ZsTKj zav~@MXR?>HwOGwrRUa=$-fxa&n-(LgV@c>s0EYy+VSwtFjG~va-b_8er}rxYYt^o2 z>((J)k{YUe&4svDS!A@$;1DT)w69FFXzR%IOj5K;7?hHzJ`Cz}|9T3vPazdP(~|GY zHBfkaSh*H&D63_yS_O_8>I9FSsuXuAO3ys0)VnjZ$t>a4JemHx zk1hRf6z?+tI(GLRt|$6e4M`XOkPALCfJ4OPx+p8C+3{;6-<0P2NJJkG2jhFTp*CWo^t(&qWm2949xkJGi2P@zPD%c+T_B&2W`%6tCwj3KHU7 z;i`mJ&Ko1(S(Go9$mfisDS8i|1w&$Qglln*X3eCVPZDHy6zr{RI-6WJ&k>=g%y_~2 zV6Tx4*u**5q*?DXq}x!adTXTc%;S??Zdn%buj7jk zQ@BWg>zG|eiF0!sgWeHaMdf?yL-&AliD-M;gQf?mwL394!p}6J8%>K|zLI)J*;f~G ze*>C5_>&I_N;`b*y;Bp)oiV57d3M8++_stjri<+mBfozrRkidhwIkGf&c6JXv%g+1 z-%(;ecsR4_2fB?IC4aA7g$Uc$k-t6gv}$?rqOCJ=86upo;7GXY-qqzmp=96B)=E9& zi$=_y%huleGqkuz7mj9o-xu^ap(@57Jzf5dG=tUgk5}G}vcu!|gIvH?nU}?VMjff( z_e$Uwxh7y`3p`Ff1N92YEQeA^eiI`~@k^_%@Ll^}F2Q!tmi4nTO9{A^a+1)0Q5KP? zi$KDcJaF3<0eyNVc65Dm+T9*)HqC8qQ5xVXxgnyr%%Q0JB#25Fk9t6w=cG{lzN7(- zLWVZlLDyZ;rww)yn*8B$<^(|e6Riu)12<254as`XFxiCK5Wn$FO6if8{<@UzV6@RF+) zF`S7w;{Lf{5!puOJC1A2tC(Db$15MW#`^431z|rjS(`k1!J1v!EVbD!hKa0>#BCMt}?(LpTidtz~6&Fk%66o+$Yvu_z$%^IHOr+;lirwWTMUIt1+|G*yG zUU^{hx(Avgp~v@cK*Ayk2Ga=G&T;R~!)n(7?-k~H!2~sHW}fx|*!mkJTYYu2m$H}A z*+Pki!1C;;R86xT8w?zFZPctA6dIg2QfK~tU51=B!pjQtUfTHnBaTYWj`)~Lw_F&@ zmdo0^15d$JNJ|gdvO-kAr|!#K;Mc5p&=lkzG`q~9y>GcB*(d4<*IW39bsuC-edPBP zV^>tJL-;H&JkQ69lW1EL)ETK9+~h4^9y_)_tD2f>|DYC5b>t$)B;K@<*Qo>dtmG^S zY`GXcZi;_UGT6^`Km%~xGs?L^b_f1Au_;?V! zAuyI1Q^-G@s49Im$^fT5TL14=KUxr@|2j8BW&i5Pt-^_9BXjV#_q%DOaZ7doCp=r; zx?;#zU#}yrk>m^7vLyX6{YK}Fk9*ThXqqmb(@~VRBumdfoTQhOwCxIH;Gi|=PfoN+ zVRxW!?(6MUSUHK~imS>L!T09&k4q0$rFJg5q6#dZ3C{KfuG9F50El2Rlsg-Jp3JUYrm`^eO?O;9i!ZV6KM zQqY`T@s{x7W_w=bp`P2Xe#&OQn8wV*85^J^ref#8Y~%mhx-yiCN?ZMkXauX5l|&|5 zmb=$5C9@O7YWH(PXIS_Ff|`rd%?}n~)}MHlI9SwUrYuEKUy~ZRy|hgr7WWjU+kVY0bjgVDeZ@-7>Tr5`t27mh$*;Z?j) zIA?GM6HsuVHSX7q&#T%b`*fG*yTs&iLUH^p#Sh|!)H(mNlI)gndD6H~*ceaxz~_SJ zG&VqsYiU!Dh`79~+6`)KEMZ+P+0b+QN{6x-p@Oc)x4@(NjCaqBCkyIHT&%(dR8uE_ zT4N*1hNZ~ugBwkR8mWuT##?3jdsyQu=JDHreV>P=4q4@6sBFUBn4ltjah+RxSeZ2? zVwvO9Bco$#E3H@Q^qe*5ndzQPk%P3O8M=<-WLn-}6iFyQUL0wjLUntN&ni%1i1dvh zabodoR;Dg#rgN+pRYfis(a|_gf9I@0MzoJaYMA!%;T(KAIFfp>T&FQH?$6$t_lH>_ zYK!ZkI`#6)4ftY8>r4Ab4vyjzo8GGeGWfc2f>M9UHD>V(mrS=jibY$CULV_24Ru;i=(6Pk^fw#U1umZ z$d^YA5@Te>QIIq>`0AB*=fffCi|qa z5LC%?|9}TW%7#JB$>$4}qeB3V?tZ36u&;rW+2Tm}y0aDPrJ?ud+We~j!1McwjJ*2b z|2R#Svd+{338$|1v2_xgYFI?E+7Z!oJTKK&6%uf>}t&|1Bn!=^Gh~YE?LzG%+DAnwKuckBMDY zO8r+Vt4G?ajV#{t$MZZlDH>okdUZG9E6IN>B%gAEl}Ew-U4QnK{(kQ|k;;V;(&Y-8 zhSfHKtXD^_-q2pQPin)8wz;0)Fh?g8^e%D*;A`>N6;Ex}q>BVQ$4jB@`P0K17g^Qe=?unk$^&)d+W{+R)K8~z;SIrTNSS)P7#!7+FDQJ)a}dQA47A>KCQ@Q*K(v)n+2GM&E_B zxnmUkWU#C&UnWFG@Z@&~jXkrPr1B57;?v-E4k-h1A;FjRsP3H&b+F{Zanv)fJXrK5EcYNWI zjB8lGh9~9?wfX3d>0AM%U*<>2Ta*W+>Q8^^Z>*m*E7Pif&fCj}jA@!Ce!}Ry^-Y@b2w@p=YLK!1y9e%T z#(d#f-(`|s(*dP4%!MWH`u+phXCgm!J*!1S13Uw9$~U$hx{8R=QSDP!)4j2`xkN zK5;Pe7f(f~)D9PlqA`z$GU-Rt9Ms>k^3ODQa<0E;RF!hDWATmF>)`jzhxguz+T0h^WEvr}d6|1r2H>WpnltLhP)bl1_k>}JZV|2xo>bHMeuOp;FS7$> zz04-#4l6}`NHqV$X3R->puWJuw>OR8LP1KdTAPf{K)q=1m&l+L?BMB?^W39*6`_)= z_u0C9e+>%t;<-{L?5BZdJ{2yYoVZ~K)}CygzM;yu2>8)DLkuhgpk|sYDl#(8ev&)!bqU}N_)1Z`fp*h;5oqFS0;m^J5T#CBpHt}pJ=_74o;kM9gP zNKJV82;H;aE8IV^a~9dBuBnA7eVeOt7g-45-qxoFx0Bq10l^0J7*bEI2U zT$}gAAB?P8US-^$L*a)apx3y+fa1mHvlh)YK5_s{G;A`?bGrL(q7={h1_iUA+H1Wj zF-5O!QeM+b6OLEM#YOk3wU!q=e#Uy2B~n4OAD>-%ptatta@OyDnPyH7?i~6`Wzy)L z-vmoDES;?Q_vMarp-U?M>SwlYPZJauBaKpi=Fgtn5V~+6T?sFA=a95^$lOel$?^HN zxuPJA(9RSn1b^&EC0QcM7JBGwgDj4ICX<|A--;hVN+=Ws*p<5>B8qlk+3_%6%s~9F4+s9(_)mHf+rdn3wb=$cK(Kkb8*@jZ%N2nve$2#5O!K5*EiW!9 zL+AQHMYLSA%$MflXkg5f1w-GCOZuj<=%sV0-sB5l8e$7@wmuQu>AC;#s~#10W}TGh8#5hNL|%I0wH!ykFzz4-*UEsmZzl8Z%bt5P z0Xsx#Kn9)Tw;qZI;;S(#Z)dtc=3u5P{|yF&eLIOgs1R1P^ZE87Hv~IHoYNHyv1k#z zUszLI%>HEwW)Qz?A*f!)%@Hup;p?=%NIv>T;^m)Tul|34}7x7oJ!=LsP(nT8ttY0qhA_^9ana z&vc2`WLc&gogD)unQ)vH`wK-_B$TF>SL-clg?I?Y9WWBw49;ovm8RV3HdpSJ`n)5E zcMmgZ79ei$JZVz9hJUy^8!(R_#l|8__i4BiLp@x+od!ei3Gx&+pZu5V!^~Q?0^h%u zgVuC=k0owZDjN)c=SLQ;*su%;zXgOKz5Ls)yjsmQ%g_98pPCB-9V|_}wcuO5%N!f- zU)l{CwwI}!s7B}J(hb`6L$ry$Tt7&43z~Nz1J>8I3czipTT?WT%rjVs1Bg3&o(sNBgrnwx$R^9neIbe#Wx~7{@9hc3kV9wgb?KPtX81E!9GBh}wm3Qgpc^}sa!H zY~t+Iu61x`Cfd*D`z4N9!!O>ATZI6RqY`mX(!RGTlwA1o<>7(d~8H)F2GWkGnQSJZRm+j?!0G=8&-G z=rbQH*vY6ZZ?^6KvGm=#>s7wYMVzDa8{%$KYsK9tXyNlL`m%K1X|I;Zg^l=<`_u%v zq;e{~KwG)HB(xC>RBER%%GR^u?YXSkb_$JUhrTpj;gu1O>lRZH=?~aF_amiq-LSIS zt&(1HST-iVsJNJnkzPZeq7?Bw{1u5lrwfzMrGyk>KR=a3a+m6HAdS}5jY$bSQkIc3 zLqU5gd7#Fp7sNe9u82E;&r1O`QccC+n9jq(c+bn@m+&_7e@Ph~Yn_>L4o(1boh~Kh zi;;U{$%;GX&@)+MN@!tKt>Y1mO^W3+u@5Z`sSJ1py#0z47DU;X8FFYIGUR{j+o6PP zknPq!*zkEJ_-Rd9tVvofhtElYK6-^TY@S_q=mdRTdDxmm$nNbGWl>!%F0)T{>_&TkV=+; znP*HIbxN z&!`0XT#A(+`Rr*3yviS$(7yanIX0W-m+wnmPOP)dN9MDWUEss5xxi_O=~0w`P;r2HnNaEcIJHIjq#7v_P_+n?zn=%>;u3id!v+uU#$zq;BDr zm+~#EAWH^9)>9c%g#mb5wgy2+I8l(aeF3nH+uO*ePpZc%b@HRQyy7q;7HgbTcjR`I zbBY9p-QPM{j=sN3%fjxjmz4Fd{_HEp*TZA=YvN^p=|>Oo9!F11q4d#j8$pHh&Ey>= z6E&GvUALNjb|fvOKmng(!U;$}$#WRvp}{0oLzMAW_73`NabaOcTYZO^dE{6_(yLwz zE6?%Z&nm%_Wg{D0y&*l5rJkDCR*up`^Krs{{QvN**p{69-1hv{Q~WFNksr~iiVq@b zT{+mVvf?)m2b$YF=SUL!ky_RV0j%5at`+g+& zOd6a@wE-nMmPBB^A8L3vi|bg>(?QA&BS^fA!h*UgaJsi}m8bUKU$QkFyM5!xUG8iz ze6IuhxYbDn;5;Ae_p)kVIW)jXIzEfoNP1$fRkZe^F?{oWbx$%@jFeBj;iWPs+BVvIIiq4kz!wD|3QWNGi&x@v|-543sH!- zjQ@&jg4^GNv-7s^HvgwT(`j9usmoC$0`?SWA`Y&o&?Q4n;AVR!<7{&z3}!kZjMZd0 zEL;~Gq%Hf$Cf&Z;m-37q=Uq&gSbOEAoGkDyk7m6wRij>9lN@=$U*Sr*Cx5FO%Ls!^ ziQvmb^QcW^7mEWWc#l0McGbm3jlabj`- z5AVK_%(%Z9Ds&KL;)T&@+)`Wx4c|xFKl)@mSJ*la5S%7P8$mRWWOzSpAv+`kJG+R+ zoQO`Ie>Ru@c+;@REx7RIT^l5^NAWVA=8s|cIPhtL0YV2P6xF#=j{QQ_2$R|H4lu@T zf`bYokdz89hy;z_;Y1%USxv}|r0yxzcV#L8xeCOJ+hooI$|LO|TzI^t?j+^;?`}6= zrf2c*lAw_h2Vw=vr=6(*nZz!CFhx6Q;b%``L%Zi2W#LOGmio7Tje8>a!p2vIsx9J; zmqaNEz@<`@^&1`g(V1`<8YG!^;GidAF;}?Q3QRSYs7|8zhS_(*97XFgcj`P7s039= z(_tRg@@j$_a9h`UlZ8O|3Nh5<4ZP-#UVxSSXkqW!uU&esQ6<32YqmC9 z!BD3RAEe8k^-&8ymbAX>hbqJ-Dqx%)p;JKFXw>9G9qyg_xmI_ny@qRt|Bx3hQ_c^p z{u$4*O;fD)#yA0LvOP7}JTM2INR2Rw+$e!5}pJ+AA%B5yGQ+wdPk%&X=Ezg1SHHBRE#VX~FjpT$>rCPuP zfuVsQ8C7KLPyr+H;+TY7$W{H%o^5Gl8>8E83FH@pZUlyJg0)8zyS$Ye4Uk2DAw_Hg z{4Qm5gETRaG?-=XGI-$To$)YzYY)iqga1&z`Y8_#jog1C*!c7=}j zo@1uXnnJ}6PnW8zD-@0Ti1FH~`63b%IB}tOYn#etVF?06_cSqpC+X6~Y9cBoR*pk^ zWK6;V9GdNHCZ%AA;DJo?ukQAYJYb7e0Tn`rJ(tx4=kclP8)tJC;qoc7>D@t(6fb|S zVQHGOvEj`JHSsW4^Rbc*-*%vE47f-W#k%?DGcLy(clptz0rHo`w^bgdV(#fo7GCSy z2{v44DcNKZFS=HY=~<=zJJG~6{PWwq4|5FbLL(pDR!O`~R3cv223@T;(+nm;k#{zJ zhlL_@PU}Ltry`_$Lu^_0r4OS&E>e4D76nbqrabcBFwQmy1^p3oDMUX48hKOdKqB)l zxf&(kcXVEkFt~@*-b~DvK}_W@2cu7z9olQUljJiU&43b`z@*r`K*_}~rP5h~Auy$~ ztd`zSnRYfzR#9aTCh0w!XH>mnar~Cshh8aVwH{;O)8xEq<_7t|WRDDhm!{QBPCr9m zZD)*J$mYFXZzTxi9K;CUIv3c9{CAZh*vz|x)cd)z6^-_UcR^z3y!yGOR!-G zFWH}@UXHNXeXAx5UFHq{J)i>UVk6$}0CA)owmW;^o-l?eK-_6|B<;>4sj}82c?LVHZeCFk3(sP6zFhY2N&gqw9```iUN5 z^^)Yi7eFQ_@;X8=V2CyG#)aG9G@~wF3V#mw%&%H{my;f24OEt+*ZfTyLb=jJTx-! z*g7_aR4Go}O?dH1)weZQn`>qHt=>18;&7K^&(u1nT+E~0ip;O9u9Sahjrlk=sojUT z4`)uVu=74+ay!Z9&*dyhEQ1@C+=_qEQ63*1l_%-ox)c;rUR|H`V)A5ni_~{S-mjA` z*qYLOnTMG}2>XtAwftLiVMn6|eG+mIamfJVotRP;v@c2-p(5j{VioO?O~~L<+F9?< zVf4@6rQ}izPPKFdhREh<_!QmRGj_@G018-*>O{?|!)zhfmdV@Y57))NOg2 zaA!gNDlJU)8)k&tKDp=YgY#xDlXkp$(e4%X#QqI)j>ddsXDn9n1~rTSh#AKXKi#qD zE^d96Q9qqt?)R@g*yc(~kcCfDKG+}zGsG!4SBkLqu8jA2VLYF3Mc`o#)+bxD=`xe? z%!$MPH6o55r#ao_v664wSVT|$`t|FrX{HS2tls%zj}B%^)IP;*6C7MX$l|Q%nZ7Q3 z;$s~5FyY+UD<||+TYp~@KB2c-=x?mZx_H5ysc^N<{=wP~>K}hGc z%89J@V`~>w@4qxJt0Hhr4NgcaHH)IrX#S7(DW77c=gi!Sw>_~T3KjO5SG`J-yWo?z zKO>WVFlu9O=u{b#(_?*Y#f_;u6e5-#$q4x|pFb3}*vv)JuV|8bZDFY~2F5iuW20EY zwJiFMp0hYm!QEQ`FRk4B&@-KQclOSy^@BeV(cdD(t-ebB)Q8>hOLG00Ij)bK3Y-os zEY5WsFzcN5_g!dAJyuM6VV*_RG>@3EWYe$YFGDQCI<;UtLX?=_z6kpy^s}5U+)Cxn zcJX_`md3(i+-s-d(YZ&nWfM%v(tT>Xwsy|W&ht9uGcT|4xN+Ld2(|^iIU1Fqm*134 zic~;F7RHF2n1vDaZ*D!3UmVoE(nlssza@}wU({c@PZW|@C7K2IXwBNGr4)fJV9vkx zn@KAfN*cKJzgxd1N|o6*W2Icd42{Gi?ccaY;L(_c3b#dv8Y$eHHR~yM#D#TgUYA2BZtBth#dV`dHTn7$(oa<+Le7bF*-9r zTm8t+@_}l{FD9kzLn1fiBJ++r?smA}y6?V}u7dQ=3>CR!sQ7C9`B#yyJXU+Z)vImA z;OM{bC+7a&_ZH;NHHXca%C27+L|pb{mMkGXq`lKXIbFj~b+ev`i@g|yD8YMmoNJEq z$=WLz?jB$fcfH-EBB4cOqvW!W>Re7-=;M9k^>^%x)WFx+(^-fG;n~4OvA4Zs(NF`q zgQ}I`1>xLp_|lzEti4gfQ9hrV+j!jf@{Cq8V(rKq$5yp!xImwXgdscH*}Qat=Rpkn zG|4wy`bpsjZ-L99@ zl=G%-(>l42;4iR#gO~eB=zr3Wo{cc}wS!%8EH>|%#)V45~LE!$Y zHbj0hcUe|Z1(T|v6QDI|TbzPqGYirn@Ziz_?BT(5Q>z-i=co_AY!RcLwE^q7FBW83_=DplCB9l*6_yY8 zlPtHSx_Cb`#bVyG4UIipWg|>ZPWHW(E)l8l6>Qr}F;A>|M6n|6@RucBCXc5c+9jnc zm3S=B`laLVpT3nXb09SQ2(q~*~ zb94`;(CGYmjsjV}{0L#QA&Uxw=%yEJmmcLv_S{jo>ikx0ysd{&`NN9kzK!lL7z_TC z*A_&USA7p>cOyPfmNdF}1IAJ8*j!&w?;NMMdU^o$jluPWeN?i=cPy`9YJ>0Ai^q9P zNs|4<%u0Y`bmDq8pY=utptFT~C_B`^)tFoG*z(ir8>W5@oVQ5{XL&(CzQp4yEY%@o z5-OHzSA(GG$IA%lD@za>>4N)JekEmjdP$~|FNC~`rz!}Ed6cy_dw`?PpcNi|{CZ?t zxvjL`RVPm3Gqw11GdDk}XuTwfHTt!kL;Uiuc(P`sXRW;EjQCW`?wDxKe9p?6Z2L&W zz-Q_kY29=U>Erzvfvq85#iYdpIs#adMV8imKebWwAT5>>H(x{TON)u&UYim1Rz_Xy zE3=lo&G%=1=N-Cq0_5Iobd1=W%^fE7%q&uEfrOruxM zJQWzlJ>H%Q`Q)R{3;ZbL28p&WQ%tfY#8V>WhKiRw+}*F84|hLXEHVXfdZL5Qch^e( zPguu{SHSTkZcbeKd6o>B& zCF|-ci?=kfqe^JiX1Do=X7*5u6NL%`m)tXvrlQ{$QC2XQj%FSYHtp^pgBFFTA4_ay zdSw!Xx%XC;j*o}_4llVU(u)z%VPoExaYirZ5$$TJ+^{ABP8HF0)f)mxg0X9IG&wHsV?iFoa4NM|8GP7oO#lrd)n;^Kv+qiOqQ^G0TRKs- zxQzrm@qU5VT`-3<0obNUU~P&Wx%3bE-pp1|_>t0V9Rln&GWt1VDAfz5StX$Mo?V6w zozSkz)>J`Yfz@zCQ+-9ZeXh>o&Zktr=6?Nhn&*v$fW?`%{s5wjS9V?(g}Y}HEgg!Y z_aT@_gjN2(lG9%oy#6=~5DZfk~yT4lyJR?G;H?z zUN))-lsu&Jw*`~_N^abX2k^jwQRV!ciN4IfL4m}@T$+H$P~{48Rc*yRl{m=5797=C zaPEdkFK_oK^CPyS)YlJI?MCx274OS!oogEz5qDhgS2AU}f%Sf~fftWxv%Rp^I(mcuL>)Yz5Z?lLy5_V;$_YndwHcmeMd;H+;)kN=(+ z8`l;cn2zFw6p;2QcvM%hZYm zi61|IPLvWI>Wyl)4SnXcEb3x$Yr^1vSo|i#H_P+>eJc~bF3P9m>169;=C`{}-yy4D zpu?r*eCSTGo3)ny3wf1n?8wHGESG{=QP{ zBYE}e1oYR#>`MNX)CyLQQIrU^YE@_08PD%I{)YKiY3aLeo41P8vcs_5Sq<=Ak8vD> zT5pdXc?a%0Ezd4N33b|I3@z(WKFu}k%b=7^$Cx;+w#$;5c)?ixG>uB<(2Wd3?1($B z18(c(BItNcOiZ$-^_?{^0*&ASIgKC*6AkY<<$QPKpkOH~>NWcz=e2e@0;UqNkY9sK zb3S3_O(`kc7_xJoYUVI?ZNDk!zQ_~7Cm)cefthTM6uQOG!-6lLftMiXx%3|!4E~{{ zZ$vFsL8Q&HoU7S4do3+;VvPApx1`{q*TK@AIlPwzo_en>3n4LLUXeCbz5> zj~O<%nYa_9%P4GgUMjpXgTaU8?7lV{gWC+^e*B8*-0$Z8OezpBQQ@8Nm1%1%j6N+R zBLjxXcQ<||VzUVpb&nh8pW4~6W>=@k+9an=L|vbIYC)YbQ^)&sBL}GABwm{ zWLlS&5ioHda*QZ4Fzn}deLTyIZwJW=Ewe)I@?(1UXm8FgQ17O%^Ipo^Do&54ekFqgk_DG(3=ayyw6Gz!3y&V!^)DT z-x*(d;NF5bPcr1^$RZGNFM@vxZ>yI-N`hNx-grDJC_vy{K1H>9uhM!S z=6KxUd^TIY7{7a<@ZTdQLu+y^nJXP>PKh~$R}xXU<$Z=9B~*z#=jbS#ztwTTMYd$< z=dbGPMOs7$b+)ZrT^B!6YdIu%Yo|Hfu6_J?U1cIV(zVX=;nQnbAET;^L!)d_H>$Sm zHxFc=|Ibh@-8Q7Q4t^r$Q9+^mfi}j5kRfZc{8Q^2b&cyr?OZwIRUH0%j%lKMv@vZB5OsHQx~5D&c3=WDQ;7?IK{l=&3GQq zE2UTFPoB){G>f5>Vm$UylX+3+S60-Y+*UO7o}@9awO;a1&(6!qaiR{(_aVbGrZXgr zl-^kINLZHL_?xs)0lAWzNK!E|F~7>nLY6&0fT3i46zsQb?yQg3P%f6kI>$ScRWR%8 z$P>Z!)MsiAV_u1Rb&X-%mBSC&4uD`$@FsU8s`cxLyL8C8%|4V}el8$sUKPU1EivBp zP3^IXNqnoegUBP8&(gt;B;_z6jfnBzZw~8Q&8^M#LeJLn+V5o&UbjX6tYg*k(E$P> zQ>SCL^CrPGFp!yja(8nKPjsvq8MGByksWd4WaE?T+A<^DH20i10uE0{a?Us_egz{* znxOmx{f82QdHvQZxe~3V$s&SCYN!ol_6XkxbM=*0|6B)ZNJC4Mi1W(S{i>~*c+d5I zBdOOa6j`1VP76P=liLgVc)R@ScSh!@BWc|>^W@`c-N%^O1sXI|wsjL!`~*}Oe>*?w z)g^k2zUOr)`&QJ3GkNl)j)YN(D6;N!=lmNKbVO;TS*TLtN5Rrpm4;I3avF z663=Ja-s*pBh)$>%~i8AE$KYlbpiU}teoRx`+J)Oy?cB?M1KbuFE8)EjJdzN>0!YF ztwrDM-sH&j7nS`ZcQ?aT;J zHCbvF!iK2+D_`f%CNLGxd)*4U38{`VMmCfJv$l{9z!X@@aUVdyUY zecq5={zQvNa>K@kjDXkd1*pz=&V03A0*T)eh=eBb@jM2Hm39g+69XoWjVaYB0d;%Y z=u2+iE?E)BaH-haCk|W8F@nDO9cW8ulWWnGia|XGy7(6_fV7lX!}* z7T0#i$t+Zl7r8o!uOO?U?q?>H-aPvscHy&joiexX>nEK`FR{cMXjf%ZFPK>S?tf;R zfm1?Cj#KMaA^33@ov_IW9}mwD=MfEL_ZtrF@x(YojoM4GIBFHg_t)w7UppwaMv+xt z=-K-EKH%<)MeSVqw(5wxU#?Urz+=z%>bS@>HR0%4;WtPG&(!jde9(#) zGOg-+&?+lw*03=!(3kFGcVlb!st7sD&Y=H5V9*`$6S2R36^1ru81gB&oMEsF)Si?b z3E_3DD)2TvYXi=Pn!J`6e18;aoO%bDs*W+Af0yF75zrj7DQY!c`?PCxdGh^pE5FU> zr(L(Xwhr*N9nX*L3ePx{6)bhdpo5r-6W(6DFfQVz*Tt;k@97~HFh9QW#nnMXGfL6) zd`;W36e>wPt;DQ^vykt>$5(dOcr0CAIc>lQYZMEg*LH@3NT;ahe8wjY7yL%K^a`!m zLt~-h?s=V2@nlzELq2*16|Xdf-2^~pHL*OKsL|ufXcm8X5Vd*G53EizQ-hW8H(DP}wnAMLOJ(6Y>eJu-vR_2~>9Kp4u6*+>c ze%{PR;%MH_pGbuEHTFftyj}}4+j!Mix{BYtQsK!jg1-b~tj;JaMFRc985J8gFQFf> zKtK}0k{%1%j8V_FT=ElZN#1KP%}jk!-kfYpztdKvQj zhkrR!=i|3?%6sLMFzF~IHX3Z@;V9Cx!;);)N@T|J-bM-j;WUC_0(`}iXZp9?Mqco} zPF~4QEOUNc#)k>MzimqBZj~m>A6}PBQpx&2oNLpvCWuTYe$p{MsZ*9Y_oHxhiEb(K zQutHt=?5WPgJ&}?)9(cbAg7bv*4Ao;u$bJmcGdF+_p%;lOQ$uzX3tApLc$%DgV*kOESC}|-4aA@UF{1F$^1qcmESnoiqb8WHSToT zd?(}TU0BcgE8S7>%pa1~)ZqaBfPC)}d+nyl*Ja>@@(pA3{EB)*enslWX-%1X1UhdC z=U%fPKlX6>;r8Xc%YUhArGdz|VkJ@f43yeo#70y#lNOk+uDd8P{7T2A=%>DpZtDx! z_K;c4vwW-P=1!^&QfbVl+X}PG=Yo`?Zwc_b{^$&dxbT?}_Lh}mvhu!qXf|+&rWVcI z9vOk8nw&#i%ylAi_{FeFwW=vAVc?lZI1TH#%1 z;uONh_j=Y|ALR%y;ip3CUaxz;cf8uVXN{+F&*wmv+YQW>&7uCC6b=Clq2KpD^PEF3 z#~fzdUDGE5wd>^RQ#8dGLi75swf7%lG~n-!RWxNWq9Y`4BED6K>lPYL9`V0u5!91V zuJVz>EiGl02#=b+01dFVmZt4{3*_VRsTA9Q~Xu^4Y6v z!2M1~yGO7-qjLRxHtK=zHFeE)etGzIbD$C{$wsCrhm!00OMZK3yL(OE-djRN{F;u$ zSB)9@;W=KV%?M+PuqP}E%%5NN-xgAwopbCA+B9ykDDilMvbIaAQ+%QzldYkp=GcUM zUUSAeW-Q*BSYcX5Jyz@?oTbmN)2y#}A2bB>)Upc1hbRvwQH_tiq^0eurK-)jufb&7 z-}t-132!WW?nIXc<-X}DOS#(qK4Slqq_BdNV;;Fb4T({cWGm1av;J(HqBDdfQen>z zQL`v7LgN_GartR$7h0T|WC}KXa+A@{--oMQGtY2aeZOkITGnkPHklY?ynCjRLJ?M% z7WR%j85`8R;B{XFII~SM>fG<77qNR!OI}!MC-Qs!(RSy!ex+z!elu0c)ab8GrJ*n2 z#I_TTh9wrzOEaWwbp$WQ|GJW2+RfgXMwU*kwC~Sezt@C(Wx_bFmhI4B@!{*rCyiX-sfw# zJVa~riykywjdAN4OzIjwsR8U2N45W~WuwnTFHiq*e6KZ;@!`salSl7m#Z?-Pcu?ec z;>TS^@~|vld-``n|0@|&PfmJ>&onr~iUHfx=3U%gbAE*0Azcj8nA`JpXd)@Fi~hbm8jNahK<5zKb$G z@jQ%!@f)eWOZ=&7TaSGv@4@2k)6;KoF1H-v50H+e*~XUIY`Tm)qXbGW&zkfZ4RdZM zt6KH$RPwK+qL~u78DGLel9y!K7plGpr^#sU(at8ViX-ZlRM{er8|}I}O``?M>@&6$ zblcK@#e}_K+wN?knx|~{acex6J%r_Mk95$;R-RF}as%dnsq$px#f-o2`Si< z_E3I)TFxy|Q@@%cUu`RGJ>*fqhLNbz@s(&Fi7l!$8-=yqq#^BLHC3Of9o3K~Q>cWV zWSqHUXxMIyij!cW{B`QMl^7pp&i}JK{%=Xi+RRlkN>ys2i5q#oNBn$S+cl-A3pkQ}=hPZAs3Yf49_IwzQERUFzbz7Rtr}BEYGz-^=SYa%8ORhGS zhEwbtZGexjo_feVRyz{G(vc>AjTqVgnL0iElh<%c-S)2Jcaidh1f5F}CDG2xUqd{E zWi76EW}jQ!sX!IozERUV5FWTt@ieO=0z3UGCI=x?+VRrWsQf5Jc*+l)H&UA7LHohZizl_8d%~IjZBWoA%YenDeE=sCEi5$zEi7 z>WH7?p!@fv1esmJhJ)=?9DZqzJf(>qvviN+CKJ5U$F@u_^(boFUFuDE7(1WffA}0} z`501}+UluPXHy(lkyIsg8{M54)>EwmA1h0f`AN$t!(Fy68h626UkD_so#G|tRnd>& zbkHGsd4+thk5b})3gM1jiMN(N^UYlY67aCh<2T5!WTDA~$solYe10Irk)C zVZpnEA|3f-K&!wm?@QaUNMF2?(lH;SIGvYB!HHdbAbj)N>s2{pf}FDC)K|R6UOFCs zjTS(l+E}J6Ki`2zRVu##(ymik7ZS!tnlC0thqPgIV_TJ%`rqhj! zH!@t~A1S;X6zV$~pj1ZrB*46WxrPxnRM7e2j8%F)iqAo9PQe&)>pF+Vs!nEfe9KL( zR(~B$y1M47blgi-^9xTuo;MS1^Hn8_QQWdS&XFpbULB zF8uk1&t-UPM5|?F3($BaIfgUuCHGV(Yf1|nI~vuLbz}&LlvBOTj9T0sur-(2*1xG6 zRk!25pSwEJ{@LBKq<5<2h8~qe?a~zI*LR~;A$i@p*}Vt+``9`~nJ=|Gdvm1ACf{F4 zl;!ZnCB?ERmKQ8JL@9ea+2X@bG>b%s{ClN^W94r<%HG;iE+=j%J@C9ze&x4DCg;2Q zgL-i%3LS*F`Q>H#4TY?R6X0%Z4%LQw= zq6(3Wt!xU#ez7=NY0~ladZqxA)p+6^9Uj3y3nIwW^-QMDu#NZgPTuk_`d+zoO}n?W zgfFOG8!+}5(d;daj@xDT5Q+=S)z$2hQ~G{dQy%%JUTrNg#TvC`HG(_#)7CE*_QoeK z%&Epe@)av3tdFjLctnycPp$Ln_{$E6EG@Lb3T)+>`gUHyttMHlR>?PotPoeJFug}JQW{fjfTGie}~U->p?wZWkBi=&DK=G@~3g!WH0Fa0-bpMm9Ic)=*L z@6SZgSgQMUqK7oy)~3ps$kxi|jTG+QrHF*gE07wwEgEw3aiN=IelVM{bhyn}Ru!02 zuN5mX0r3ccS8)b35;&@GvU+oLff~Y@J<^?*pHEu+TlCn>Y~<_$;De%*0NG+%<;kH) zvCeP{aN1ZjPJpA&=T~o>2k54Prk|^`IM9zCkSrWjG)CMbd7d@~!AsgG5PavU(M<0BCayr6dX6E*q2a-KF~ z&3eQA-5$>ygl#UD0F>ni0T}XQhmY<%cu^jAU0(z_aQWoWrcRl~N7*@}6;vHx!tXyp z{*s@Bui6S-T1K_Kw(#()+QD?j zIbS(;tVhqbr&<29(Jy6gsDY6&o9Zd+Y+6YkY($S*m2nZxYBY*yH)qSc9IjR9Aq$F7sUG5!U8|DeP{8ty_Fqce26B40vIsX zD(J~*&Y!Za?3l(3^kY-A`c@nu=4X%PRZbioc4@mhV(tGM@6g1u?&;-1E3nhaTrK03 zSZHhUk5_V#mK;>c!`^n#Qu$Oh8?kW{F2MH%=&^%K&b`bS%=$eIb+r^VsTOM$3k%*K z$}4KbG#=AS)HD}f#(ypG4PagZ1n6P=bQY!EwO%ueVW;|&|AZM6hw>v}-Bq};AmCdU zlf0?rRMkfv=a|{}ev7DV-S=5bgHaY!&}r)sdsem9qeUMk!-Jzl5IQ9_d)o{20W2;E zkem z1Z8io{Ykt1L->68(eR>#>*n5+n?5w}vHD6HF03LC5)y(1ypARBtz}kr-n_GG#_h|N zigGyazma+1?TXpeGWgG2L}(BF!XW!l(hxo)*_$e_wBDx=k*YvNg5(fLd;)2r2@rDs zVT>I?-2SyrZrm8)f-g4SOU3eC!R3-lAut?Ah14Wc6n@PJK#z*R(xiHUj6WyDrE+Hz`XX_b+a zyuZK30e%t;k3Iu2zX{j#fS{D!X*#o^ONS^{ZVA)b$qsoEr1iMXcI6F6;H|3m_ZV3@ zIgC-oxdhK5h@=G)*0l`^k+7BSV}m6EQq>ZPJPSx!b+#rRs{{TdJcdMHZYlMbu>dj* zeLRr8g7z_4&iN2fVVhSTA$6$MPx(IFR%PhNc3>;v--@(jEAoi){xh=Zm{?bZUXFgL-FX@t|yRzi5OFp3?0^Mh*th)0?btD(uy-_RYb3yZjf*AYcfZsT*Vp)x7 zi3J-Nlyq+EyOUp<=V!1jsmv^@W7iGlI2F5ccXw*reXulHxl#OajHs=Z zcY5H0uz6_#oKDzZBl*cp3}5}xT)r#yuJ=iL^v6>te=r@Fu@`?$41fQGoZOY2JCQi! z3frC!A2VcB`Dxj$d$c?YE*3AJ{#N}`ZxqLq=e^Z+sy^sWde9MZ5W?3?)iu?gr=VYB zce%DUxaOTl_GVChBch$PX;EiuzAD;KKLLKs>J{o-FdM81u*Vf0I`6qTwqb46Ltjpm zzK3P#9dd<-NgmqDJX${N+;j5J?YPoAKB2~x!8CaA%(%5>Rv?$1^r4Y7Vrw zlH%gyV7=i1!A~5SXp<)a{lIf)8DHSH?>h`=>=D*e??HnD6%xQILV95b&@Fm15hOrM zH-hr@SNkK#2Fq)`2-0AIRT)@Xk$QCy-Jh7qKa3(L`X_+1>}TTX#F|}`s_miNW_eO6 z(XoDL=zP~qZk}D$?gr6wZ;R{<Q&`#Y<2_WcQ!@pjp<2Jq)Y(1yv{hoEH~^n_<*Yu-aT z5O@t)L$sXhHQBonoB9alAV>6q3D85p%2@4_%E_~X(iwekvj#&>_7mt-T0LRzw~OS1 z`;=7<-sEEgizvuqZzcBM@2g={*3{?w?b?Au36F#P0|ahAS&8kY;AaDu4U~)4P?b$H zOO#K=27`~tZ-|)Rp2h4!>M{)aQ%YRXWxw=Fop!ChypSQmq0a#dX>hB`?L~X^(#B;A zj!yB4*wYF5817p57gC7URfQ%~72;EmdZhBFxwLXNs`oItr(&SQY|i+xkGT2{NBFwK zpJVM?)>~AqF?Z9uTUzT`?HncLC(!Sk?!NCt%J2V3sC;lg!m^T}=*1}BIMaTr`{qnr zP@C6@JijH8{V6NIcW*0srl`jJc3V;U9_~z-P)rAXt})MzUet*qtqma08&9L zp9|UY+T-7KlgGJYV+yJYL)C)WN#nJFW&#INIw)2{LCJh6;hBBkXGz_@<9aIA8A0eX zwfKhO(L!|awt{WVLDRqF?!Kd8bqnZ+qxZO__z`|O1sX8TE_$kJnPkyx#eF3kw?_v~ zH&J?dtveDLq4+}hu0)lsb|^v>4T?u7`ha#H>Dn|oDF|uv;D@;I7#JQbK%n*qGN_g8 zVEsyRadYdIct~Qg*kx$7Qm|u9f$Z=>?emv5-hd?uH4adv0(M@1ZgbCrB034bA!h`i zyvy^}F~|uZ1lu}Bt==>@1(t{r{y*NdYHk}W9_nV9RpvH)5e%4Q%X(f0Rfq}AkoCFs(t=#ycPYRT_0{H9MJUc#*6EzNhc5-PMG zU7IHAgCS}0xsgx(#3>q%A%WYaI0}(J_^lxJSU^0!NlZ+nMlMb`g+Qt0@#Dw8g)mA1 z3|b8gBppQo;OC$Qbc2@wmFN8xi#^snK%M(+6eTx;ioOi;=F@9Dkh^2ywF7AHAZZWM z4`b@8_kET2xgGh z0X-LUn3)Zd1FiPXq>aRa_^oO9*AD@ZKrnSZ2{~S-Hd6I%lMU#jo48=;b%DmAZk=F( z0Z;3Is4(<8vxdT2$(_mg&6<7m2fWp=Yo{`lgPBN54IoRL2y&gRL^YY-4m2%D;b;T$Ct1+9D8 z)pta;AAk4sa=X=`ohFqFHb!`4Zgy)@S|r}uRMv0x5kiu?%DU3x*DmM3ZOdU#7`>yU z`y+DfHHQ8^gz9z1L4^Dvr(6*(-_qOE4EdEOCxfBsf^No6K;(0i^8O_^OAY*0G1yRfR?&0S7$n#?1NCiw$9+k~1X2s%PR2DPjO zrKJPw_CSbso70o+u3jH7iJaDbjM*A4ccph@eBXlI1Us_(?S6qE_}y(k0FTT#Bnkun z4-_k4hoiWpq?NNXhe_Ji55yX+_}E{#k_e2-w(~HWHo}9gVV&Xkm{|as3u;P#IgY`B z2jc*gME;}m#cckZ85Wt6x1OqyKlPEUxlMMF>MXOY+*D&RG@RJkT73+xSU^w@x57tE z#F#kz`x-bL0EW5YQ-5Jp1$i8yw9EuwiNyR%1Ffx^nZfSBu<11O=iD&3Lnx%3fPN3G zPKA|&shs^#?<`rpb{f8(jhFxiq>;w1i?p;`-m?4eMUsAJ>u3Jc;@|N){O;&r`()}r zb>u4$a5*r_8$9q0pod3l2-Jc`!^Qjc1phwhV)&H3&Lq42)4Jke+mjaNP@CB;7%J4v)B=YUM%o5=1vljF^Zs>Uf_r+5%?QrICh29^9^t@)0Yw;Yb+ z3aPZpw|sWBg*fw9gQKHu{WANLO_?B~#Bz^2Ro*BR2rYY?jU3(R$q@~YGIKJrqV)RR z&YB_dx%eaGUIjvbo?vTDtIbztbX8p7y?h4Og~@KdyGU%U41W|gY^6dX+)7~7lxKfv zrtmnHTwoiYnjapr6F05FL^$XV>y}{klGbc4M>jGOLfq~YpH5|RRnCc;X%oeXY6glY zOCP+1DaoXU-s>EXgyr2Bn*KRG)>+cq z7on51))3Q$d**vn61`{ZGj*TRvH;{^$>1GBy)pUn{ut5>gBNXwqB`4?&Opy`6_t0NtNyMg+v zwy>GMo1ztezK$Wk?tSv#B=j#9!PovSFFy*v^aC@|L|u>s#z$=**!c=T#RfSVDa{%d zMyHFw-+*LJ0yeq0o;ySUCu1ed>cGm@V_e<?sH)!%;L1Hl zklZ@S@NoT1oe2ofg8&COpquJ356U>OJk`JF6oGODM1wh2uX90H2L9d5gF!xM)qVOY zm&3Tlj4*_8gjF{OurwH$Pw=&_Mg{mPUwoGznl%O zw^!foM6N9Rh$>iLxe$6`#n2uM0`~|vw78)RHo2Pq~9m(0`u zh7(QR6e0d~9F<|$n~+@_QW5ryCw%_yta0O~canr`DZGsTOcQ_S&o!PIQut0^WMILx zjHWwFxJ7LRIS>iXO_+Y6ldEv=TC67K!{?Nv3{Ne$SPz??YE$hSrJ1girG*cMge+Vg zaIr(Xm#TxPmadAH1_@PHt{2Fkd+zffK~b`}g0ofegU!fN{K{!5L6?>Ee*m{zsZS~*PPt~Sb9jEmab(KA+e51Y$dx&(L zYz%9r5C`O2x)Sn%C-!^1Lmkwo0NWO684tsMy>9)mL`56oKOi6g`hXp}vl3&mh`x;R z8`cgvN9xd7?Txg{D?bWZo*;Nwv$s&w1bPRoooNc%lwLW;knr7G>vUedDoc9947(*P z$;cVP_pn2*vy&~?MO?EJkNtx%VKsEts+Cr;f0Y0rLhZl$#y=6gdM1}FE6HZ7^rvSv zvPaOX1=*Kdw&Vm(9EBR*WnjE2t<2@$dizMG?O>c$AYNe-1brExJSth>c)J+xQK)5v z+AMG@ORZx0<72?|hygdV03lB)Tb1k3v{6@b2U5n|XZg;~tcFV6*iQQxBp3j)4)vhmjVL3fY-o2OA_!WxpfTqJ8EEV`Cqk*fE?ypB zX54mv{^U%&Bb(L5js*EY*-_8K5Sj+YzD>@PzOVe3Eudc78)jf(yO{|HmqFlkfr{R@ zJiDFX{Uz9v5kQ>~z(sG45S9%oM$Aw@2Vt^(^8S|Jq{<$Cf6y$YmzA}<%qiMalVr>F zMX3EV***sh5VQ*>eQ??`PNA*RRV2sEe@nT^KlJlaR((GjtJ1o*b*_csVqS)U-R%Gq zA3}^N5jfL!)xKn1MMB!7eWYna+6^Z3Y}M*-d(XtKge^}O#uKB8*65TCvkqO`gPs>s<-#0ml39Ftzl&F4%VLGE8!oPk>vYl}m%QTws5*wXE zrbR}gIJ9RBlk+S#r` zuoXcAhCZ{uy1%1B%C*?N0=l{+pOyQd7Xn6%cY0((%^p%|-2*YP&6|e@z~(H#S4_qd zUvA~i0+#(67Sp~U2fWd=ntk}*eE!~Xi0ObYzekLVS!`);9{O=Cjv+c#Y=_~PrehDn zB)h$`b)I~}H^^TwgfqEcu7^AA^-)!-?Ln)jND)~8n>JRx*Egk@e>Bo8c=IC>b3BbYJCE5e4dL1_$8G+%)Zdx8e;zpMrq}kIcoZ>(sBdmJxU@8 z%o{`dLguRYFG;|_=~%Pt&TXX00@gI$dVeu_!p?$mes76={}cp>08UMI^9co3@eg?Vw8FFb6Yba z2y0i}>DV+2-}nq>2CUEZ=%#W4*Ccb;kb$2j?jo_Hs(tQr^-ef)NN=x4kE7diH{3rz z+Aesd`~*kK_YLyBCuLXvuy)pv^(otQT{yYBhf;G$Amn7bbxhc=NdDmYFrT$DO{Ldq z6i^hEm$SGdT8Np%^;hwN>#sYHXLu8pD4_}0uiOL^pMnzEP_-;`;sQSRlSMwYWhEXS zVGfx$^kU)Mn-Mn15n*)nnm^9e5?m$DP~Lb0(Z|H3J=6P^w~u1Q%&oO7hfWr~Lpg61 zgwtyeWS!2L__f9KM6=m2sKD&?#L=u9{p<_zr|5 z5tbt&(_}r=_KI7{#P0VSKR4eviINJy*7 zT{kYgh9(PS4hCGiL&e;s1i(3iU~QlJ);gH&;Y6L3-x&{9`JH_|b7@%v9-*>RuL6i} z5%=i>_5yg46qtv4K>lF%lu0|FNoppu*B@Q#C*UXsx65K8&?_P{)9SXs?Bgr6}2N?p{2nWL0DGPnmc zfXe=bGGtB$hVljY*=EX8U$*`~u$XZDAF$#~rZYjkz|K;z%A#*f%C3~8+oDqZ_!*W3O`qV#Q)&U@{wVxu^5q2@odG-xeY+Z)&cV!lSqK=Fk-Mdi5>JY&j3Xj`1ov zDYsmppAf9)%dcx87mjp@>MotLH~sGZsdZhi8!f%gaXy!Lr>oOc!7($l3!70@A+Y`6 zdHFqymd=<|p0r`nKIUn$mXBb1?efQ1Qrp}UqiVjWA%UOuO_zy=$t&gTy1r#P{87%4 zx+hQT_4}+H&)!{gZ`BvJ@-opI`Otz6Y|u_0=ZT}toR}`Ye1j`!<~E84vojJQOG`RMla4y= zGPJv^9>muA{bG8Ks<2Vmfg5YMTPH{_2M-0EjCV>jm%Qz&R>lt-WSh~K6{NANx-!ji z4n0(iRQhHwJO*yXc@O3C%g9sw{MZj^)*{dE6djwQmOIX(jroT!{Kt1=Qk?*tE?Uq zZsr8dI6pf8m7-;UfX1z03?BIYRwqOVjtJGAXC2}^Hs8f<8=0STrD|uTO%AkvIN+2g z72pNxL6m`z`F=@n3e$AIqy25d0yqqOK@)GpM8l)LNmq=LNtV}Usf8|rW+gS(txZHX zo-<~Kb*{vM{JZx(e0_bL8o<7)AiqS5_)4kzQisig0m1+(7ogyQ{#=nCSd^{6N5L($$*RJIt-GI^q$bjXe z@V#Zszk9WYub%Unxt?4Lb~{BtN*TBmAi0ekDQ1TGoPw?y2Nb(WNDYDT54eiWB=PnD zOyE7{)$}dF5lsK<Vq^__N$~$|}`781x6nY_({idl6T(i6-NGi?A z0!;fTHyZ%sWu+?kCCoBQIg*$$-Do5EAOgB_)qOMYMkhDN^uQGdpg2!TORvCE)v*+iz#DEm!277WD1B z8LHsE`uSEO(#%70%o`*>r@fsm>a@LeXHp|zfc-b_*g;?RB2@kXKCtdRY1Nxci$$H! z0EC8UXHeZ+TaN=i_2AB`-(Kf(upd2`PTQ;8XM@@bh=~0MiUI!$_HBT@Gyfn9XeMSa z8A9-I6iqYZlXqtVPWF)zXiEn>&{E4t5sKFYFwQf7zGmYS%4r7a8{M8bygzCpaVOrP zf`wq=rwq216bMPOYptxkLmWR)Ow0QsK<=efGJ;AC3H?|4@`VGiQY1pC1UHLtj%W7n(Ljvj_|H$XIukO+W81}kmEWT!|eVuWmEMu4C!=u?0xLkf@= zVDpo96WGBDK2_>`cm-~T9~eS9Nzl%eUuaXs654r4{a;yG>C@_i_Ar7@3EICyb1^bB zeEL+eO?WKAJBufXUaf=c zye>g2F>C7+F3*pJ_gW=!7fpS4>5dx1QLMQPVC(AcnogDGB3D!EcZ}c`F*_GV@Z&#v zG7rD$oJWYpplFe@%r@1CC0Cz#0gGZwXK2v!-PN<}-KbP|3nO(Ln6v$>APw(g z26F2|)np}(<*bhCp{KCO(O5;8+R*ofxcW~H_6t7Rm5iIJ>0vHx*qL_=1YCb{4&!z1&n-A%nb@S=tUDm)ITy2PT=`x40}EYCRlr5(A+)! zKO)m+YVQ(e3xSEuzAJLL`QDD;t)Co)qi%C#2`G_K;^mTxY00cNT zp3S_#f?{U0`2nkpDfgHtZRA!_Pa!fp38a2FM`n_hI?zC}&h%{ICxGSby)-Dn#QE@w%o8j2{M@#aC~)ig{qNtC z9a;pZ;DoN9>c@{BCdw$jp_A5NpNXxl))Z_W#q}pjx=qbp05?Kq^KCCyG{Wfm^{SF- z&!5C)o-Nj4+^PTqrLknw(4yWX$8$9=ui9#)14j!QxsmS&hGQQSKJ-Yf5nnz5O?<$y z4LU7J$ERZs&G2!fkcSilQO_NYSc&f5mAF?RMk09fLjqBycL&h^2_pN=&7KdQ+;ZU<9M5|6 zZp9C*@hR=%Pgbm|x~$3E-So%FxhbZPPZlb$lttJAaX|HPk!_Bam-E^$<86B9;#3RV z<4^&&1mL%Uzslk;tHR`d!_e|OPoMsp9b`{Sv-*DYQxotEm#DHCQYO^lWU!}S@lj^6 zoFw-@@MsQ_kbz~mg+@;b3F$wc0?f6}!xKfr$?gf~Q^`XoV{+!Y9B9F8iQn)(+KOP- z)=|HekgHR6AD88qy#9JfGZrK70JCo+cQBx|1DGx5?)*O^II!6AdY!;Hro@H$L(T#> zX5do51fbSO9pEn<9UWaC)~r|VQmJuyL zvdEVy;MT^Z{Fx8Ml;wgA(j-?67*LF#4~M$OJKR9Yf3N|rKTP4oK)>-kXm|P~6UJfs zID!AeJ@P{V7bVkj{xQ9ZGy#puhD0TJdhYCv=nf-8T4fs0%|BaFaRBY8(a{%n=*xsc znT!Vd?48x$S)NVrm`w0H)=bp=UA@wS(%;O})hRdVG}B_aKA&vz5|CIR_U9#;@{XT% z9RP*Mxz?Uc0C4Lg|i7UO8~Rm6b_X zqbq^)m;ZmfCV-K>W3D&m9J4``597yy0|=Fp3ekW=o|SouGwIZXC`Kv}2QdSXWc3S~ zZ@vR>!Ss9n&nXYQEGGCkR+;7sNitPe9)aT))LNx?p@msQQt;TkbIXxlQ!m=30YbqEj7h^M$mNoLGE}dD0n^&=fZRjKo@tNPI#Uy-xmXwiGEmM$xXWH}%8;M|7?0Q>mD8*-kp zE!uYYAj%6c0(%3D4B-54piD2&Xmb?dxQ?N4iDyXex7qTIYtMrP4`1I}z7j|Ge-P9F z5}*Kd_E}}pYUA9*(?IhQbi?gWY2$j@1&X9B)`7Yia)SPjjJ3wCRo+DG#n*ImT`j}L zfN|P$z_Rb)P{0u00aolKyb{Q%fgKH;#aq>NK#)R!r|Wg-Ij@|#@Y)<8;}(z-GAHTf z@tc>@Ggae+#33O{K!|%)OXVA2G69|(W`@D%}r3!05MTFrRe$F|>Mnk-mD^#sKp`WUd5YUcdkNF;lYP z4Lr0aThfJ`SYX-f7H|Pq*?}LvJ`KQI+aD}j?E>o{22d=oa@+08Kyz*P+-6ue`yGl0xX{yP9jGog1TfZ)A8-71FxoaW3){tr_l!px^&-sC*$XHEl4 z8{*GQegIymp_Zs3WX313cQV3!*fNmlPOQQzVL5?q*ss{bZnF$GW` zCfp6zIK0P15Rd(2sw=ACuyf2;0UrnL&R8l&v@`bsVDDmb(||JVe^`p@T2oD+_ZJqC z8S&3<0Tv3Ei0|A(8en%K-Ai*j-xyFY-jBpDl{famGOVza-m}|EL@=kjZk^E`sdeu0 zC$kp8g&O%^rY%x_>x1%jHhd?PHOO*z)v;d>#5wycZ2!^b6nU}bUaHQJCFQ8@^!+X$ zU+umMeQ{eX?#Az}60nO)`#lM`oa&_OtRTeHo^4t5cB&}e{yV{OZ~FF4AO%fZc?jLn zir!oHIkS?!Yozu~dV)_+67dp*S_`$9T#D$Cmd8P+Di6zK&Mu+Pr$WV{45P5aqeD?2 z9+~$nCwdDT3x$sLi_~AR1#djRz`_q?&#x!S54U#JcT0VB=ltY*9wd@@M>6<)g)XC0 z6-YPob&N}6w3KB6YqayRphd2V@xEpDa|`XuZ^ z?$m-C~o;OjtX{$J!p@h^HUVGwyvl!(t;j+d!B+O(8wB z#5!c~l{Vk{6LaKsZ{_;_h)}Xm@eM;ip1n2KR!S+nW;#@7!w0(PS;9(Th}El?HvT1ToP)PDeFkm=Cb8{pe^PQs;&|f>@Z4wT>m4XKhY6ar z7&4_jfY};IaX>!>^iqv{G=S8fVM>tzkPzVV2h~G4x6MMV4wy22K%v7NEP)t?)t7=Z zRVk)tk==`z*OR3q;i#{Z)RA6apCR`lyiH*r*MQ;kbBX6%1U&x~XN-t^;mKOSF(i|` zbI-t3oHX4Vkn9wc_2Qf~efeNU`S}aMor9w6rJy)@lIFY(%lK?C2j=+~cY)aDEAsT` ze9B$Ll)@@wbvsE;ie6LM-6Vvo;Gg8{r~(LXj@;y2%;0C?yF1)>#uW#-F?`qrn1>sD z1aSX}vdrbf6E(PGPaMqdS=#(i#cA{C6-MoWQ9Cs#ojtaqH-cNG#-8x|xvgPGtel6R zxo_~W_gyyK3k)fY!m5}Ie^n^vOWgI`+nGhbsPFZ!NPNNZ)y^*U-7qLK_uYchQm?RY z{OzLScQTdoi_2gS_PyRJ*#7)Acn1)<-`0OHeA(em&1WdQU6mqCxve|L)xMoC2^Kwf zy5#gp0IEKFwg(X1wE@#VhcGA((8u^meu_6?+j182=VN`QG;L1%4sw`$gN1!pl~g(r zGp*AFQ{T%lKsW$-RF5?STqT>)y?YVa2Y<9Ojb{xW;57hn3b;&J@L7+7+``yfA)Vlm z_M}(m^3@ZC>(*(4BU>u;@N;)`PomlY4Q!nr3ZRkn;u%ezHdl1FJ6uJPH z74ocv$%x%wfb#zZsKc0gSOD!(I&k89Ow#myx|)8&-6#THnoV^4Mi~ss&ra53#2U#1 z!BJ{v=U`}n#``GM4njB$9J<1ruJ?KH71|u9>3oW|=*zIf~gLl{>q0u!7Mdy`OW&f99H`;t0{mh>M;I zBDEp|1gC!mln<2d2WU^d?s7j|X$G|q6J2y$8>9TykH43w?rFCczE@<#!f@-0ED)sg z{2hpWbV5X)OCOi~@K=tnWV0;0_SO(d1%-Ico%>31{n=NC%tg5|6N4~!Orry#qs$fb zLpLxa{r4~UR>0EQerYCO^A?nQUE~dybyz5fM0f5Cw>NzM-mgBs;%-ZN)xpUg zhlji|&JXoFJR_%M^mUkXGR#ilYmlJ<@&V(uovBGp+!praULov*mhq33HY+{d_Ii)& zqbmGS4{zRMn>W!9v`WK|5${u81*wIkfz}X96DALQ=eNqi6R%5GrGDu64ekz`)|!}U z!whecoEbrY&H`>A-YIO>GjxxueQsm;vKa~$Xytt|GpS9D%OlL5jjxxEXc zwPi&C2dqjKG;uB4h&tOa-uc%eGkgh779JBAGX%Bi!c4>1*@Z;2Tyv41wy&-Pmu6iW z^fTyBYcB+}D)1wTzezbGt6nXJAlJX$b1>_hA(14Ew)8;rk?m>C$~`CqTz0o>{;KPo z|C$awD%8!d-m?h80t}Q-H7?p(p~(R)5bHs!>ZN^cZL6`89MddV2NxA6V-? zPBigd4wagcu{1lDV6B#TGUT}M<0!)QSgN1T<4A>)w`D@#$xoaK?pUo(G5N$!ATT!m zjq0@#3hKj9ty^PdgDGRU1B)v%?i4P%{B`kEZqS9L0F4ZzRTk?MtGU>hGjDNKd6BFq z)ul=jglyU5zs!eg9q4|F($B@XHnXHk+Q&@!CWhP6{^Uvy-6SoNS#|7f*l5d}#1XBW z<=IoWYLz!`cMCPh*SysHS)dH}5gibMQ8|dR8#`M+5^l)XKOyXGRKaLDlZbgNCBYSFt5kpDB z$PwKLbD0p$Le;^gP@vO8sYr*yUT1-mISiUswkioFZJ>W2SRjl-Rlz|1-jCh7#cEIx zgyBK+xv&HYF74HPR{DsTf6wqBDLgh!yURpaFa<}`B+>F2dLsq>PtCm?-tw()y}t(0 zJ4kqvj;KLda}SJIQ5)>w;$4yDa#jtx9#b1_{@rAA_dp@E zWvDP`#2yhXRql6SVn)Hvaui_GE_0}&Zp!FgY))g})cc8XK z|5IXFTv?;Cskp?I7gW`bl(#9FO46i>NrKXpn&_hm-nV53z5ZHQw#Wauk$HWtbABp6 zz8rxi-}bRWTF~C3RDR%2KC?&!M=?l(PYu}~pHcu1%okVcffMRbN`NkXV7^oqeE2MM zaE$fFqdmFhAt9jb54vpL@kS|)J=uTIn&+GElKR3GO7EQo8+p*{T@_ppCA5pTY{0$ zCMhs)7RZs`6li}FF4ufvwV0|`8x7)w4;JiSkWgWF=Xx~bzS#=1tdGMlCd;-uS~{iF zfKYSTRa;u0Fn5=vPTOdY()9m2)d;Dfy^lC=x$zS-_BdKXCEE5M$GP^Hu ziOw8`n1Q0mA1}~PiVFHy&ozZ1rZJI_8x69(s?Rddx2s<4z#AN(KIR37A;^0y-owJ{Q^YY12P?@{hO zAD~?G7$V7DS%pBTwyw2kWplXoG)C^&N5d@jGsY(uTJ84EgiPbA2DjWjj7MQIH(!tJu%{Q> zFHd=%a5X`l*XWY|GQ8le%{Z3-M^JOC1kS!}o+IEeRow^gyfo8fbt3-u%`-lix^yWG zIXv(!V-_P1d1vyep_H%J`wO;NGPwPkkL8ZXj#%)uy1xWDn(VtkGjmJs>dJF?!UKh{ zb6eQ3(sXAtK|J!bEu3NKydsg?{1Xxggi)-0ENR)>6|XlDX(6|m_qXU5!^5R6!AI!g z{KZQdwEX^k-5^tCb$Ns8wdnb5MI@(0y;@L1n3c6g-xC5<^ew}TdVWC^qGuZXXAblC z#N5q|#=d#!E@#yf^yd4-MJIcY;SP-2<`1rA)LvdEHL~jB>#?Ob<O)NcphM>?f)*VhFQ$|cUY3XviEjaKjUvO|Li!uti6RgPMN!A z)xX(`2G&cz9W<^ozt#7esvaVRJF{5dPIF4~pe{tM_v}_1ESBA|k|liG@d@+=kNk&y zxi3n3E>x-EsPk)gdwp+^N{ubJsTZeGuLxLuhuO2qh5rIsm{k|AyT>rTy^?hHh+?RA zo>Hz=G)jK8rGrw{I*{TWnNtzV0-*7P=;#h#B)Vzk6m~7hG|MfDo-g6C~M_mreTJVItvFpNVexQ(UHCX24p520Pkj)RID^x3L{XlEnk&Kx8 zoHI=lm@_qQ^&Tuiojx5|K)LiOjW_G&TCW;eNLfW8B7OngT$iP;`i#3gBcXe~yW1`1 zeVlI^0_^&CBYzzWV2txOd_V3-QAXKTI#c{ZTTz@LZSF>k;uOF68gcUS(_Z=wiEGgfkzx-=GS+a)aS>?n@HsICM41-3t zF`?0c`UpNQQW-3Ie;6vYFhj#^Zl8gv!5W4^bMf~mCr1y9IVD6;Mj$X&ko?X6B`(K@ zCn;WwRV5oJP9)&PK1Ch=73ZdN6zCba+kB+nznkiR)aX%orZCiu}$0D zzn-_9oTI;itq4u_w_9YfL&_`*PXhsl%$Uaj(5KrZR>}MnX<`vaTJ)2A2VF$Uo-7q| z$+p9jU4)jJCf)96+)0XLEN(^rbLZCAhqgNO+k-@!z-F!%=w(2@=p#bJ0NH&WjVzvX zb>Hx^DC<2e>=c1reUOMzO76m6&Ci}meNvSP((4F6qW3sZ6NG1l7t6CsXnVV4 z(NcdZ?!-hpCt{;H^Q|1StsqshgGh<|QoD43=q>nZ0)#bj!S{%_lW>ggzM#b#K2q#` z5J6X8Qmln7eM8GRJJg~L<~KQb?VE`)B_+@@SNXY!f~Z-jC6dOCoja^v_3MpAxrDHd z1EIlEa{!dx- z9ySCrU)t->K2TqO(Y7)n=}5&N9AIgciAv9dP88oRp48O{AU+z;?l=(Z`xa_(V0R`R zhelDT4Uv1Zz*(u7x-+`aCet|3wICA;4tN!0n?ub^)(>DoJ+etOZYw;xUYqm?HADGW zVj>B8h}v2*Kl%aU1lp0U>NOH&lD1 zzu(d}8%0&iU0PUCcl7I?xXp3_?cN&Tw@j4lGb#m>jcL#~qV?y93md&l5`nADAbEdL zS(`Vl{A+De=XFExfw|P zr;p$cMTrz=Ti^3Y1gyKh`?OeO%7ZbD7OL!+gU^fa$=KT>x*HEcp{UrOib1=tAtdQG z!K-5im4gJiXN2Ll3D=ev%Z`uEbIMO%90)cjrs=2wysIFr;fkfb0dhIBNPZTQxt!OF zQLMASvFF3Lw9}-0=xk#Uz-IP0p?PuQto-|oAC=^R?O*=!ngHL!Eg&b(FYDoZ$rd!Y zwtuD$X&oK7Sd71B9%8rThimg+wJCY?1p6GMy>YMCU3$VK3P^{!4m5O(f9<5cI`IYS zVdU~GUr1-)@GP7z3s10?UyNxZ%cwokhH@EJX-h5Pf1LWS+kB_j=OC2+E$P-`n(f*m zHMKwGbR#A}=v2YUHZ#MN!JOh+%TP)*+C!t^r7+@g6wJN}{CRL`og+5zjJpwA$zA(s z1wd@`2he0Oq0V>r026R^^~F$uY1H{O;2zv_`WtyKD;IF8pQ$wT1ULXRew0&wxp+is7BxeD=`0G{&t?vjckxIRT8M0+0n#M+@t%Hi7_f#U?=a^Qq|Av`P^n!&ni#kN}h40s|q0p_vOk(mcSbw%&Ud7Qs8TYua7d`di^5 zMb57?u>m)`%VRd`E)rY@263qip5}c)RR68lu)%+HH?&xwhkzZJ^YopIWv*|_8dWij z?pKc=1FnG!fc!aNY?l!o1dH2?p3b4BnD6*p5!UE3p2+vMSuRcj?|iu?I%b|Szs)|L z*PKKEZdhqkJRj@WTdpEHIxux-ut*0@ykCwyM7$T0ck7baxZMnlckDVeyD^(D%9D?F zW$SCBgv-rwVPLycF;rOElrhPbkHXg$6(qhX1L z>%m&5hLXcor4L_59&&$Pm0)+hi zs=$9VGNuzNqpked)TyxX8sC6AhbPUOyFw3iPkqO&N4v1N_)xP)2NzZ3KtmigjW zf%~^c`CM*$>@PXSiRGMby%0?xLe9soWB6fxWWZ_m_b@351H40FT?$v1Ze2>B%c^~8 zg&(Bb(d=?bQ*5rUZrRN%$LA)XSuk5%FBD@qgir=bj~?qgvyVG!ojR0xeov_GB$~fI zxDBc%!c6Y5m^vO1Hr8QgZ-3rd?lo_pI2S#aQuxqhI#AM9k~p^do*Pzoa4k_`+#;(q z-s0e9t0Q!YoF3j86}`>LMZ7mV^+|8vjNk%`-i3VItv{ZL$SPFBe^~Zcw*!s?AW~6k zh0~zLd~->PI20RRJM8NeVf14LqD29EPAgT!z*39Rrh|!RJ910@4nm5eeTp4jFXm)= zzAw-Ibx@!-eo&`D(6MX(?1p$`y&%@jKTu05Jv%k7I1?jznQJ>KY=LzaNkczE-VH5Q zZ6c6qFGXxPdQZ-gdwT4+hzX$TM+g3*O(jhz3Dp@~)soLm(HFUDX@Q0+R~0{V=VVk1 zS9rKvO+kDLtEBc`hR5?R%XFp8UK_217TLZrJM65(8Hnd7Yk7%WUWyI$EA>mqL6r^N zb2)o0>}^DcQJo#pQ3~L)M^t*D%6n9?0=?PymBm(>o2-0H1+gl)ueZr7%P;_ejL7*187`X+vCEfi{t_FyRmSxlW?EcWo~FWN`NCmwMavpwtp)s|SVu1S1AG&ha{ zs|Q~jvRLKTwwkY0mmUVHy3j^f=FA9*!aP32=y@URT_Z{D>zSVd9OOqu3j8Y@z_(f&>^_!WBSUMZ=>?M)AAM0I1bF(k*rFaRt#Z$( z82KAU@A|$=&@%4qiD_7CapEPxrxgj?BRBgE8YBslAf9RSAaPdVLTz{Lp)?hgr4`a+ zZpcxaM%+Y8-1tsf#~Nya8Y(5P2J5goCs}?>R;kTh;keCu4F~SfU`c@{v|p=|hWm=0 zf@AtW82>qVu5>mLE|8uO9&N?-Nbz;*)TzI`ChLM!yF=x1;kz(ru6X=StCYuB2JLO|SS`q248c{s(IISz4|hF?evtdPie^-X=pRjU}Rw+a0#hVgg|qzTW^t%#!%RK>512L33G$ zgTCU}Ah%em#Kcr*0HDGH+q1;axh{ST6l+29B<2MD%=XbrQ1&DK5_VtpOm<@xDJN;u z{X{Di1s*aAeG+B2+6Y%;WxY7liP*<7KDk^%b|=`Qbk^*^}*2jby6lGeRk7m zIab)Vkza=z)L=WtBN+mZQgef^NXd31#l~J`uN(aMMM*M=rFx5*JS*g`5P?hPYrc$I zsU{p;Q`a)Uu)wD@pADa;9VtaNxAVjw$ITqptU}#NU+jNu-%*>)7-tT>>q~xE1TvjW}^+ z$q||Bo2xQ&?n?=HMgcdoe=CQ!I}H^mUXA(>&WTu zzJO3EUI3xP(@;D4fiv#0(ADG|zOhxmn`&#c`3}ZUpc5@a zP7|U7l4!*DB%M{d)s%3B1bfc0xi90oBjgKqah0ZyY&Ce+yvjy;AY>c`53H>J1?A?j zcibPGNP8a)4l`(Xh~Lc*SuK~iKIXljyH#kqFVK4Shy#e5a}w?Rj1CV-?vO!{ zW42Nq{%ICVUI_l5#k{)uP8+T9^!8m^%BzlN0!FmF%KsnEMu=#dFrhtwLA2dnu)U!wn( zw%iykP z7B(RxO0i*2vLo8(GORj?4?7RkkgkX;GGlRj37|#~ofOF;Weu*bMB^0b$Gs?MVNE4a zbSQ}@ka%nheMJZ+)a)!%)#W!`p2f;)y3NjC{Nl~#d^YK%HhK-n7XyVCRfy?g-JYsJj>swU%x({yoMKe{Q9k zy3VzeF}E)H1@$z7V%i}(W%JtIn--*ng-bq@1gE1_AF3H7?y!3)t5_H|hCWz@ zksw+OxD?$qU+;>iy)o=uuvwt!R;sm^-MDX%I=|+NFu;3#Z0A%&kINU~k+}^OHA$|P zy|tb*Wft@iy&^p_`$$on$xS0F#~z7+vyB!#4yM$@M!-GuS&UaGlfX%}>p#ZmG67w} z-4@lvqTyA)O1-g!y`K)IIUE26NkcQ94N^e6!IoCq!8w;k3TB(+l;p*l$1f2%>tuDJ zq1sv ze$oD9f2vg3ddf5Aa}*6`n8(PD??|=u=z3|KK^2o>dxl=ty_#KzYIR~q{mWLunbJV- z!&N{>E%@Ns1Zy|?Xcakr^u7O4cQNkA=(&>(^M zPwbN4Ywp|S76M%tfahrzux$d-mN|PjevbSE4o&EaP9(Ht=!}T<(JilrgFkDZxM_eld$ z{23K*il~pc5fzY=j z1&&lFuvzyT(eojf<*~(khID_#)~GpOE=Ekj;~xw$ulOHAqU2kTs$sA#7J?4TEk7er z%;&xX(J@eBcQ>n?s2qCXds994o;)4;a&@CrwzBbn^Q43zutt*2Sf6Hk>qyT^J_<5g z+#G#NjYyq_(mJ1W0u`_faFk8RW~1VzJ{OkWV!oVd-{!JCQDH_DOZ(42pmPRLq%#GA zfKd?8FXlPvnVr}I2Z4RpvUAQ$uqfX&XPE0pqxuMu0^5Flb+$Mco%4yi%XmQ?F`-0VX1hZV zY7M@Ephu_?-%jVp5Bh|tPvEZ}5JQs|0xZOYr8=e6R;5^h!=oX0#cW;w(y6JYgxuS_ zFD~^p1)tfvO_e3j3gGNj4ZYozsEuoc_<=Qi?Qy?VcBU1M0;l!GO#3%kbO zNVQOMS4E0fm*&}8T84_)B(>Wj)l4)POB0pTbKhsxxPG_E`YNF9~XPa-aDvJIRWa za~1X+6IM7vRCa)dKwkI*QMUvC%*9qw?f#aN}ha%zJ>KH>HL9UWf% zfp<$nYCGFIi>6H<2wm-V*M4vx-X8OtF%X+y*g?NA`b3#jESvk*#Vqp|#G+;BDRl60 zkLNDo>u-Dx4(9c2ShrrTPTGVeH3o_{f7KE@acM_(EW&K&pmhJQ2EMb$pdn1`QBc7> zmr2^ANg2|W)p8slOe;Uy9DzLnZM6nXIB6~AzKO>fDFgw{3Nd+A4e)MX<O zv4T3N*f-@NN~T&F{0`c$#J)&v5fo7iQh}6m6gT9Yaf5G46Es2N(;)DcPPH3O?R#O0 zY0=?XjbTB${$yG6&)Yyp3CQ9IaM6{kN8z88cuM42_mk|ee|%`o?%&>c&hXq}egU{i z(W_FiBqWz*=PIcSy!Q8{uoVvYBIX1Kw_UHkB>|D8$W z$?~Pv47^fQ!ANs`JO-FCcF@eT!(|5@>? z-*^&K#I<_T1k0N{Cza5NS(d9v=6mO<{YqM^wBi-sch*~X{i!gp8o_MC@@pm)dj6#N z9H@G*XA z3fm^h&py`XOB)t38V)V$k3I2cy5;D1)RD|%-cRFQcCP;{4D~Nx`QjQXp1@AQ)pLNPvA@uc`a9>QelabJXFBGaAiSQkM##I_j!YJ&0irNPUTnA^|(-8?>W z0camPf4bzu1Iv*UrsvIMWuCwA<`18_bMekwc0l8OIo=ghF~wpcXaOe zYU|$RLf$N#IaX?TnYlT-@B_l*4h-PhQV%}&d*%etUH`v6I}gdq-utm~UjvV`itZ^@ zHeVTReMhMUhq5TV4`^a0?*P?(z-GI$vf?M{3ElKYF2<^|wpeedxJamr>@+P7Ev-+K zPHd`-yT36vSDi3w3;IfYVdKfK3=bGfFig#q{?I#lU9R+pil-)4pVGTyUi#^iPh?6~ z<1`Sk+U$4G!;rpYEf_8vE!S6JtZU9_J5RN|@0(pa zVH)N)82PLIN?F<9o2mPsZ0vF90yyr#UmZ(!FATELwwN^kd`kH2AG=MJUU|R2%c3Yk z)I}u2CZBRjj+?&bV7hdMS_~a|-VZCgT&gTT0m-X0uStHAyIs@nHWyr0w(!)$BaM2? z1jD7^0Z(W zuRVCBsudIZ-!y3DRN})zBXX{MF;`;TZ_2$|B($X>VA60-jLM~nwM_p-x754wbqe6U zbQ)fV8hY#8k=m>v-?&;`8ofvx6iazIde%_Ia1*qckts2S<8vFTvVP3FJ8UUu%d>H4 zsCd;9#!Wx8E%#h?5}jx{1KSg$M?cXL^?Ze6xaEd!hMEg-b(xUD1Zulrh=OZTxwjvG z{O!_BqDG8M(arOv#Sr>)$!%O>gvuV+Lu%^vNc~hm+~Z3|)JKo#W0#PPoV;q zQ#rk)j)w*Hh8ZIu`PXfg&pFiK#`UY%h7|@?PUKP>&ZD@$6lzp!#G1tBo=x8acVviH+nfku zyqd801X8731Yrx=T?aVZ5=?15-ae!e`OJ#HZ>*_g;@Fj)9`|EGpRfhdn`swV@09l4 zto%(}(EUkPUVKmrdvWj2ui01T#t6fGp$l%WF&2&3cRv7udvoU%AiT@~dQZq-aM<%~ z?omnK_Qwf~EDZR5RX1L)pzq2O|9ig^v-fq*HVDRyZo9)ZW#=uSve4DMlPY@aP+H)- z2IC{G^xH3TOKksT`2ZpMs0=Sbbl>6EH%cw?{GBdw=sDHw1qTAL3Go~rR+c^3S?(he zHMmu1j_;ZSaTPYMT1(M+G=cgfjK0NR@ikbU7d@%+B4LYrxG~*!vvo^aj3Cj{BkBt+s-v!8&r}XM+G5@Bb*@3jun!E?67)ST zrQp)pQAh>ij!^1_F)siCngcGVfqUbxH607o(oDPI?S&Gv8%qHm+yYVta=er@7O(Y$F{gmQ&W>J@BOi=I0KLN`|-I7s%G#?wA4| zD^Z|mL*;etm#y2~M>5(?0!UK1JYNJYaB{>h#gk%Bx+*3xfV=cU7ecp6FXc;ApH{QeVIRAaWpmSj0 z^YSjgLb8V`9%Oni&&T8RC{P`Xb7~-%4MPY`aebBV59_Aq=6q(nsi^r0{R|6Gbznor zfBt(Hx>PdYL3HRjsU{%7Cw{AB-)jGLReIT=GxfXiEPpBRAN)`$AL_$!WPp%biOfPnPROBkY5imDAw};F>Z!8q~MRdCQ zQkuxGelX?)-&gSv6Hd5jQm6-%vvbL*BIPq?;05zpR9&BS5a;X)*@3D<`PK;9=^`3> zO&NL}*dbVfBgg(`(;2R=Uq1Pe!DVk<>`;)^fz=6kOh#r(dYtyA{#CjkKOjgGSWHdn zO!&ueNrvFMB;>7k^Q@X1DB6Q>8p^Z6u@!A1ki9zroVOIDnZ<|l$YU95Pk`uwTOzm; za+mRu>w~$KlMaQVc5vgS*P4=(D)j5>D8$1oTnFg8h9SCX?Z@Isr*(#-)1~bs4KKCR ze3%Sw1S0_uet;}7F0=UbQR{B6vbcS_#l4m{Qbd2GAB#H> z9?(CLzIXLUhsLTj>`p}+JcoT5mn{E-0=0YHN}m964Fr%8^RlxDzMJHR<#1Z?(t{gL za-vIAG&y<`o8Lg_iCq3nf_C{T!bK-neTp)&`@-x#&rcXi?n=cSTpkLQLM%&b9>`v0 z#W$p#Dcsq(kf~82J#jtOWHbdwPPB_67eBn#QXY0P2w3OVBwrCEx9i8{QjLdrT27FF@{_L zx^STQ)e$&WCx<1;-8Dv_%R*A~*GO@~pQU`J7Yl1E-^`=w?DX1%rSmt+$XE|%S*MK+ zkS;lM?C_D6(jn%Kd_Ar3;Y~Lon$XjM?bzi(h#0l1FKmkqHYeFXhZg%Bz5U`h%unO! z_5(%$0CibY!tC#BD2pi6U3^4L>09zm-~N@YQMtlO3i}&fhr_s!ElqNe%cZ7r11JvE zU;@J^ywVC9K=mfE1q4v+PVkwAU|3H;?OHXxY0!d@gdd0Q`dA)>g&yhr=Wg z6C^j12qw32u4_|K7tFr%h0`oV`+>rZwcp>SAz=D-_v@aN4d zamwS?r<0ey1UI>h1Zt3j*$=%; z@MJ;wG~2RdldVFXW*bErBg zQNFO8Y!=6XBPvG&OtP1%98#euM9_DJZll={~p4nrTQK zxc+A3Td!gXYU_vbFK;Y8{%mU!s#lqpTcXiax!409ellP>tpE4-T+9_q|0gl8Y}0X0 z15K5+gi{0pR5lo5&vkzPbt(BLvm{GldD9e&Tvzq;T9}wHK$dmkPmZ<6&;di7WVyT+ z!~Li4q5{!j|5^Wn0jKGZvWK(Gp>=IgUg-B#MV?8$?gtv19Pirm)oAOhrz!*FB?j+u z+V(Y7&v&&7Up~cnk7%|Kpckl~;##xoQM+Pa-N9*uUMwJegrY0<44(brZWC&CAKAY1 zEoiRU_HTi`?=0Q>oVk_+J|UgQmhkV8{{6xhYTKr+xL^Wl?cI2I1{Pc^@*}y@NUWOR z6x%c=q7-m=?V!`7zzo|_-0fobSaRuR!9J{MxY*m(hD~HhZCyyJnZ=V_qYO8p)sYo- zoR4lfnI&Yes!bX{v(H&9&~q=;bJrgJ>SGqdHcvEX6lxDaYdljrc6{f}O!>C9zVN_% zN?|%Fd_Ry1@GfFaj?++By7v^1tK>~CPc}2RNyS(|f3=Y|PYA&Ac3t^ix@QoO)3x`| zG22*Mv9@wuNZV_QXEm1bMF$n%saF)~jk}K>k{|pkw{2BuAGE^`ugFTN8JICJeesXi zOe`gxyo#o!-Qwgt(IR%RYQ;E&6s#@CYPx?vNmgtBe=J>hJk{^}R)p-b#Zix9{)uI)9wk%Ng(U_T0~X-`9OT z*M)`w=WIfE=||6)5&6 z#Rh6qE$98-RvJ4ax-|A=nAXlgvHdxVdaw5WkC|SBWu=_uYyV*M^StRrT_ubYq?*`z zn@kg&L~<#aJLOzbRln5W172QAkD*wl$||Lt@aMzxO)VdAD)=_FAdY|_e_dPU-ugkt zD8Xcu`>++s%D&-k@4AdevGEh#Q&t`%c#qzERW7E z|NLjgTUUB1nlWdcJ}N>l4A>CIAJC6-$5{2vGObt?QRP^Ttg${D2HAp36Ajg9ZxY{Tizpn>=|vwx|}pb;v0wx3 z{NlxZY91UFZ|#>KYRjI!M(YZ5j&>D>3pH=AuSM^8%Nc6vHqNa{sK5Rd{Xn?#Mb!Nz zqDAu9S5@mPl-cWo8@@o7_Fr{>gKJi=l(KgQB33FOb&U3JU#|C0=sdfxZ?)vB$38WV zd)GbNDr912wkZD|t4_nSo7*T5QWN}-+gc!_uT4XsbKlr_8GsTwDK6Gr-O=F!HBYjf zaeE;F00pTwI#L#fMMON`A0eM!7RZYZSomwDCc+^h&Exbv?Z?uM2rgOw=>P@yPA#6) zQSN6a`pND2XJ=gLv%Sa9rvtLf-J?e?2!9Wy1k}mK+V)_#j>|a zgxD)Q%TYsB5bf5}k?+!ur`)ts@xT5flORO?gNX(YM?fR>N5v0_qOg;pBlD|^w`<@k7BW-sm5bh?$3#c zxAloJPbH+^(8<=;J8vvsGF^Tr{5 z*E{|6`}3Umt;g2BRORH2HYv&m@&Z&{(aSqb&%;hO{CK6?Jl0!o|B5%4o%SKcc~+Or zZbkKIQnA{Fd)27A$GQzz?eMWXm=4I;3o>cUwsLnOH<1>ZNP9Cb>20gBK+SoSTDx+Hg zU{Ofq1yU(emT{5w@KE~q@89o9pp<^$45BQ1qzNQ0R8)D~S#MW<^9BM(7oG$_>Vimr4;D1UKq(B4u#II0D%`aprkXgT81ZY zxGFpbsy6t!coe}qNRn^zpjiy^xxgT)Ie)2>&3XLn{zrPGqeBnw76AA)hu!0!mpij+ z$Xv{Q{lIrY;%I+gmZpvXneo4W8Nlnt7$2z#-oE)yFm^31-Zp+QgEuLTtehjaNJqyM z2&6(<8b&_&JK#9ACnHa6>hqM$|DTU}dozDe?)5d5eGmuCiy&z!AaafXvK@QKUQjI-w|MI7H92dlMKS{%2&`cjE9O_iYP}~FTeCw&dmQ)bawc{ z-{Puzud6Fot-n5R(w?8v-;?9+@HG?%$?!O?J|q{+PlL^G?!Mri z@0U->pWhyO*8=ok>!1JZj4FI!<-huakRT85M;p}@))O@ zJ}ay1&yk?S?=Uw>Nohv9dNx=-(qd1L%{uHquI^#nmv15N!=G22r=GVspsbkQNhmo@ zefoR%o>Ja}D*l^+!IbNBYbcVaAm!IdEz70F9s7cFEc6FY>>XtK;`jy$UN~^IS3c`< zEEzCIejRx9;2yhnZYVjEc1JElyzncd=JvYaSB8=w`7APRuQ3XxKsGgBX>nbub`JKY zZ{OaKl;m7lu``VcB-44oYs_SZ24m$RU~jiUL&Yu;bi=Wda+tNAK_{;mjShpffq;rR z+RcV-c5}{QLMnIfQ@OXx%IR#t;=k#q4C^dg-6ZP)7vfTzDJ>5dcCOt`b+mr}cl*C! z&8H;OvnsgO;lrylT0F;uNjYe|Fx*%KdI>o4DzhB!7B>x7i>wJf4`5|c) z$cpB|dh5c^21tAYNzDsqDPX|GITEgzw`Ya{B>-^BDVqBFw(!#E)ANy1tIZ0;aRrbZ zSUP&f{Rt>5aYMpLHAhSNq%GBrR`HRml3~@HPx)rb*eb?V zMu>8i9Lq6Qw+0SU&_e=Hj_0fOO_0Eh?AfgBg?*6W#(z6=Nf)MoATcqaT3#6$rP$(R zz8AiQ6n{YsnGEkilJb)$iHKlG#SO8ei~vl5`R{0#Zm)$eR*=A(JEQ!Qo~_9L0M#5k zfIcnc6$1Q*S_{v@egm2$Y55qB;{@Yq_l!h%YTLhBlv~wtf(4?-qw9vv-27*ks=-|{ zxpAY{mWhmyc5ORHi5ED%%p%fiIKHWJy6cJ`9cH`R1Pm%8E4-y2%^_Q^&9|gWMHTsr zJRa zu5I=Z#=4H%xmJ}w4m-GYzcaZ-!KbaTy~ObNGiVm<6`f%7{D-q570czV&#Nlpdt27w z$5Us#UGuJm)ngf|{&0_2)Xq^=zuOO7m4xS<0cYEQhm_i~H&>tT{==i`GcPJP>C?mW zE!ow*y5Cq5u@rY4R;^uY<9OvmX*53;Mqsm(@dvjnBktI}nJINAt*~W0lT}nBi_G2D z!}>_Iwm-AxwNWF8vVRpG6jw#_8mrHju3FnHwH8PO#dZW7Znzoy?qO5Yd0=fZd=d)pp}}jRc|jLk>RL7%`fcj zH%eGR>iEa|>re@UY16v8J<7#?j9Q*Z!TqazfkgFLOMezJ6{^uC)SY>I71C}sKASg& zY|>V^@{bfr)?YdR(-Yvf22QPkyu}XOmlEcO~_()2iy~I{MVU7gB9KpFKa2 z-2g^6bRgl#f;zYQDG#?Yq?&jUNFBy!_aP0}%d78UtK)ttbB;i+<-)?le@_pr?31gI zOi|K-MX4Q42*G31`tQ;o4}sYv#zfPxYnHD&QDS|fq>>TWEutY z-_Zng3cu~d$v==LFBl8~DR+XOM44p^X6h5Nc6zi()f*}Df+w+ILzZQYag z^W*c02==XE_wmzC0s@%#+%-S2-~0h;snD(h$*vs$+52-!$fE|j0#Zo9bXDeUZ@@<& zGo1Pd($9Va6yw0*l}4Fs3@bt9FwG~ ziLe}^En#kf*Sdxd9n*g8lep4;17rnl=?*W4EK@3Hg{%Co^0p$nrd(6W^$9aUZvWg< z(G+F5l=mvqW2STULdm-R{OJRlUt{>O8do)6yt8~^WI^D)zb=V(o%(LX8>Z);_1GS! z@Mi${jlBCWX5^M!x2SEVVYExVR-|o7a*P-%(KC80%i~2|{g;>=kop9{3B&DZ$@#87 zrX*wflK)`#rvJ%{@fLw#GNQWW^$&{|~{trl!Xr2?3*^H@?;0m*QFs z8j<&SbELgLYyK|vHJh6Ui#8|PH z6X|$V_ts`sh-KS1t4XVmxU$w{zmZ&MO6Yt`D2J<`mAz&LkVO7Pkk=`jfISWJ&Z<8f zK4(Evv0WyB}KFpyFj?E(%*_Rmq+X$TmTh_4zL1 z)Yus)BhMJ0<|Bp-JEt>ifGY^lRs1bhw@K-Dww>Anln+H?wFI@E9f11~0hv)=xatC* zT01@*I8JDJi?Q60#JfzkX8k#%soRUlxZ%@qzZ}QR@6>aq?0kmgW3JtDh8f)im!;pg z=c&Ih)bbFDc2WMPyQ&Zr_1|)K>Kfx%vawB}u{)gdQvETkjq?*H$60?B=jRBTU-2i- zia-Aa*L+jwx{f(<@)SzsZ1$poz%Pb8#RE zgyH(CP}gt~Q&PPD*dK>&JjK{oY~{enMyY&bjaE_ zt_0BGQ9JfrsTkAhC5^-PZ9Of$X95v8!@;&Ui5wpdy_1YJ+v9__f)TqzsE~SI)MXS6 z^O$OfAG&k#qf6rQw$F!`kM5=XK``rxjVR6P=Y5?l_=S*Y>*ycR%l-5MNY#9u4Adom z5`0OR)dY=xAJR0$#W+;Fk+{XOiv|FkSQWbZ2cI_P76SugNmj2<3kg!3bvik4hO|n7sqckN2IM#(?!9#z3KFc_ zfGT=EP9IpNrGFROq6KRN2CZ2vs3jp^#cFaXEEm`WAoo5<`29k8aBHs3;YXGS_X)IS zCr_&pNJvQ&>WRQBq4P{_;yGMF&{t~BBvD@>5=Fyt%>yv-m_1IWb;c)Xc9JC{`Z zw%a>_15cp(h7O$DSB!^BuAvEmWE>!=Hwy)9l0#V&Qh09&CMf}>I*?2R044M#P@jnb zZmg$jRfTV%lmfyQlEp6VkpVuS#mHX5&kX{0l#w>_Mf8#>}|EigrI_y4}PpeS!EG?OIZN-R{faT&7r@;mG}N9>)I zN`9WJqD3OUSZESY_znwn_Q^o9oVe2b#&6x(=XA}(=#zK$w|Rl<~!oAp<0~x*9=8OM5ci8?G`45 zkzZDzqY(&RHa9mzAHTD=$H5Z8as9(hCJGKFipJ$#4mz@1Fw`3p6N1&%Rcl8_dtg=0bOLH3U#u=BCw^af9p_r|qbW}U zW|drP`?pD5w zz{uX2st!@gODuKYqRB>CIk|g59HWo!YO6}kqA@o$LU-CyU1OX*F)b}fC2GzxIob9v zFD~uNtK{G2Er;~vBrHB`SZfLkUiin>-_u;`@-=i;g_4&wmv^S$nU57EQAFW$os$_m z^oB;75Y%+fx%*4f&AkTP_L}ouat3Thz;T4r5|h#q#w88>oNjt^^uU(a3sY_}vD`EC zxaRNqbg#;`e2gYWd%D%G5x0GdhBPfRljzV7M$InsCt1?77)WmeQQvK}&o1Ay=g*_s z%$?59{LSw(LlWLVi1;V1g3GJ8J66B#EzG0$bX#VfL0|x}Qw7+8((_7Sq&T(drSam$ zi%)`~rZ*G2IHI0FhM=2Xtl)undwn!MI{FAKptUu@pCdnZp8J&j`H9o!3#qM@mWZyd zt_}yZZNOqP0bU}ZOmZC`X$NgK!1`h6#NuMTnP&I~XbhENn_jN(fj5eo6qJv8aMxxV8rV z)^TlPVZjoL(Lg!5t!$r`a(loIH}mvljI%Jcv?SKj(qdv`YdSbU{|NSzVXrZx7FV3` z;D265=iuf>$L_y#ZEd_7nJDHs)154d_T3j;o2*Awd#=v_2fbGQF^R?GsW=&I3X)pY zzv1*#@-yXgTh<|ji`KRj{x@Y(igjD$j0#)>Kcu{?0EhF~1+@n+2W~%%sXBVvfSKaq zdVngmEh2Q3s|rzdNJ>#)tP^CXL7%y4cvtn&psSZq$eu1%ZCup~i|UpY!M9)*_IA1tU0L)8Jb1Z^5^s!bI=NZGWIxO6 zu79dclr-p1=(HW%C{7dp*w#Hi8@l1YbI(Y}BfzsLP)H$D{&dZ+_ z0^}=UH25C6ilg3kxTy4^<^U4u8RPYR3eM$Kci4Q0n2O_bOCc?{Ck(w4d23k8=`1;V zn4J;(;lFKkNW*ltUr@pK8fr?~7i#Wn%^z~ETHW=XUgU7kqQpq}j|;0LWRt)K2nT4` z?iG>1j0w)<{xSPA<`jWtZjMi>siEW-V&V0G#U(`}A8fDfHsYvnG6~4@&?!CEp}%}~ z({ILBq<&IpZwh8$BA%k5UpN(_6c39j3RAjEv7;8D|23q4)BE+JSDtsu*$_H60F78f zElQ|Y1Q~Qrnl|kAXsQHIiFt}0KN7deZoelhdx0;4-4RI8i_C0_11+Gb?L&y=u3zEM zAWR`5CWaBF7^!TZciA{T6&B$Zz-@c0sH?xRwkCz}P-f-^p#AF%f(cvgNlgut`W;L{ z4INaldl-Ip79gQTk<~3sK?riN+|#F5xwyE%r*BmD_*3Z?8bEa~KtsX zuod(*Ac+Yxv9qI?c47h$#uKh+w8tt7yZ<-nehn)YP1bT{GZB zu<&DUcC$)JHU^Kq6+Q1Xrd#nH)4($bn%MC1^P7%6%+Ah+wt$Zx$!=f2NLB+9CW%k;$2RF%xnPG^W+W`DALby3h5cXQ(tTFd@Q{Y+A< zo{r0tJJ$^v7#X=e+=mWqnHLVAR#2GQ#QprtHbf1FAL-iIS;YRgWL))Dan7`QUQEr3 z>08A2^boE{IO|+t&?0tGXFu3l;$vI7D}S@`@L%m^cHwiWB4^JbHBl|Nvu{wMu@3Q? zx>e4o>*0_m8{%lVNi#%Hg&yt+6Rh1QaGJsY>GXIVPHgUVY-r))5|FKI5NrwW z7^c)R_+aTn0L+^a1cC2d?|h}C{bE3cS7G?>M(iPjtPvOBN$t4hIBz-<>gvxz!33j) zrBXM%8@QFDRym0ih|T(fQRJ9*TZ=vQik$eE!Swi^;dM6=xTMbTT=xU%A{zWD&x3PR zR(?kYdBAwKt3)S~IlP=+$j*LqYwu8tWRL-Avbwf5 zGh&q*(u2e7E)U<6kob_7$Jg22Jp)vqp$u?vAP#NOfq|E#{0?ZqV8GhJLFxAODDOln zm*9V#2KxHp+1Xs6uu1#={c1!+L~dT*qvy}b`S|#H`}zXc)*L{nXMlM`yQ2Ez$5*6a z)S;oF!eD;@R0{$a4;wdE83u zG6QC%!NI`^U%x^l2MAMoB@h}Fb*<8FjN!NkycFu-OoYSyWQ3Nf=xysXx5M2q0lyBO zN;{-=7n(9b#VfcIa7oE|mt6qlw2IWb%#%T%v$SLj#w{Ruf2*x!14X}_Fw^_*o%fl) zoj3xZX`w4Ix3I98v2Nqa&dyF+O${r!gSq+n7;v4!GBS~h9D$It9~g2F5)wi+B=GT^ ze|d#RHy!*;aOY;hSY{EAk8;`D-_NM8X9p#!lCd%S(eZKL*cf!??h zI$u7~%4;RA(dr3AIqXU_{Q&q#b(#Eo!pe?Vj z(rhWAIMb8s5o1~uB$ez$DSLTes_{-52ezy`WyfpT;&#i;5jW6?bD8#zjuJ5TfCgVc zM9!kTYf6Ign28CaOP))=*H%_;f`U%E4#0%s0Vm>B z)z!{RgS;b})n1z%(7S-TJ16K#7{YHcgwy-^xmL3FNp;oY`&OGLN5S$YI^&ShJx66zMbB0S%32LaJ43PLgTNd z>l3d8xZ=CkK`9F&0TjON>D?@AtEd_Wd`1g;r!7T#Ub@m|a@Wpd%&n3za0cu`+k zN|@S%EJIftu+{K~V&>vy83t=&ujO>zFnfw{cNfje&%YMlzk02^k-v37@%Bab0@c_ zUgC21un`)at52N=g)5ocJ%e#7V=s#g#*0$5;lFmL{Nj@xE9}C;s=|lAILyE6XkINl zqww^NP2`LyezP-ZMsuC997e`*f|eC)E!LLQr@AVRGW?G)nDpzidVDtP{fH3-hg6Y4 zzcvd=9_gnKe{mKll%R9Zce>|=|COd%HC11RQQKtiWpkx8{oc)(HRUH1LQWS9+>viF zetfSUc?H*laU-v0LbJzmdk)B}B6v=)C$66Nm%TPTFaNrNP83uz^cy^BUp?@>2TI+z z@SyH0LeaIIqEJVs;~fKs-D&6HCi#$ChpzG5!LKu_uv!km-3CKf`D_vrwNo6-|BUA7 z#-;~#)?B(0x47e|-@Ro?)vbF)cdPEp^(F=d`s3$MlMhJY%`W-&$_AI58?|Crs&qJX`y{rpS5bk%`L=ELMN03iju=`4zBmAiETh zUG;<|qZoa^*8@90PEbt(ua%IK#RRkZa^0l0^DH6%ryipM&Wwlq_2>RxP2;l-CN#iS z;-Fo(F6hba%L>{%P$`T8DCC7-O5I`h+|wL{g4>B*$z@9)9~OvG0-*^|W))UMt|4Vl zZdqDdYG`Q%o8RXp`A1QrQvc%z#pKyu#e85SXfmO}sl#~Hm5!e@5lWlGh|^&nUfu?1 zMhM`CX$Xu3Z{HY5J);9x0&_N*0W$g^EI_ZTsi*e=vWcLlS4%c8zUG%QQJ!6Q_6Ea{ zQ#fE$L@j$l1Kq2OsV0~ky|d8U@REXT1i@r993^dsb~1qPaX4f(=mga<$t^hO@OA#x3axMzl#wFuxaOg?d0@oe7V1X zNnBctP%cH(ji;WKA&+9Gur$MS_qb?5a%kdbQevBZU&7BA{s(O+eJnNqu_->Smw|ks zE5c;#n9canJ?4*<*TgB~1D7Z6$)2ftv1p8;yzu>@9e83fY1i$6N3qJPlELn|_7C+t zPQM4BlW$17uedu9iPnMrK!qDiEmnOvG~#Ob$*{z2VcHpe$XQ|L_wn$q()t1lsd^5t zlS<9?;A0!P(Jn`oQyLTK`*U{7^+6yqBWOvJP4;tJZ|7pQJY(Eg=1ppSN`;7gvLQ9x z8nGZ_NC8#ScZ)6)oAhwm&3hDR>K`(q{L&AdJ?XI#iQ_98e_$+PVj-V` zsDoQ(^3ADIoLiHPa1ucYQHiH4cPnKmEu5$}_A zSqBwMmCA)eg457`M`JlgVU`e^Ff+Pgo2WGeXTN{xqmY_`o z)syqljt%&~11x14*fv47b^rxiSjfieN`lJM?je6X@z~5qDfjR0H)-D4d`(D9e9J7<6cUq=lvG!f>r;gFkukjHP|ts@&1^TOWMOgV>({RUh6x%SeeK=v z_vQ^JRK5QDM~Xr#j4`C&B%1pA{L6$q8RIm_Z#p_UZF(b4;{Sd9%A&||4G^+8 zlhryR&ol?NCdolDdgsm^=rW)kID%}TPL}J>_yd4*%hcZj zec{+`KG4HDZ7-;T^;=d^F$>-UZ5Z?IVV^%=2gUVoI^=JSsuz`M@Hi?;0T7~X(cFE* z!$`lD9$;!#-rV zzeX(PBgX8`=91_>c-L7>A|^OKY}nKsP71H2B2P9JHT)Rp_D!Zq&e=Wu!F~@O&CDW; zJ;|KfoVH9BfNz`}JeA9F6lG0xuhH=wGRomkmOR(RyGdzevPOLwb7oakSR?7fr)^`p zQt85k&V`8CA_NN!_HCJxNOIf>_etW;l6vGf;XJ7{XoM(L64LhN;nIput+P+EU?nTE zL<^&_o4$<;HQUsfe|c=SRPlb4Oc66{Sv{AY6JkHManf`HcOFP_d~R0xnagy@*`T(X zFj#CaAxI}HiVdgiRN8Whk0q_zzB_`!eJbEpfS$f`VTT(294KAfUKpJmGkQ8ub^6- zs;Vl~^?_ZQU$QR`mMhR~c8`LBA}uF}&4ByUJ2{C0Hfd7_o3l_F zJUTjZ0v=%yccjvQDqEbWdwm}?eiXoAP=PC2o+Iiv_*Mv}*=%8l@D)%hf?lx^G(3$h()z%5Y|#5uT! zpnQPR3y9VIwQ&Wo1%UJYcjn2Fn8l@~N1(%ct*<{{w*#8HlabWZFE!Z(MMXtZ65d;x zHO=6ux)JliZH*2-8%9`o@T+{v+j{`O;X6Ra76k^KT2CoFg<>xV{tXFPN1}Mn&bo`~`s%$eRJ^ z24k-#u_MIEI(}6rFEx9=fMuLyQ=xm?mgmHh)h-NI3-l{Kby=EzoNtu{uEEa7d-gH$ z@fmq}WYW^o8$Pv=Ng&{xk#_w21R%3c(KaQ@UC`zOhyz4lYc=LWJ=jh@EdK+cr>UtK z_~dpFkPS-6=3S2{w+GO}7}|ZA0YH6VU0x#K;pbNXQ3}yjzJXR8tUqg8+eiBPH)_mM zONN&p`}<3Syx}z4ObIHGR(Zl-Mr|MSdk9Pbq*;G{x(+bA=}oUWnLS00OQ;sY=LTKB z0EBNW%d@KuC;%%bR`;2)HQu!Ab;pGUY!Eg8swBW$d*k83u)Vzv4e&v%f@}$f0e&+E zWX}cg=Vd`#pKiSr1F@YXR!`7?p%H(C~Ah zRkXPTN(54t=D-b-m;yG>n~?V+l&8QH6Z+8x^#N?dMaC^i_JAU&0AfUgv_N(k}DI^Xabj4t~OymrYBqnMI!6S z?sWdy(M2A~z_Lp5}CLNs{Z%iVkz8A+wBtqpCVU{QfG3P9BX_4R3&-h^kh zgBcVZS)0YQHY}IIya{GMIwNn<3KmC(=Ew*iFRwi4r~wT)64X2(l@JBXgAju90e|y#+ z8v?r2Y5gYjrt=;3^Y?&YW^8m1w|_iO!I|={?(|LYPMiuCAUNLI|ATyV>pjL402H(BKPIXV_iIZ4Hm z#EL$91NM=Qc^dG0QqIB7vH`ibSJa>04FH^uS^Y4g6(4^8_`e^OSh?4RiBegQe>jU6YeIrBG=jJC9K=t zdoz625zYZ?NFwIT=7mKIYBSq+nX`~oaO!<*>(CSkAPF(1;FA?K+Uc&J)w5Vmx6u1} zS9#KUEZ`*VxhO^Q3G!0gn@24hCI8{13{0^#>g(?~(t`8osFK;qo>OWmBRM`SC>4bx zsmi(T5zq-(2vb-o>2rzOy%PS^qhI`|!7bE_$6!cE8jK%8TZmK?+&Yu1d!&unRZpx$`gLJ@OFraCmoOaO3u&pVxE<;|4^jJ*cifmOSa2JfXbcVZzyO9}?U0iq|N0dxj1x0`WIKD9;w7oIvhJ%&1Jz+?iY&nHIix5uXeP)r+i z_fU{@gqi+8+(LfcLV_8h!>TH{L8Z$TXb1*K2N2FCwcYsus?L6n*fqR^;Xm}_2S~@t zAzlXnz`@5KH1Tc|^5T3Z74amcH3aW}6wpLoPVg5Qq3d>LCTh zaI66=7tqBchnHjX<+No;V)Mb&_AK%ddAv`T6SK8v>8yA6-EPGHMR65^rF7_g&y(PH<7--DLiF^6X&2%Wyi^ zfrcpgiuVIwvO@WUGTw1blbg(6l0^9llJmzSO-G7128N&kJ7wfw=WHR#3wHhAu|UDH4m4cBLW~6oIz`SQgpsszmdG|cj5ZW#m#N{ zK~qCZz(N-AkMi>JKG8q#@|U@=+XhR=6u5Ll(^>$l-i(XTvXY5zOZ)ciGJu|o?e!<3 zR1YQNJ4^!tU;vW}HJv)dr9!6*K0Ud;0c^rE0Oz7d=2!sCGyz|t7mNK5Ecf34LIA<# z@>HtL_9K7)^$$d#Q-W5!L9LID(VhKv{-UYp#z)s>&JTNL^NmmWE}j5~)RyfYS!z^E zFi3_^ZgVVOJz}cNnye=otm-Yd|6UI^gBJKaV0eHY=7Zv8nN;;9X+XzW-^Z-)b8^R} zU*qWp2=4_#1#SUTXkd^3n(N}?B2Pj|9YDh&mOfE%LlAX^er4$&cU#Cy-pFtB@db4e zGFYAo7n%W0UsUj&U%!48+us040phbyZvobYCgP~)bjjLH!~M^0(>U6QvhQG?g)`+dpteo6E8?i z?Cx|$L|rr)CF%;=voYI4)oz0oyc>T zT2V`rs=BsGL&{ggqS@k+IVdu=Nlegk%Uz0}y7dKgg`*4Ub#&5sUqoK%$>|?^ovMqJ96RQ ze0GEzUMdNVDX2OC=XPX|$%jZ~M?dug(@t8_gIe$qro2Hcz*K&v$mX}z*LU-sxzblt z22;sC(n=40Zw~vP#p`MgfcNVCjxEZ z(9qE5_*;Cfpyl?GiUbzD0OaZLmy7$U4o_C&1jH<`puu7VF%;HhcICl1>tI{QybGwP z0NeugkYJFox3>p%t(BD$|IF~Ey4X;tV|D~30+fxw9OTlyF^4eP0T3jGplb*HDwF^q z_ib=M#R*Je+&nxkbDROf0@(St(yAa#6%1YaL8A&vCxCPT0tEPD>gkHr{3)JDf2H0Z z`U!yi&MM}BmAn5%(cZp5<-zk2B`peM`r&PPZ%;4+f!bY=R1pZ-n>%u83WtMbX-Jt| zh;UR74ys%jg+uSQx4&C=wp)||>N~xX`AajwFj68DJ?OAj7Gn2J0sosO5Q?A>Dygl# zJ8&F*xlR6@&NG?nqx?|%n)i!Og$GliM>L4%BMJct%q}U!ByAm-`LnBVPaj+WdW5pS z-iwO7yZ+V5)Hg=@ChTvlchX_~4JsE|3M$$bbq-dlZY|OLD%`oldXn)ysqY9KcK8wK z1>wod=5T>0#!@}7mnmj95y)xNxA0F@8#)q||4C7ZNyd^;M`OJbSeqKP&krSEv7*^F z=hkdr*^rr3M!A<9C%yzIJrTEqwRR%?3TO&se9ef7lxv5bV zWEf#sDzE3U3LWh`lEz7PYRo0|AKXvrs49xFziCN`E&iPL#Xj0^nU9udAxJh*hnF>- zxjpY%48^ZJ5TmqWkW2W0$|FR*GO}gjQW+pDY5@=O)M0Xz4F?$TN2?K*KbUox-zKn2 z_)OCF4PmD*ZByTyG6364Pa6tlba%VCw;mT8+d$JX;x29r4|u)tbZ#2+4mTFunCX%$ z!`E8hj@-U2i(Qv*4VSFBaY6J5hlt>aJu5_L{n>JTGve6soYMH9krAN!)4M+N_tzoD z6@b04JL7d}9Jr^+OgT@U-HCrFCjn~OSG7+NEwMIgJhle4|3`BVvsO$k| z_5!7t6F00*2p~1CXb9diD`A4e;d3^pwiB#OOutcxlQ)3z!V%xm2B-a@DZeffodO7Q zV3Gh~sa;}|ieMgbdFHG5WxOK@m-?fjBH+z^B43D2lTzx9U z|NZx0T6Q)u*p+_M#_&f5w{zp=yVzNil$4(@m^n11Ew$-bm2Xo%P&_p)#_aJFRUHk# z>+tx5NKQ-~m)F27?2sG_{&{mo{{Y1qc+5+jY_wR*#11R}DyyB+b=}eX%(!_>*QK0 zlD5q78~C^}ts@1@u(-)P-7Ic6TS0gCT@^4ZK&1_o=pQ&;MGoXy_Q$GOsgvud%@k!5 z29D~&oCBy2MHO$n8+DU{nQYGc4VBFVxO=2H`mJGQ>Hdj(2GGl?5>YwiDAmc~1>QIz zSg!;a-j8wB$I4*6^6qaUr*6l%7Jm(HyvFgA+w(oYhuR#^XDVvUyGyj8_$w;x6a$2) z781m7658$nHQN*1&+ezoFO5V*2zrNX!T3lfmHkYPCS8LRkSKG1{0LHJePPa18d`}B zAQ_%kxN3BAk0|2Vvf{DxXrr?Fo3XgTs0`g4PWe(jsIlYCimW0NWg99xuIHgjsc=6u zxH2*zD}=))pD$apiCZYn-=HAw8y}AVTP{TO1+=HF3A@LM%mth`XC8wV+1vUx*jC@Z%{PcF zGy}&70MiO8{6Q51_$cN$X;4(z+LG1Q)%fnC_%HfXm0gy-@iYN{QGEeY<<5&?KU8Ygi(LIRbB;M z>DGJ)z&B?zH^QQ)!bMrnw9~30_K>*Q- zodFe~dFFsb1mmT6&oZ_8562wqBLG4TP3S;F5=}@{LxIl1fC&s3woRG>@Bno7 z0A2F}6%DwLcQKr=sgjea;BXj1EZ2drwje=AjLNX4VToSH_=GJ;EbGQU-eqh(S+W#s zleHHXMW!Rhij7Q6C2I1@J(_mI{xFlhiw-A@EfS=hYBV0mbZZ*O#%QL^u3P5(y}I+R zAYs}8`{VC89PY!txbjgR7hmtXA&fmsC59VSon&NanHKxBL?9Cpjiy_h+<_Ubmnfy1 z9!JokWO4Eu<7i`8B-s#k4!0zXD`$2+DJ6|O*o2W;cxIi|L8`Jg@hn#*-|}OHD(+3m zeSk{2Pfar{&ZM5#pHsWKTV}9c!m1nMRK{0GLz9sMS$A2}HRv<8*VilwAJ5HNCVxXG z-{NYtCoEoVq>!tjAPu@K&gR_lZt=aZ=~uJK3Vbjg^?QeSnmY7aq>{-E8FN<&2(9LR2GP{r%E2rx#s|{ zWj9h&kfKWcA$}(rp6cyP@Rfyl^Pws&-~s{&1|T6c{QN2wcp63AKf2Q7s3(dr6Qy9O zxL1v8z8^TF!jhab5c&d_a5j&S4PUZ9fB~6YlruP77-x|Vit=)M3_n%ctn+2z{^?&Uh8tk{Qwz5iSvR6MXlI+uvd9L=r!=&@7p^Yn%^?OO0qIvx>!FWXrJ zjm){c1Ie^7T;tGXyfWnbwks3UW4Jx?QlE>2j+mGykIf6f>Y;V3Ih4%N97iAWlsQ{ayDVF9~4uHQJTpUI`?9&OSjbBrwmf*K*b)c;Qnb zcr&JM``6ub^Rp-J9`DVzo_$Hp)GB<_Nxx2Hg83rx%XIYkOW>@q-=Qo4(@N)ywbV{2 zm7&f_N{kb{j75(Ega5RBIvtvzTC588W)d**8hHAuNQl%6BV2pw;PS2a8P{G3NA>83 zt=}o84Zl@afz;**`EM}>XV3X z9l$nD#}M%A&BCS^EK$b~iul5Hv;p{Bp@C+$lm$2qJn&+-Vz#BxbEvxs+% zR@U4G?=3Y*C6;r`GbLcIg(l+W(x?0fb2UHYr=Oo!?aXa$dQ!n; z3nhb|wT!ztWjGJy-!Nz4Td^Ap_@Na*w^7W-#`x-~FPqyQw%ad>sJEAm7|i!wHDC)< zHjUeLM@1gyVeC1$b@*BN&!y1dfqW16p~S~Njel?r1M$e~brkz=QrzwL^80XI6dXo@%UAslwe_F0(N^VcJb}Ms`(s&OOWV^>E{c1v%l%$W|)ssARS>e}! zG+&pz_%pZ@D_4Xz49@J%vrxE&^V4AwRWsnOIU5LRIeo$O_Y!x&cJcU%j-gY4IZi1* zTArJn>WgP(we8FK1!r*qZRR0y8gW@szr+H+OoSFI5pL6Ks;}%$D#Pvkx%OCX3fo3B zhM{Nn%WEB`|6}Poz_EPWw^AspWt7=MwkW(-QfbHtp=3qUuViMF9g0*UWQ4?96rzkG zgzObUC^Iv=qKyA}_x+E<@g3jcm-l&}=eh6ezOM5+&-0?!qIqnnMcZ05ajx$EnvJwJ zDGHVOv3J8vlxZJ?I_{o=Hxuq2}$4m$RjbX+wFB{9JiYU(36Bj_DF7y>l(=1GQG# zw%hCDV(QbQmcn{6Z?GJX^h@|NG*b7C`PUVll~(nklrPUSgKK?L`MclduUvl;Wo&D& z@Pbd4JK$poBpPEM?={=YPH0}>%@Kbud_}5A(Rd=Awn5_Kc1pcnRZS|F@rR+;!cKE* zax)nyF0yA<`&T%pS7*GncR!oG>A|0~^!_2VeQADGYu^mMuub&-3xAB_oonrRIK#_^ zSP$P<v`Nt6)fr&3JFFbYQqtn_Z(6&}K&F+MCoaRc@+iU$bbrkM^fCLeR z0n?=+Md#OZ%ZAoxl4MyP)?cuy+$22^Q1iw*R)zkdDK8zp)^#49rf1Hpe^=CLF~%{y zr5qE?Sl{_|GKwQfH?iO5ov6A$qp7WdC^KGjdY|NL+r67v`pj=#3%VpQ-1qdvPT#w~ zB>7d|PU*e#>-#YMbXhK_yg$=CuPJ{-SN-}1R#_9FJ`VcIgoEnpGII^xRpSEjy;+Ci zRBrUd)*QK3u>3S|(ZOpV{g2-%aXqgtiG`uY4*M-hz4lbOej2Yeyw;=382LFhu6~I>Z{$$q*RI;c6u~Ct+ZypQuDt#B{ci_na0Z*o zf+oO6ms-oMXkyHpH{WkV{nMN*MeiallU6OqH<5BsmVXRsPp)q19(LJoM?Jfx5vSA- z+NTB6Y);gCX%$J=A0B7%6%ID9wCO38*4>=5A;-b#nEmkL*mzK{(ChL|-h%t5orf7m z^oCtGUZ%z~pS9dCxc#ujr;`0n`O%Gnud>S@uWoj_+x=VUy2Z%xU2nDzfeF0cKd9Pq z%M_EtmirnqeHw8NK}nYyelEqYeOqvob|#^If`7_x-qCQ&iK`Pw*uu}~uzh0xmQlB~ z_{uErvreF&CP(@G+_Y%|K53h!pr-?)Ey7psd3lipiDONCoVLnv|La!hrXXeZLM0|ks(TGKgsWNe;( z=x819)3MIT*3~j{(cSOU$+6iuFsbp~;XnLGy#lh;TSD&Dv)H9yN>XSUv6H>@x&}F# zagu<7-0|=hkAVP(Jnx2~u%5p&z~cFs{Zh2HaS6-*-XzTxL7$2a zF_q{1aihwc0#_|GEeu}Z=~_&6m@JxI(GqkuX8E9Mv!+!3j?~q`!7Hm9=dr_3yO^sZl*vs@6LW=-9i*2j@vt+hW!;$7YbnUv{v*i}AgRk-&o@M11Ztfb`o>x9}%wDgEaxP7KVLC6hv4c;a z{jP<(zI~KrcVF)XYA`GPZ?$V{_KC^IX=-Y!Y0uvJav|^totowjWs}px=YCRyrAtm8 z0dtnVBt*hRe5WBi= z*CsTsU(1x$X#bkgUcB{qCM1>XXQG`G?SXKwRwZaD>*L=&&%DeiFCwVG-uTpBdD5x9 zb(Q@3$l;ORX`fR$l^WW0TR7IoeCW;E;1^LFYqHg6n3zt#YROzWI%W%x?F1 zJ~$xRIHLDv$n7w*+PU2&-FduK=a#50JXfT+AMyrHsX5qD;?jdl#5~M8GY{qS|FwAQ z8(f<+^K6s-!orHVD!P;(qbp3UPcKm<=q}5BbJJI@ZlL#R-sH89`$_1u^DzN^S&E%( zXHJ~R>EL;t>8NxW_x=kP2R=1%vz0G$R1RBEqn&Mq^AvL15~DQpJHuw0R4b^QtUO9V z?VFt@jJ3iiMw34AuBpGLEvo!B^D$k)M3zwn+u=|5E{>cV96X|BTfkgenjm+oyTnQ_ zu{lXoEA@qN99Le2MaAa|b?l?&?H-$5+5D9?>Gi;L@64AR$AGOg zuJ3<6YF;^T^U@Q!RSFt%*%ju+k{b3!$vRW_<7~cI+|Fs_IjP~eM_(M&Faoa*|9gS+sfQIJ=tC?qcNm*Rl$5)W?TZvFb3~Z-mJ1Io|20* zqmg8m6U=?Iiby^)=mC@k^lo{2Sv=TjufLN3qsY~gtg=0?fksRHmgAW*6T11pY+ZGY zu(r-=p9B224)D{&H?WpUZe$7E82sZy)@Ak;kEz%C(i{yJQyg z^*7C3%{mX5Jk6-+_pj(byg$azJgRbVCR3Cv-f-K51A9 z$JCYN_!x^z)Wly=o7w5oJSXS!HHH_rS7o_AWh8W=T!v@s?V_E(l~p?FY`?wtyM6l@ zm}6VQo%_y-vThCV0U>8(#0KpXFoCP3H|lMpK_li~-Qy;e@EcS$pwv)~@d+;{-u$3j zSmF&T7}2@awF^`WSNBZ2o81o2G2eMrK~Pcd&Am*Q=XQIP1vFgCyw`dtjwne?tf9uN zV@OrjyZ*DZTI5ESDTuw$?PIS(%(nN@9d2#~hD(NP(p4CEPHk%n;>l}d_XD}j#5gia zL%=-qinz8@iSvh_hqarHTjkrUd3Y{&1|S4enAfac%Xd|Fg^ug!6U8IFpWcaK`hiPu z;3XGB?(&S(NMmt91Cx^7%*-??=5o$4lFhW80XB^;*^MUF^vODFLcLmKn*B3qC#%0y zt8L3btuW{Ld)*OLi-% zMq=5v%U!(nj6qBkS=;Y!-Yc7frnO6)8T=o=W9xU^fSB9idu>s@5I?l(Zhn8FI^5Zlm zj9HfJf3WPGrT89LIJuVEJE50Yoe|GGkR|`Lc`N6WE0=?>y~+O5apTTc?Ul8Prq`Eh zRkpBuFVWp(`n%!w#1XZPQir4d_GU6%+EG_0%v35gUd)v<969MS&UzzZkz=mzjzp{`9;VRk8tSnzHSzCVg;KWz2 zHqzX$vb6jY>IS?P{Y3z~ehovhc~?bvj($=)Gz6o`twgsG5Ev*WDd`Ob?*cgR)q!2$ z5Kj*`?Ywz#J5aEro+-n{qjP4#y0a=U34!lGVs0)+uU%fkr+wi^LejsYdkCo*AxKI> za2XIl2j2>|d08e}aBHs?E$vS?ExH>K!47D9pge!gT*k~GC&bjSsyT~JC>$^Pr6^zM z9@GqSdqnTQTuR4O?CU?+YPC4iR&+>_S6xes^^={2n@hGwf; z#f48&;^+>YxsYuH!N!0?eeFB3oRnw1I_}T!1)<$a8k!I(9|E%ycO)exkwma|1hbubzkD!NZ3SANK>LngRMu|mde@5setqiBDtGweR9GDP zze!Gf6C3w)9kXm)*z<9Wzi3Tc*0i8^jInWG>Ycz4=|}lIiBVjJ?jzc@A%J(P*U`7G zD!=H|GiUkTre@@sNzbIP-lM$BvGMCQsli-@W6Sbls&CrzOUqT-io``_RRUK<`p*i} zU5bygc;bKB`}w^;%sLA*E~S!4U0$zUKWdnJQ(2hFPk73&H-#ZIE(R>zQ@-w7v3+R&cA>5vO?` zg7^OZe!{0(TvYr%^p6&*70OjOwFLAl+>!CZ}%X+0HOAg;Io5IT$`9UOLDI43OiO? zTiXLi9Z*Gz(Gyx<{b2X6hzonSg0oJ(FS<-Z;Ma4`CoZ1kG1$iRBu_#@;ym_0?gEWb zhtHdj{{byISY*&#m+3e@4uZy=+${ZNw>M)SBS=76rHYO}es+W&*b#dFJ5f-Bv7iaQ zUOi1sBLnYUrLws>8Axa(_wC!=Eb~aC4^A9;ZnFX;j>yT~fPLvWePE9Xl`!CMRE6Qo#0{selmVR^b&kVk!C1Y&^;K(s<+ z(Q4K?ctA@|R%@AWdaI(R-AjJPGov!m!+hN}0gQH*LX2v+OGDcsEqDUQYEq^4?eow5=E;SI18IAED)F)suS_C$ z!}kVHO+tuXIH)SRVTj}l_f(cS(J(>S&$_W?mYxQjk@ z^h~UbXPv%+w2P0k%73aU;YF0Q?9%ZK&9GVqfh1WS4gM-()J@|CgzlF9r z@Jc5q7guRx;|6ffH!?+3QoLtm7m^FsTRjX)H9fBI$6&V9)B5Ye-I(v5r4gg+x80Fr zVk$kb<5Af_%dX3v5#Lq|xpzi-c;!4;R(Vx)059D;oE&{9gGJ!Mx8mc<79-INk;+pMt2n=I=n4KH zximGu$PjU;hc&!-KhwZVMT?lT?d^BBht=}O9ordJLlZ!sV*{#KDF?%jEn;dXPx5>@ z)2XAcFQz7%M&6rfdCB&bV2o} zKKM?~FL|PqT5Xi}>C-7N#vrRs%E=+p6E}N)o(C<% zMzox7+#->zXsNS^G;HJlb`24E4noH<^#vr{|IDHh``%B7Tup#&2~Pc-;ak?(Mo{~Z z3(Vw}Yj`5F!DoQ}iavJ}cy|SyZ+87Z@%vpaSMM$9POXs$Y*A=$h`TuqRLY3yCrnyk z%GVE$Hcvv8pbX{AxpN^BH+mTv0!u3g9mtl~S{bBj%2y8FKc2kd_8}09AW(pS2q!G8 zigwkoF?&M01MO_D#B!g6wvNuKTXgTwM6?#D+e@C@jBrfs1d+y1zsc}m#u={yk{=QF z5LYnuE z03Q!l*6)2T$_URgw}rB#*nq#yt+G(1MJ(M2fp5M_%X2kQ6AsfG8zD-4pap`7%lla# zFz%}q^ry8KolUV-_=FmSWhrT)ipHq6A~MIAEv*@Ts>|g zd^z$h*N)mZZ}u(DL>7_24iDCwgdh`PRkimRzjA6VIr-4XXU2g<$T{;ru6hMyF%o_- z_`M%PDM4jkFl=f|KPMv8j7a=sP60M`2~Ick?~j9wJvKHrzclH^%J|RL48hZP?sPMa zxLcDX6A4?ydnQ=o{jJf_Z6ksk7%F81JU(f>hsxKz5ajv2Uu7zfrSS}(y%_wbdXg13 ziN|h^-R!*~9k(hR+8YQOkE&eq{ABKUopG{gMfuE`GmW4T(CfG}ZtDtc9Q|YM{HOld~q+s1L zi(K7)_4XqNxta;ooCV6(DT>^86P^^T_9^yEil*$n%WyB=q^3XpgZLB1pzKuBzO)%V z#^WbXGHW!Gj6fjF=iIJwEiUDC^7=|fcKdfwuD^?;D@5gjqC{rfVfeBTeJe!b;Exkg z2U`0m4W2!Jt_mgGrT_lZF)$ER_#o4NggNhq@;zi0@@G4mntHj{49}V)Jwmbt#<{AI z(bfWsj!8HYJ*e6R$`wQ>yJzoT=_Jx6upYT?EKXOD;|<0}yvjFjAs5TotD&mu7;!TLwG%#O4o1_Uhysu^UlWoMkQQ+4lW<95cy{(?P&G-q2udYUK!Yws#QVr`$i?NjiRGZ>`FKhk5yRlw zK)6f_vfH=U<#$Cdb8v82Z8gRu0o)8zlMf;&_@!A)aLdv&FzAPyIN~8uqCt`gaijfs zyA)WXdwg8N zjUSkCfWcdb5Zd?b@xj`L4-X_A-@glhv#MiaB2IsJ_}#e3Y-9IW|e)jKK(`QUdcpi?9 zjtH-~y-;(QqmW0A90JGIRIs0AYrz_t%qyG$&0A%UeN}^QQ#C|-r7by*rF^)nOK(|G z)V9GUpYLJ7m*ZW0hVP=r_1$?~^WwKC+jy4SwPbe9C1zhgquu$C`l!<}YVK6?qtZIQ zz-=ipM#*dc@`e_Exn{7$efUV63Qvfpz{MF4sUl&o?sq|@)d#P=o#fAdKG~(><OJyRmGC6cf0%7_wsxF_5781ld5m@xYO_1*?iTgC(e8O5wH7^65Gw}*{CQb$GP6$e?yO_l>40foAkx_qeahFTG5lO zUfl{IbozRF@VtTixFqYc^~2)r9|BKAD~jFy5LV;HSri-Ks;%DAKMmydX_(DGxsQp_ ztCepuC%fJ{GlI^ycI`DRKcp8l{Oaux`8@Vob|Wgck=Lan5*ISnj~tQQyVn=q5(H== z@POssJeNscgt+NXCnOS~cEA$F@5w1>_e3H%p)kND@fQ`qkIq~mUsL1R4Fq5> z(4>{TbmM3`fH$nZUfAFeKA!T`{?9Kv$(be)7CIMh5WA{XG~L95&O&jrAv0&^Vf_~&JW|BhvSukQG@+Oo6uwlFJb`@$Oq!d^?WFrV|@ioZz zW!sSvdHbbI%EgQ5*PO{b`?b;M;`8)RgI68;J-d3q;b~N5Hr9A2#-!Mk>dqxQeW~yW-Rz+9~?u8(7qlj)`$~ z1k4LrJIcGU?$t709!^L~JR+&ION6?Qi5A%9exZ?@_NA_i-f>#eT~4zymBG!!e1MHU zex!qw9^{Xxgc;-8$278gfxbHYN&{CDXBkn!>*{XCNj7*V?#!*(1x4srB&DVOy1UPz zDz57I0r&>>7X*jMK~P>+7Jk7?A~a+hn?0AUCW9M>)UfKw2YCJWp9rZ2K7S5CE(RF8 zhv*IKA1jIc0P;iZApl9|w>&oM+z-!+X~=cpVvA6T;MZ69`)etV=kMQ#rY`nv6rlcV z@;raOr^5~lxf~`g*m#ZbOhF8qH{oWb<1OZ!>YsU7!=V0w{6wtjWu5Px{o8{d)_I*x z;;M3+jy#p|eW1N=QB?(M%$;1xCaAQ@gh8?ggjn*8Ve65eF7B4e^H3qx zwf~(x6ARN5Xaewjyem(n4Bv;jSr5$Gu;t2#V#c_X8Q*w7+xTF!^a%CEy*5< zvoGXExVJ7w$jxoP7OcbG9eR8{o&O{`AOKaM*#@b^&P}(?09^8UElQ1!jWsnl)1Ec* zOb>hb)h1;TGWE~o+tsgd(jnFXuG?jh z1;DUMpY{J|+hAdwIeRu)!#3;tKcgrMBs-Af;YH4PD~_)Dbbx>GEQ8Xi$Fo6$?Kol( zOCovFOTFig{!~|vufXYwB#jt4T$BkyY%oY9{s>JMTlUCK%Mm}UGZ^P=O=EUDD{-dI za<7BHTdZV!OxA21KwN3*omZWS-K5Z#2Q$wIo;dNfFc|qbL?&cF0EV7$@_F6-@k7kX znTIS~^7^qdF*}6h-gt+wLbkT%5EFZexEM-V?|%>{kE*`&ZtNnM#*(}N7A{Hf%LMR< zECB`5fnA$av4=)SM?qzVeQqH7F+^%JVebFWL$yoZ|)aI?f z(++a5YEozZ>?#@bJ7P`WF;<)N=~$cdAGf?z@!;}@&1sqYHrEKtX|=v!46CuZc$(jF zTjR*sdOptTH$2>m+HAJAmT(<{@gqdY=Q@Gr#^W zRa$1PyucPyc)ST3hf**$ov{8~#eXz+HDuMw-JR;9CQ4WCIF4{dauFi?NP3$}Z*-CCNa7j8JvHL%w>QmIr@az{Sw%n5;82{zn6 z^9Fvd4^JDwlqW;fqT=EX!-5FOfnsGE_CER^7bh=PU?Y=A0e}Q`><1V%{vFSTEtDE8 zK(I=IvWMozK1$EY**^|K^qctfz*LYt1=1=X04!Dj8DgJSesihjJjLadM9zvo4#;!`)fp8!!;wzO;^R|L%~ zxEyO5h$ILoc9UO~uu^M0R*N%)bx|B1qD&Z&YGG62p^u9h=_aL zX6kkB@c~oVS73L^&uU_~5O-sO`oqaW@pqS_L&}{Q(S1GE7cOMxHMjC9y3$}fAtiyO zO2;1RT{fRKzb))bx>QAOmYo1z5%9>%H!+Eb3%@9HCiBTIw4(6zhZ^Do;V}kUfVk=V zI~*pitB`^e1G3ODHNA&yiWo<~X)_@;v%9O@kj<{4p5&fn|V*=G}fCB!m!{6X9#hYdag>7eCpI zzE3eMvj_LHospV_9khHUpbBEQg#UkIu1Ou8N}N66Gabh%=;v+)=rSXE7sX16h-SNN zxiAa{D6rMF08~5;ewX+&w@ns@YjJ2=yo&0YfJo_+&5(QLgsrv0A;3jwPmqTUbQ37V zPIT(f>G}EdCkc1%dk+^rIy#O~6B|(~VdWfxEJ}|12tC~`nejrK{?vuv@17u<-=B!ohl2SK6bm(&s4XaV9u z2!yHm0$vJUChiYsDG8BAMvq?1UZmL#g?aMxWA($jg>w~#@9Rry(zuwC>yrDEzobp1 z^5nKDQB){i+_9fuzE@;0e(d)+sW@cWX1x2NlN{3z^NTFBvPb*7CrN!5-jsS4@vP^sM~k+J=NcsTA8TBoy8K~(pfq%XUnTV$)u&o*aO7kB zyIFPT{`+wbJ2K06>=n$qXTVJn=!%Q!{j?{LQH9%V@;lvktw=N5nXn7?UJ<%WZ2pMT<=~)I ze%rT>3<~Wnr?}|ogQD!jWCYnO606&=Awz~+S^s!DogEqEf;l9ll?&XJL=u9LB6}tM zYW84r)ORq&h94q9v|#bKt}{nUQSlDI^ts=^OC=&S!U}}*9u<#;TP%YYt^8+rCmPQD zm?r^N%+Heg4Yfb4C47f zCb=Me1=shfiAFO*K$h_WG{956fqZ@FHqXSaql|f1e?{K@KcCW_&M}(}UT;tw*K~fO z`%{}q&sNCvOdpM!E_aW0=YE_Oz;?*j(YODk=uCID`z;xU0T4FJY~iighlb!EdjZrT zu!oD>5{lm)b_`1y3mT_g$%gCo2B%M3dJfWR1F0kkCJx1!g@PBd4VY*Jipim*o$-VzHu^coOsw9o1aT36d_aImr22zU`VYlNS*o&lP-wTU*%xf+iXp z(;xK|5`$z7=_hF!?qJkby5-)|XI?OD+aK+^_eo zNsJe3jlAQDiF9MO!_M~S&c(&HQfKL(>84lDKaF!ZXQKOUkC(@(M*pPmZhJY(PUhJw zh`+19TfAiN;v2IhxQbWVOYVnYNYa#^4Qv0Tq>No+b9p?hzttxxXSj>iJq+sJF1~c6 z?ya$D@#1CiqR!_{pGwT$scoouS$wlk;1JzJ2B(mCdC!s3AFg?n?kTy{3CeW_Wn)3% z!jg!;X^NGFW+`1cSK5UX83K!y#uBmx6>>G@We&cZ?)|k(FxTr8=bR#YM5^WHzZ^H8 znb}LmZSJ_zOj|ltx*@P&VfF5*dBv&d`p5m@awYrX>vl#qlt?nS+fD4;r(`luyL*b> z7rRmYu)m|AWLX8^UjDdx{ur^#PRDM`T%+VQ>6H5N%2G;QZ&da7%MUj*=DPB%EmM1W z=lJT~pbI>RJ4r)|CA2?@H#(mC9Ol6n6`AsW{tT-c6i=A3Ly&Q_?#Qv^=9WKhsD0iJ zv;n}VFjkKUk!qH~x+WPljBRR+(BG9(QX;xs1cIC8ys8hB;3SWO4K`0(@w3h!hvg{-5+bLVCe|jH z=0}9Y=!R{#=6_Dhl6?MXb~Y*#(m_G9LK1`ya5jj5mvB0;AL!Evgp52d%2mdP*lia) z5J$c`-K5Uf3#)$s%V=My0rH2X5)BO*f`+I_l3Q$N?#t}|DrFCrbH9q1vK1IHdk4F#OZ-z zm9Wi(%c|yunWU0b(0-4V^N;^@87v_H&WHx8D5~-H5V(02UA_L5F$F zpd@a10AUl(89S;=H|*P2U)7tFHU5{(z{1fA$Ug*y*+Ba~LrPw9SpDMtuRpFe*d zc)1CU2Tne|$O4QaLAGaE2oXEhn4aY9BpZD!G~y@%afDRU?o&&YT=b-t9(&yqy4D+5 zOh<Zlnv z(Bc@tc_06l3{=2Mfc!g=D%8%QDKy0vZwx6g5SumGLKon(I)mM2N6vH30u;u zh~!A)L(Pyd%G)cf;fcye_{3>^v)m_wJnGI)M>@*tRZ+aw|M4)XJ+AbGv&-GuMw!^| zgqq0mgb4ee@n1)JLk2BasY!62{hdzh;?hhT*(=#VT@@Irrk<3oYkwb|iBbP|j@j#F z|5^w|>`TY?or4S?ME7>zJcs zobsRs)ub9bXI}B3a%)9J-L#0CDfJF~9_HUX<+>%wc|a_@bNZ?`rK`Txn&S00tMPo$ zBL^-4re@aWF~7F4Ko!oLZG3j=?5{3A%{F@aV5_C{R>O#9h6Y;6lYAN(tjlH_d=6~D zBs#>pun?#9(vb~2<2#F1htzzu8ffeYi!&89-(Mzr=cNqI+TGquTi3Gv;hr10skxlw zI~GN^SEIBH8xu4xv>^xgj38f_$;=I8<+HeO;fVOwl=>!YC_vzhk*>l9O(!)p;HB{d zc9;RfhGZ@*BGms(6;MdQ_>TDYA%x*z#^Qz}jrr~5v4D$AUo4(yi?Q(EIg;c$k{n4~ zYY;tQMAnOZi8MKJS_8HrUPm4B-cd?V6dzPs5(ES#3sNsR`oPw<9XXpzL2bc1H;YcW z^X=VG-#GisylGZfEU_2mB65UnK(u zFdz#1l_VQ)9_q`;?oX0A74$p3C@|R{)4;{f+nBFA!Fa;Z$fV@YABrOHy;z@Y(V;)@ z-~`AA1Cl_k%e1aPTMfum17i6Rr%6U=;D~kbnnt+AcN<8kLSGeIhnPxvw{u+Lq$BND zKqUV^F`+)lulhKq9UOhELWY(gJtiF=l49`jRbVCo@I-^gV^SwKqt?NUHbQ$P1O-G& zl)3$6!lXSSAL)0ZJVvR1>N7BT40c5jLBRzSs8suOvz_9}4h%vfdkO89>u?At9{btH zXga@|;ClcMNYI4CLuK&G)m!c9_t(wCzM&LUT94PSqx~ZFtE~e*?eUr}=a>dSU;TsS ze+Ca3vE8lZHCdwhS(eziIG4!hBd<(1DGb8vd~r)*QVV%xvyA;vJmAUxocVgYq703*+kV0Jb1qZ*-;iP99je;96)xj1X{^n%kczpqB9^ zE$Mt@xa9Aw&*0qp-|Ux8V$Jv{Z_k0XtG=Jl1Wxk5e-~)E13BOt zOqhE0HY?!G8)rRzig{Q8PJp!kYDfqL8(H`vCj0KkrUBn*;Kd>u$HZ2-v5}@(<`voo zAtBD>wW9Ooom7N;(k;6j{{){naQqgyOqSL1RCEm)yJ>g(@ZT^|Ic;!SQl-fId6i9B z&iQ9A?N76px%|$2lkX2Y&Wo2BXvbCioW3Vl=!tEJ)B<6R7~f;l5LO?<8ZtY=i4wxD z!{JuOGY0ZNi>79tnwU!glY{nSkt2?rQ&aP5+G7%}A=M02K}vVgXg#L=$#lJV5_MMU znY~?bgW%fwPBCurz%vbb3qAoV73O`0qA&awH{~g02KbOU`bihKjoss&-t`If zeU7rS3Kz$NM*=G16F!a2)w_+<3co}Pol0*@oeiYzWB5KHz~Mrb*?TDZ%eb~J+v5zc zUzbZBJDho+UaA_phwhDT{aW#W(nkBiyu_$au`Y35Y8@hlrZ#CSqYC;H0oy0UWtAiq zSGg7_yWa3zNhKG30+UQ!fB569*ghuB$32Awf>`)y!ux z{-#b6C^BD4TAGY(0n0zlAcOQr(2W3Fhww%m@(JhqKD&V z?Ihb}37Zdr3^q~W+?V%0Ogq`N?bwDPKPFbznjf?zWyL1P$B-fvaG{&f)|ZyVz?m?A zQQ$T|vg|?_3)Bo)F2kdvzL+M1)CQ*^$ul$MN>{g3aeU)I$$$q#PE0bt z2~#V`Sf^q$?a|iOmIMF{ENap}Q&aOBSKP!THJ=9|2wn=Qo^e*g3TxAz za(G-1dezQy&7!{bBG!@NIU|J2K=Y`X@tav$ z4*0l|A?F-Tu%D{h@@Ztt%xPF87)nCS0Qs2Cru92HnUV@pHznF}n%v{?d^IT+X3ugyY*Sv@ z*fV%MKGL9*w_mF#9w`#F&t;zduJCN;+;cegMJ^rVpS>kyrq}+WKzg$R`c7Q;V9JBu zIO(E8jRT0Ym+d;u&QJ!Fbja?pov}H~FIOu!AiBVoknA>yak8>bJ#OJz?@yrih9N6! zl`ZQNU$=BfZ)Kw68w(FxY+3bMlo41ML<|hUVXv5V+{!Ja2#XV^Q>28cc>BB5@3d!~ zE*$K$9f9o=dW;DLPq-r-lv*)0!y!Mm!2Lgj0kXlrUhO+CQqz{3n>!6p&Gp3Z8&p=( zYXsG)W@|j~F7mn9)nqEw$BGKj1{%M9O_vek!7lBG105J_47W;0P22|AjNiI$^eke9 z^$~(Au_1*AbBP+ghBL1J<_(+MD_>a6p`}Kqi_#E7L_)1)V&dPxK#aK1q9jv~3AZKN zx`8;dlYp4E9t=s6Hr;Rkdll6h3Ru*k)-?FWB$}Y9Op-D5=ukVN`vvwKwm&jAVmI1z zp5C-0C#3wawZu41Yx1`!l8n6MrEfXHCmSaW%4b9?#GfqyKc}WL|D(owSw4P!W8sFf zyhuB$N@M`&xUNHcd?x)5SUZ(-WM%-j;>g6rN2J)K`HA9@*yf_+=w@%mchh+Pe^E^u z3uI8Fk54lc)1mVzJ0R9T{)b0Yfdmdul_ZteX5>-h(2*0E2r?k! zzrdn{Hmw(=;;DrjDEiA#3jjUvMl&(LHhN$d9j^;<3bkQXy8x2B@zaqz^!C23kkZJv zQy1W^BnPB#Q1uel$zb0Z_2?gWQ3`SgqW^@J1GYY{N9^d)c?NFCtFyn*BtzpbZsulQ zwt_%?yEgq7M1hT)1;1vzd`S*F@aCp6#SG9Z$KgxTWt0N|4$V2~(CBaKyw07hL#Is%^hqaabLYwJYKvIuUQp@8XG8?g;8wTR{%H|4%6U+yr;YYv+0 z8OZ;>3u|lF0u#<0$K6c(?mG#wo5~K9cel#h@6(4~0G)e~kR1AoMY?$%cq2@nXbI+x zYpw26Ws5kOeQaUr(G0R8*RqjX#2)EBZ}BgD@AC8a{Nl1DGfa@QqlbtP@j#Ys6d=T- zM_b7uiUCpwa`XNOgK~iW6cRJyD?O0aiC0E+jZdE*dc5Jqwd&^0wllV!0htg3SNPh3Mt%<Atc-;+!P81D|#R)nkG$0YA&8dry}C^Gci@G z{o2%X29z*}fC@|`5Z&l?yONk`2>lddHS}Id?Fb$XL1rEvPXy|MvJ!_3(nh3;h-VUo zbDL7jdv(j`Eq2!;IFrr-8Gi>&$Jf<_Sq$Pea)>1SCAgztUSl-N0W>CoD1l6eu}4A~ zTuHB=xcH`KBo%M?TZOZp)b=Q8F%|R)nw@w_;8l@0h^*=O@z`vV)L7BSU0@(h*uugP z#65zH-%HrWGAN-o5*`mgF1gR_aCHqmCYtyE|4dAAaTEfgYPD zqlbZYhku;wHbQ5JZ+VKI=yTAgKr0G#i~BY6z&?=%66Ffc1TxQ%C>qf31Q;NR#tPmV zsm>)df2YXduw3kA^(saHL+l-VJR@cQvmX<#W9w3A*ZY<5BbfI7p5#keZ@>EshJp`> zUPHY}#>Qjrj`dn?H#S5ZZBz}2wK+3Tx+*I3y(Hg}{oc9fKx z{66l7&o=b75lrAf?}mi(x!UAeb@pW=!9AC()fa9@htSa&|oVSS|f zg>UC145j?H9_iJ{Pvxv%$&{3-VbQZ&4qT@`G(Nub+vLqrSI%mUQ1}jT9vn%*AOU*D zdi{%%ic<@#rxs{4nW_EI@P%qT4XuA_-qH8?s(n;Y#hCMv#?p-lXl5`#!y@ANt+hqQZ6Z~O3E?|5yNT+L53sq3R#v&So{1-mq z3M8i@2B-uu!VDV4g@q8=uLnTE15=KKC~U5TbB?{%m{fTC5ZO&g zYQVfDm^-G_p!idFaml^YhJXi}8X3%s4Ta0d+#ajCT&KaZGBio=Gj}vi_M(ydY0)@S zO#3KfJ4L1`mX+O%UB`p`1hEwW4apLbSz{_Ma*yiQy5>TDD)^EPDY3(eH@>yZ4K?6S zP^gm52VgYNYDpGml3`#HdBnLG2aa)yhC$QwUYcSQnnb$_Hc9>&E7X|?^JL&1Nm%IP zlxI*CfYwD)J+GDbUA|Agd;GDr8y*K6$v>fC&_VK~t}QERH~ut4CGeo{=^WM)H->8w zknGZ>2O>979Sr`o^|aC|1oZ?OJw!wu1Pn6a1-SkZW40rH5`eVuW-`lOvLTRWDeLuz zLBL*jVQ_mS(q2TFgJJD-eNw+OHt5b`$}x1!&^V}rqfBHV8lJc?Lf5Nou2R6OmoCNn z%%Z&8dK%N8DK=NHs{NPd=->t08svRw@$BMZ-qrp@QA|`6SlM~&3ZwM*kjNl$#8SBJ zF;ai(w-bfb{`K{mgk7UKb-VR+N?tQr)->p{j&UCnIW0~2YOq)T=diw0MDyLfBT49b zIbEpc;<8l#);~SYcS~fSF?XtFa4)~hYO2VE@8)C6$Fx!(@z$&R*Wb06$b6lAm4PkgMqne-)1yWdo!-fx`gr2ELQPTAL!?5&zt&W?PV$n0%a zb7qV@m)!4QPcg|&-TPKGnR=C>!+zQJnzmEV_X)ZOMjU+HJI=+==`5+)v^5rszo}?( z+S4dtee_O{YNU>yT?tF+=4d*ak`Q{TpMWy=y=jh&Lsyfg%w+4;LSkt5Wv7}`?i8j- z&t8b;?Qo;A6jYr5AhTZj?lHj}k+# zM%L6Am0D1oDL*KGSEPHMp6xwqxc5=`b;h=`hlZF`fF=tB3S?s7H0CArlcB)&{QpqUY-OnQ0*v(oWwY%ztAmSePOnZVC$&ms&F+imo8 zJpVMulV&G4mw7i9vR-Jr@@l)O4v0lBJUJp*wT+36;274o3oNeRjBZ;OU|zZV@pgNc zYhe3;m*y|$p9jIh1O`_qB2yq~dskQN3kJWwUG5k$1%)~hsV{sLiD>$0QNQm5I+yM2 zf}5}2)(vX={256E^S!8$9b30k{)7vFnf!J8_-5%#IeX`OH`koNi3_n7v^t@67r=2c zixzlL^)&qXF=MLp!i*JQ>{TQh6y{N`T)xair0F2l?~eg%Jo0yvVAwH$>8mUb`1O55 zi~$Hm435cMH~_PRb=&|Ifa~{1)^-zJ@{aQDVX ze)R`_)h3YhZLdDDKZZ>@w|;p*m#W#@usq%{^{C+nUDHLq@9O(r9%VJ=6LyX6xLke| zlkJEgfQ*6JYifc>YTfX-+_6DPkbW}}lXf{a^pXcaq?`t(9&8y!%anhE!Z5`p!b&bf zbV#~92*j0bXsVk0@mLs)oE}Zf=mVFnl+1V`1U!bQ2nb_|r_;XUf_b277HTmx3iws7_4J{0rk{LLJq`b<* zV+n+RhhJTK+uSrgoq6NsoF05(%vl?i;^<)o0UkjHtm8e{@f7?`jg^L?Rb;Gs`0sl< z4+a*OHZ{!@r1_1WzQeN3b96=ZhSRk`e{tJ2^#=QNzp&G~2&!}~A1fLByUTvVq{YM~ z#cp1!j-BE9Npr6unf%_B(Wt+IfwB8`qOJ73KvrD6~ z>P2(XdY*VepKUB2*VncDA}FKB>c)>|Yw6e3vmMp8(cjI@F8o7h%ZI_@Rb)Do;$@n? zf!x*CSpwr{hx@La-Op5NG&4B+$1Iw6s9L&dBqDp%E?viQJg)l(Zw5y|g-yGzdijPB z$x%5JI=(EQbzB(m+*Zu@9&(?UwzKudf$6r zujlhIU)i3HKd$MrNsBq>vC7cWNEuiG_nfUO0LU52&;qVi%4qBkg0nw*%DWS>Xc$qv>s zN?{~uas-xZdSGIXuNsM#ZM8=l$b}@@@O^nYkA-i|gD#Kz)4prM|1(unqW%A8aQffB zP@GOcxJ|FV4$qC)%h1gI0!YfSG0MmM<#fa=;2Ht=!d&smYVmfrb0BNb$x={EaUdbz zd$(H(v=lkt_-`|n2}mzL-`)U|9VJ16A_I3AEt>mZiwJP=%>A$Ei_-X&<~99=Tdzj8 z3jqU55`b$ckd>74G+^ox>xN%@1dw3lTPz0EmXrogV7YFg5PiV+LRoPrZA)N-I%CtC zH0`l+q!vPR8EnuL~Ycn83P zfZ3Y8cA%yAN{#loV!|65_)JVN;16op#lKh=MwdKDzC zun$@CU(a(qA_oE_7Yklp-Zg{8-i5cHpo34bhqzE5wCHqA3BMZFkN^Q0^NkxGmVZvQgity=*>hJTuSMK9 zXQ{V8MSTn^_5XD9U*XA`_saSOd9y!8WKCBu-xpFQ0;l$qLt8xkzd_LOyk=j=EDcX0 zI-^!Wpz8ihQj223hqZmX*9-o`TN;nJF05xIJpJo$s*-1+Hz`1HN%*f@eQ`S0tgM{@;UeEZ}rDQz2Ux9FJ`TJO4TUuv-Yhz6W# z#GZ+VzZL(cD2{V4f>H4lGXuZ}l&Uwmu>bmHllQ0KYqS#hf>CRRiU1(CHKWs$ogJNE z2)Hz!hl!fD7DgZZCkIg1XLOq7UiD>M`lMRpQ1rAYiL?0&r3wccXxeAl3pj zno~NQz+`((#H0SNUio7c3i+04}g_@G%I_&;+k%+OY#aRz%k#R&uyJRs@{ zyxkQ265eSNV6Tpjj{lU>!C-ubwSyP zlb-0MhZ)u=0G7&Y+9%fnz5=vCfUjEkJp^%mM3FiI4Igk>P#|0Iy`|jdHaRa;9$58~ zz!C)3uP;|dUp67c9w-Pi`vPPYEL!LNBzLW9X*UFNKPSEM03v>z+csFR!N5yVo`F<) zpr|ytF*^x={s8ZObU@MSkB`%Vg(k9X8- zo&{LO0VwA7GJ^A-&5v$T25_fQjA*x4%}Ny?ZHx_q=LjMM>6BXoHbWlGmP%u+s6OZm z_Y>?fCKz^h_GU^>5)g1gr6Q_jmj3RJ8`*jc0%{SYDX2!2Gpck0CYuM)lP>$~*MUjr zKc1N8e82`FUfU8fi)2=r}JW%np28DQoB>+nB@#}!0b11k}r z<|vvuO-H`BFj#{6=owC8UHSW#ei%o|8Zd!y4S{_zwc*_%Zq0%a%Jir98EVEuzN*0ra9e(p%QEhsS{Ex_s^w5R z8<8PEa9et$Ei>BxXMe9K)V_Dk2h`f7UA*75eb{8#KsxH5%e&Kae6uPPv zAQeqUX2bfP)}BG2c`e+ekr$JwIOk;_c5YK6Z`2`g9G|cpXX<)~-WGkU_C_*tqnmw1 zY9zMfO>8e_`;b~R0HTZ*yma5ANXiao2QnBh`MpxOyF*KPh*5^k$)jC~?-Z(5z(n79 z{ipWbb9(Dqo^Mt!V$GIES$eT08UeDajX2^hmn-Q`zJfHGdahcisP&Fqkc3j=o*_`FhdCt&mjV8A1FsAr5+*TfXKrj;%F8BpC z@crqOF9V!BMdxK>^JH^y@3S7(JdkMf*F%rNdBVy zj!zVz83a!OVrbaFysf+!B4pd)SPDM@TOy_N%;K|6A<^Hg#|cj(-K5R21waXawlvd= z)KN850YPrjFJG`%S{ku|jD+oI=qZOhj|iSf*0EJ*_^Mq47And}1JqV1O7ah7wq9{J z9ss930B4%{i+Hl!fBOF%;RQ=7#h*+8uYte@oZJ6vfC5J#AS?k#NKvJOQdx?K8u;YT zl%7h0846$~6j?bC$5K?UU{#?A$|?QGsoVe~1zKtb3X2G&OO&t(N>C3#A;2yE9whi= zfXoX@AOJ|2p!BG#WP#ivN*o4&_)9?56QHdqCLPKs^Fx67oEhT9@}#@JBc>hb-GQQn zqId(`HnEf%YyBy1&KbCkyeC&$fu4AdQW)wF;ia6*=BG!aVCoQ`Nwyl%?@o%>zr~EZ zxw=R4hxlVTXC8s7Xn+>)P@LWf80D{324%J&>MZs$t&ak*Wq~z2)^GYKwYbV`Q)uSr zB|`w}5BN$8;Nur*I2Lc7x9>`o?mbU+d49;}kAY*Y%j}FkqKQ2`({aU>DXMd6SF}DR z74$MPZ$cZ_yBOSL=mAnli9Hz@_}ap&Y5J@VC^Gs1PYCWRFoVwz{EDPd%>xJ-*W0U> zkllD>jeEIKs7Zyv6rGd1e8z6j)hp?ktX(hm>o24dR6IsQNIEOk<~-)y?oO%9k5O8- zURV4ix*0V1V@Nk;hoMUyWVV*at{)-`CQst1k}%OCxuHL4i>TA05O1^c-n`6x)o_j= zhxRD)TasPy6a;Cg(|_tO-p^l6T2m${xzMkbXnW{)nI#M|{^?>7YRe0FPLM3b|3pOW zkWYKfF@n~eKW`fy!HYR!SBsNffGSLRPObNx@6st(HWhuao%GGWg0Rx<$dFxaEUrpn zTEKv{Wyc9nA0Ag`-qT?in)hfde97D1S@F+?Ps6N2hk+i~9`1f$JFA zbLfh}2m7sXJRSync$6`!c9BCz3RCD#PPCn;zUg^>pQ^LgdzG(h92@&<%~ zLM{pbej2oYvN@UptEX=b%vyZ4McS+f&hA5(3CVUzJIvj(*iVkP26TW<eGj*m(MjPA^*AM0n3E)qn?;V#29hd+IH8B3(2vl)h!d8;)!ab)x(DP)P);w+ z10Td%^<=F~ZiQ!dea#TjKO3|FKfZLl3_rPhG7cr#9;yeeh|J{%9p&!4gC4v)TmlWn zpp%f$5L4uze-x($xh`_~ctPvrR^Xy-;2Zz5?PeN>5vSX{^Qu96syqCr3mIIz8d^BR z*VZems~0K7h_L^?DD5|^s@G|M%j5R8PD!UHhvTwO*X5HLG5c>{N!C9%;91Mf&lftz zi=2v|EY2K%Kco(tWsb7e6I;y*d&f>w=XbyaJ>7-Eftpy1uAm6{+}SxS?4Kbd+NcPNruf$tM@Lrg1*kVo)uIhD4{NEsMH9G6#4V z4Ie2Hlc&=SN#<#@*~cd*Jg0}er`G=a;iEM0Q&r_Yq^D+2hPJ=Qz094MY);hB${4VhWjC9Q@5x&! zSh390jD~424M^!P2I9RRsnw23CXD({>(3m~m-(uJG{2_L~O)b&hrWg{N$x&CbY<$PzJ+Z_pd3!W?}i{Y~6!*t94 zdR(m2{2l8Q_Z;HF4r3G64iJu zBPb+@?0h1z$&A`D-Oo4Rof~ux;LHe2gwpMWLJ#HodHwvcan7^Dok>*JjW8 zbW-nm0o)Noy=&-GL#lW5(=X2!DM_o;dP*c?%lYsQ8-zwI|F2`%PFOrImbCZXdq?-p z#vMQFy3VnMH+g1q{h#BXBYg?>&*#C_0HOz&BB&u?h-f><@@bm6Ozzj@QVz7w77^|y zMf^P7@Q`QF4z4;;o0#}qoVl{iUxJGyUpf|PFlhU6UHQ;(+ULnP0n(p3j05a(_02?G z4%Via%~um{VCPI<*gWvtvvCtv_l1S54?1>k8j2}|1^0FybVG>?taUYIlJ`W4N_rFh zBUrXwzsYb8(IU9tHkWWHUa;m}_muO;cko-2YKvMxp`iC=r{l%Qw!{5>Hp&MEPvf(&lZ}~nuw?R|mba3C(zDLxrFy#r&mX_LTNpeNpWkql|Gytj z>81ue|KaojGC~WTd#<>+ct^cqPkp(D3H(~nY5)8I$$7f2$@#e5c@_-DEzBpg%=3S` zc&!4sI^~*8iDvs0d~hB>(^T9B`y>VADXD=Mx^E2)cTU&}bKvz2uymC$Gjpi>W?Cq9r}{L_^M!(Y=llm;4&0C{tQ7MsWrll7ddR zuu?(0QfH{3drHu-nFCTFFGP6e!s(`0LuAk)^C=(laGrj0a-lcd_>_8ZX@1@6Yej#% zP1Ggmj$hzeD1j7cPJCqoNe?(79)MG)3EBvB{qJXXS)n0Xkdt$MvOM9j&NS}0-@!q9 z@RLjb`)N~CnOlCxwWph>R;hcg=9}>mEcWJoX*X|Y z5vI*k%(Woqd7>c2LsFpQyK#(zlKA5UJ286J-anRq?7Z*pnMkowcxzfXIes9f^hkQn z>0VY2X0P2%u}94E`JNA_$J7S}-2=LP5qugxjN4NL+MW-GJ{iTmUgK%}-rLaFK%8Fk z_6mr0s-$MwUggY~kJEy-NHq`w4tID@UHwUz8I@;^!!m*N5?*e>@}|%HMhYB(pk}j|Jl~!r`;AAS`VC2+ zmWutqU>tv+JLlCPd*mN;V{@gube{=l*2IGBYYL_{M>gKm^4^pW`u65XSVaK0pk`w~ zd#*m5O<}^x@t-R0C`x9-27L2h*M_{L;h0%WB3xS5EG6=Gez>+7l>b0ZzxNu4 zansJ&#Nsa%tv0B`sq@LA^ZXXMz9EA5WRACe>y+G(aZe;u>lIaF@iDr17F^JjVAt&q z{*2i_b$+&xAFaDC(zBz18>22>UJ)6`9T}~vyf}l_8(7@`zBTn*0nEH0fd;unLOQ2n zn;ZJ%#}7fpI+aD8HaZ~Nll>WH!}G9k=1ABd6qmLEn=TTRT*I|g{Et?0s~Q?mOBq3s zeYs^6(3_JRynh4ysa=1w@?tAAt43$t|E)^K=@g0XdS@UP1ofv=}{ zRw@uHwLRZP@4sd;)7(IIUOL#@Xx>B$J!qGM>1Pj=N=nLmyZ>^%SFPnU^$c542`hcc zd&PlJA6}nr|TI{|1^#i zw2nM`kf&$-#^_1(CSR{!`fJ6SLGAJ+w4W{wQT{2WO(AsDDrf$@j2RGBf zV682%^}Bg>Z7&*eFd#Eg@G=3+nox;yE-3#@7ZKVA*^b@+dzzpsdVPlpDm@mO!7bDc`iFP*EMEN zcxt?f70ewwM;`Oes(V+oVMY!2yZ>EbdY`S>%u);OZYl-&mHE`h(opV@?%TD!LXYq^ z^tz!qx+M3*dW`t9y7kI;j3dq?*RUO|x1U$+!(9b8>+bG`PR=Z;J<{{xnmX!n=SE+9 z@O4kFGLi_3`WEClXQBGeXb%_Qv711asvkiB3Mi_WF^pxq?D-8_vCge{KU-i|(>0^6 zw2OYjZe8Q{wH~2wENEV@lP}3r|4%fD4IfSdKOXi}@Ps)R=_77_WpW&&y9Kt#J%lJdD*8CS1%zc(6 z=ca#t0j-v&u)yg4mDT^u$~Q`s8igz>QISb90;rSoX8wBuo+KeExxHG2>H6BH1IQ`Y zsFc*B3y&&Mxfy|?D#-C# zz~@@OKVE)q6lKtRbrONnM8oR$iXk*c5j)U3Lx_>{J`dZlyZ#T2oNC+uQKRTI+>=n z&7R~DL1d4YcaSYmF!j$hUtRd0jcK&AVL#!D{@zYb7iP8&dUk4`R(ThmP}x|Q*>!2~ zVqC>O=ilPdANyJ%6~hbO8S~PjP|@29%uU8o)w~7M?XgSO7Ob)#V=b}wJA9sudf48^ z*~2ptj1sa3)Tvmu;!%V(QLry1s_D3xm<cXqC&!}3i=fv zX&RxQ@3-_}$9VBmYtjqO8E$ebCI#T^>_6=8V>t^sS+EWh7s{_^y;xIgwefV%D5thI zwGLzIvB|p@I>|T~Qv1vN{+Zpji9+P~r_drFem`?0;ytF(`Z1=&1Ib84!DH0-3Eok0=Pxk^3L^#4Hv6AE?78eCR|lRnS-VHWSI~Z&^OpHM z(ef_GdvAq(Z&*JjIjN&Kies*t^1Ro>jJ;3cA($k+(=M-|ftBBPAoX+TKEqs-)u1b` z|8+i>#(4<7^H|r$=V3v`O1xwQ)w%5BZwc{R|JssS@h`r*%f+Uc`6;ijuAcXtVj@lP z)H=aq&mu`vThSR$>D*OEF4ro1*~3xTL+)K?04HMQeBzvM|LL}bIqcrn;e22mC|Ht= zH%5IPv8(6n=l(3=;_vufYgHorS-$Z8&EiP>c(#`?tT=@1cfl}X)jFeMIxAq<0(G9$ zw5`1+Csf=4u|B&6b+CT6rr5%LTji&m;L^I1?54+QAIy0tc7$#4w<4o?%An%=}i9|ezZQ` zC+obUot1GhI(opQ9sZbTl9Z(#ra42Ue%@IO5Wf`Zd3uC3NH)nZ5THErh_F^jxKUy%&8 zDIhv{$ZxKr+&5G!&R0->*#A|TSD&JRqiZ_FQ*{c}=a4dI_ty{xi%)Ej7fTEscq?y3 z(!|ZV5~E<*;q>++{4~@1N9|dz&F;@;yldQf`ztnu9Ms(t`P%CkgLEs|3^{rI?)~A$ z&^m@32iiO059IEo(2jIRX6E=6I{wwqgCUCD^VRt$-iFvNz3?tepT2UoPlIfN&0iTP zL+7PNq`WG0e5hbhQuH{0XOCkqh^FOOrEP{ zNdYSNNbC2(9^#&ZuSlt=`}6A>sPE|Og*l@Zd&XoA`Rh48`xWWL5tp8wu|3b+3x1mK zrr2Hty1(B%$`I&8YAM%F~-3Xof zMdGORf5LmeH_sRT{nl&52Kymb;?ViKugdQDufKqNxaNn^^oakY;oi({rD3D?<~G%4 zb~PnT%;sG`6TE42a;u=dxUly4HxXTD%rleS?_YGQTSVe47+Zn^|2@bIk1fHRuk-%Wpb;r9 zv`+C^!_5N1SaQ7Dl*Y#kSc#4HM_xAKl@z3i5-;lthG@X3%mIC?y4=~|l?7~RBQ_eF zy>oE9{g#(wg2(xEAgv5RUGB&@eYmhuh`W*uuamA_u0x(Jk}~&xo9X zJ`DfmvtHsuvRUn4C)i^p6NT$VdfU|{mFzF{iSpmnl`yX{&umi$3VTXOGh~sV)!w%s ziact=+0xn0EgY1HWHTAkT@KZfz*vGP0t&*bEtiWK7?9@`2jQQ`_FwVa{a z-r=8VtZX2|{MX92z?gi^)W_mO0aNDjja$zKbeJ0VWv)D27rovoIq`hmY|_}}GXo~C z(BZ^yPtOrbQzxb;_tX$0(KB(vc5aD$r#+=eqWaF2;eyqYQ;0&V9D5!uPX;xkW1V6J!Jb=0`4=UD}!$Ch(PzK!k#LyG4H zj(VD8NZV|O(ynUt7uT@24357Qk>d9eiDKM$+Os{4N@F4rrf_N&Pi)aT*1cY(c`M*y5RtJdpKB8}p~S=S3- z{|>dD!DeYe+Th3k;Im%GFg$jCb&hp)(`KzbXnB%ekgr1q#dWKp-QEjhw6yi zadh!ZaB>*##&DTOCwTotd03apwYIHsq!&M`_Is{9qieGcVH?uF8%awT4vp!Jzx*Hm z!=oP?MT;qcGyq?)&I?#-8f@FJ@qzIcf6Jv=tbkQC+}L9?g%fBi(J0-M_6q9J^xz&m5KXGk@v#1Y^0fTtu*x45NHYS6T$Ajg7Roa z97g8B--$fFf6gBgV5MRgI3w^TX#5{lyBk5n_m!;C;;)K^*HVZYl>xbv$4_m{3y5ts zFWOg!McVYqY(vEyo^<4Xq((e;w11zExaJ3CJb8erP??+4b2KOd2t^aYf z^E@i(7`4+O1))Z(l?W465}{O4s;plt0>#K!4@08su2rx`n)->p;|i>)+Re-lRb{Z} z`%p!g$+7bXY!tG6Xs^vPlIbb$@?GLiO*B=j+q0t5iGf~ixf=aEMxFz+%a-)JJsz!Y z0}lxG&2RbQ{n$P{@u@EG$h;?Opdz>K+Pbk6pta)V#Cf;cDm$KKR73LuOx103NtjnF z1pV%mf4refa@WF!p{y<;x_E?#nnd;a-B0Vvqt_2<;9i6X@&rC)4P4fm|Ax1&LrEir z=E~uNY{JD&Si)N#d2`K>_G|pB3u2iL%w}n-4k1hL3WFHra|-00myuWTkBBd#Eg9EC+YGiQXOf7!J7&jvr>Yi-gjwOs+hrsM+hG#pr z-=j;ltx=MOxwe?dPk5ieihqwMoDA2PafIW8w>0^BM=}-wFN;^jb?s@{JQ zV~t+wk2hFTNJ+e!-W$Gca59EdXfkAuB{%iVlSm%_S-e>n#PW$rO zeB4yM=-+=(1_acRAIDP%>8r~68gKn;i#@ffvT8ZHgH2At8@9LSTI;j!Qh$xJh<3#c zONd=qa1<6Wc*{91Ny2q-nu=(yKz@=g?cx!)aeVO|{eqGTyp^*n;x2W@CBslQ5JT@! zhBi>r(8SVTH&}(E8qO0;N!kGqWVsqbd=xi_AJoa=lHN&{%la(deB)Dt&n@YkXoDe zW5l<@b75{W2{!UX30?%+q$GQi9$D<5W(`qC*OHko@bM2@M*Q^)aXP(h0Ii5Rep+V4MY?dlCF=T6w7yVF-f80S=o~^G~G?sMgghESnp!K zf9F4P{C8*6_^X?itva;8GGCYmyW36&ua^iwx5zp??U@o3eo$OEdK^5MK0Enoo2^I> zLNm%~Rr>uA`91`5q+6AJU%IqX0@Dw}O(}M(XqaK4*xe_uUY8G+S`5YEcQ6Vu+5W%W zMi#~6KC~!MSC%`-P!#~@XpIt1{sUQqGXr96_E8wFwdMmOOxT)e^STzx9;^Ikpu3N2Y_ z7queVwSDUL#mFxTI()a8 zrJ|PyH|t}!y!k~9r(oZb2qD|)rY9?EqKolT|MbqXcs8Eg_5?iP{4l?aK*JIH)aJ!n z^3|HDt0#p)YqZ;|!>Y>>XAITQ5+UmD_^im`-nV=)9RU|ijdGfDFt7A$mzv{naxcxu z9DloWACa^=bAR0Vj!llk(~ZxhDlfWq8N9x`&%RawKa-Ufp5pv@MA}}r2&>%W?nIw@ zLN9L#r{;-7DElrXvc8M7j#7a?6>Ipb+cWj%nkT{{E~DvsZVrFTK>(v((9*{Bc6ztD zTT^k7cBQb=(jA2IZ>^Atm#WS2QO>R=^-@f+ZfFZ85|Y^Gs-OR0dCYoS+C*qgGD~G? z#}35Fqt@|WovV(Vj7Kn&(N|CgB5QhubqN{+zOEoqG_RrEi^+cD{UGy_g+_yAevnp0 z@y_@DcI9ue!G`yFI5>(!&&Kq2T!%_*AL=PY8BbNK`DpOw#f+kP6(9ZV9TdwLV3^Rv zVGV~DU^*vkL+%{1oc}HGW9aufH5uYs=j#o5E7UaS4e_V%&?P8H&>}Y0IC^#K+N(bRPT9H!z-jY#xxD9S-z}w2Bwq{?IYS>t}q}vH_|EtQkG$i{yY$^+lb7g!buqqhd!|^ zY!9ba&7!Z#3lT1p8{8a9Q;TIu@eUa!&i9{o}-YV{d^0?RW7-yXJ@Zbp4}}^Q)DF4*(v)mR^-&=0_$}(QVR`VHV~g z{<*K#>S0fx7X-pgFn(xh?b>I9_0(1}D6T7J{AIfEU0xE5 zfTrg6baA%~?Zw)9m$@ug9C8e$qx3@Cn?pxcbLX#QJPL4rn{eP?&|yF6j#+T*8alRw z6zbiD7kT*6oB1V3-oZWg{7_%#3>C7R+H~A|h!+}n zTHui|xEk?36glm|tyrl#QQtV+3E%GL&#*k*k&5*!E?FybA)+X7mf|za(NdXSHL0!R)(q8oXa(x4wGONx$kQzm|Kz9n;Xia$sC5@rK873-fEGMT+lC#FRWuV!W*h zOpQ2!c#Lt{Lg&9KjE+){s}mO1urPJRjFgYXTw}px2IQ`BjM172z345<%wWu%94@<6 zB{XBFDG+__Md+&^beu=yyc<%}PNw!=)*3TaJhYDKLqp4}B5<+jG}@7@?FlsEilYG; z|0B>36E1AZ!drP*Jq_k82knfwxOl&v*F3}h#*o_r2HXxMfq&`C_1_rkIuUA$+8zZb%^zZU2*-6woXp<}UjkrfS;YT{56S}F-B8Gl9T1|0v{r!p?n1_xj?~U<5o8P1HZi$hgxASS}|gryZbX@^9APjNjFKO&_~;u3h>@ z`-*FGn+4OQ(q;2n47vukbF`TX85*-Q@-cRa;iXCN!0MkF?Il^8Nv5TGix0)5<>f;{ z+hO#78o~<^aF3_fPTrq+FCuIr5k8%~W-7>dH${lDdjNELj!A6pf)D!EijxHy;rKlO z{|T318~&^F!MXT_J7;juICQCw$o}`z_6~iMFCT{;6T<6E9Su!?9;nhAq1c`Mc5J)u z_+md_v_FEu0)=_jJYq@myCX;K7^Z)4^U(HzY}&@dsr^q{IK>rCp3sXUcW*Kw?hW(K{O_F zL&tkRq1|RM7s1G}D^WmPX?s!0dzK(4qgG9eqr-V$=1Mc>`J#Obp)<@^q8!KSW`;p{ zUxKGrk4F%DUro2?sLKChou@ZK=M(QOw_#ukbFx|PZtkim+NCqKP{6&nzknM!@*%8XTZZqMH+`yjTekj3ory!2$k zm!5ei7g7FqS(hg|sl8`2#)tH+zfKljEFl@L9~krN8vF|&pOELHztF-X$P(uAkRt5u zcn2CjmGv0v@RN_h!KP=W?are~iydFH>v}ysD?fPa8M&cV)UKEHxN@?y%J$l-Jko5o z-l@fG1P4SYNx?mW>)%}d_c^rRY5!|y)t_I*%gFf zBguth52Lu+YcoR+bi!jKFr)ekPxZdO73;nE`YU>_l0FwF{U}iHV8K;;B#YdKi&QpE zAFf%$vzlc-l=$UVefM^2d;|YwfkXifv3{`*8;sN3V^#RLy1Yb@i@V@oGY_MCQAs2_ zdOfZ+S?b%Q^#*k6#r?sNH<3PdgtHPgp`|daG#}>SKAs6_nUvo9tyk&6<)s~0PirON z7zx?zQ+Y_F?S(UA-5KILBQ%>1ChvG%()jZY*BT^zMtc3vbQ*1WOo~Pm@=nW7a$(F4 z8y4OW&N1YXV;}#NBPRV$8J1q;hup5c0U768p%c1PZXoF*=dis#^4fl%QE(KE$|FRe z4XagbhmH*${4`VuLKz9)v~1o-F&7I9W<4CqJTlgfEJx&~#0w3{6gzcm3-Y7R7zU)P z;Q-H^de%VFz4My7fesiX(AaHkn%hF$@zm9lpif}@ugy=g)bp4&S%=r>neH?rbu;;rvspts0aH2lWcJyt*j0XdB~eK**F zm^(bw9@_KSm=}9p!YV;C{sgPpeug&@TgfR}KdN%vP|^denl&S^UMRDKPmA%X z55%Ru2}@7qY3Xa<(^z8!pAel$IcBpD6x4v-O(S)VAYdrrlQiD&GmrOmgqG-+*51(@ zrU@6+(h$r8_TT=2lYj3Bz#n_qU1t^ zX;d{^UWUVllO}pjIuAoLI?K{&gZw2kI44s@)9WepYMlNK?rw&G`rJj!B<5`Hvo&O% zv3liE(@admBi|h{-Gj@uQ#^d@EP4>1>Y5tGWzS{OM+ku(?YAIyhk?5HzQT<}+dk_k zc3tJ?|E^Ir2d35uCxr6x<5Xy6i%s72Ax9x2NRu8>WEgK{hrx#l?L7F>BMj?8gau=I zKmua|?=707m(!u>2gVDP+H1@Xk+*RjYF3~b4X%ZvYC#`c+XvclRYQJ--M{TFW+lxP zTv>T|Y<|i)S?3d}wJ+l8ahG&CN7cm$o_3=#(&nyL!mpWY{z3tTAE{!_jMe)C6%*-7 zaUjZe8R9FWz|MMhD);QbGuvGhhGUXjKSWFUJwqENEw%q%WHj1*5>`|cwA#%%A^v_rub;A zTPsFJe?4C{ziBWFEJu6CFT8zWCd4cDGgzIHVg!?B(2nQEyk^E znjKb}*T#w|utBw))w!zDafec|nXVFHu^Z>s z`^LfgZ=Bw<<>e_2rquT<=}ycJ{)1WR5JSliE7cnN9_oJYK@+okoDi)qY! zW-c;_0G&Uo&A?(ydPymRPSU$(F4@AcT5VA?Naa#^RAI=EV3JXO^LMhH%*xJlxy zh+ei^l8?R!xe4i=SD|Bvw)L$a6D%GO{%y1ii|!$}`9HS}be@y_Frb)hZm7e_u(`w$ z72)I4jqh3__P9N>&QoBvA02TJXV?`E+rhZrG=;lPGMU*DkE&5@W6ox~h!Zw3=E^2c z+B$D9F_Wg6@i!OFkgoZyd+xWg1Y4n46xrcdKTVhKi^A6y_zyycL+)1@eItIW%rY=K zyTQ7iwDRD(R?@H-d<82zJtDiP1F7hQS#vB`W!J5O9fxHl@~*uS;Vr|x%~!b_@3A*i z$L{}1?DG^?kkMK%6cNKWk^>K&IbenWov`Tg&9JoVvv-q#mdNyY3wJ!)u=@12gZl}dsSKFfGWq)g;l zMi(c>=RfaJ=jWj9A5lMDr9EPC)8%O{l1F}EZVYM*cDNc1bwGtmYA3S%$R_o>Ez5D(2rW5jjLj+XP2y{2V65G)5@C^m5 z(0Cg&Rbo8n^Q5Bkv4NmCF+)H2nPoF%W2AH7dxMz+jdkSCC2;DBUF9L=9erZQsNG*4 z8xS|VU+HaBiQtaqzj`x^m9tH}k6S`K^JKd;H{3|~zSI@lol0-Xx~&XSkguvgtI55| z_Un8am?4AjC*6-^+r?W|h){%3M&1NF*P5;yXVw*ko&r9gAii;AFZq7(6~Xyp_P~?G z3+mUdg*~UMF&O%SHI89ixSKI+VxVQS85&&lY5{tULsfxhd?K=*mgjrHAR98$=YCgv z-n*IZ`+Xj!5kl_Tg)k9Q8r`T?TfG_*bu*Ocs4cUvMG%N4zJL!e zoyUDLn=cJ0eE0yKj~jc`?`-{?yz9tOg+vtf+ix$}OFt>L{1UJA%*{xSa3$i+M{+@) zZ^FnQzi5%LM-Q&{X1X13UV2?JTpFnTkFM-C?si^gr&n(&C;xlSW}e@v@%sXN`*ccY z!>{x+1E{UOp@kd($IgAssh7I} z^dt98qT1Jr);`mow8dVs>QN{Z$Ywd~S?gx=%7;A?O$^c1T2AAudXxxC5kKls{eUiT z@J3k+@#fu`o(z%^#!2Ixfg!q?7vXuoCsjx!+aD&H3r@&o0>Ei@R%*P-A@=b zF19g2UHLK}+|n(#UbMBnS$V>^iS&-l4{?ltAr#paB+XCI`j3! zZGQcQANyJCr-&MHSPIe{=^VgnkP#SGg5gWtx#TsMom@VpBiIL#Dq569?P~`xF{f1} zjtRI$1-~epzs($7-JAh$+O4766{`)8$JdUD{qo;&(<#Pz1YECH7(B9MS2DT&V2irk zGk53D;231>fr8UW-$deM8>Y@I$PWoJ2R;|`G5>sYQsV!$|CfAu^25UmSAGfKUCqHh zo`#l@WWI6c%Ll~UP0LtUsAe|JoW}j7WE=JS zdxDn?Q+Ukgzud2f6KU?fS_ssJn)?@N?X#=Hc9Soq2Hex*Z^^v&L9_(2kEuS<;OCD* z)7rz)?~DROIY{(s_`2kwi59{uTW!;e`BLUJ_@VEh#3fTcEq3`nLGyl;HzfI3bb<48 zPUoty>qCfNbUfDjq(qAbmXGklb^pCSCK4@NTn~=+3I;r8zK|3te8*wufzf^lA%dkZ zVbrYRz7|4VO6OM3er?;)+@8>=59T>W`4OqK;e6ZP8{L@bnEnk8MRXkex_lbfI@{fu zy1LYJYDC7whkj`*9oajy4oFr}#UI)7S{-M@+~#>@!%Aoe?rSZCb04vbwSKgQS}e21 zD8XAwRh?&U!+&?xrK4M+XZUX2aJTvS`(wN(yKXBBYi!N2%-4ZJ|8D zb+9XtEkVNM$Z_kRaJ_kJQPLu{2vvLTy~BuTn}qhhqj<++@Ik#F5l! z)oKSKOnB`k=5#aBdpCE)^QJ63#&Ne;l26 zJeB|Z$4Qa0N*|k(WM$9HLn$IF%HAg<9J0@`i%7m2hS<7CsZ4-SrFoV0X`6&4ZR<+%xNyAbzxy{%^7XKtdyf2H9*SEgNYWtNx?+7MSizeH^ zmLWw~LaIkR%t_4e1#f(Qyv}b}$@(9jJ3(|(-vJNK6qX=t?>C}Vk^|#D-(efbolyB; zQ9E1g{rC|h5|=o}_&d3qp0+!YDLNb|KnDsAxM@G9ZG=z}np*0`6gr;9L^yXBK5?I^ zi>lQdnUT(d`@Jk(wgQTACi$pO^j?3A1~o6xHI9;53VKOR3v|i#GZ{H& z_HA=CR?cp~upgvS$*j%wQqdG!u*eWiz2g<(RcPbE6h(X-|}M-^2~PMjVq%8c>68)?zri;n}7#xtFT z)6B$-0p&Fc2VHVO2;$m5Ndi56nkS{opFONVakyFcIrwfES0J70d#n~0j4A;YiIcS3 zP^Qxe&Xbg9Rk+z#v{>u~os9_xaB?p|3h&kRZobL~c@i(R|G;ON%j(JOAP+ zi~IR)M*Xoeq}8p;vyWW2A#~pD`yn?WwMJP9kE_r`3+#JKK>4t?_=C>cLG9ZxLGk>3 zS!1Vno2bozB9_*7K`VI3IeaQCzlkVnx?2kU)kR~Eh%akf*Nb^M?Kk5`IzrwTycABI zrZr5sAIPvW!#*gS|F$^v>c_c|`Wj~;8w<#CNQ)a7(RWcN(5YZ(_c{?M2oAN-);--27l zUi3esB{<&erW#@TeTekG66K&Ev~VAr1{IZa{|b%1ckS%GjZx?$S!%kf@_6e6+?LX+dZc8St8>>n zf%A=mw&EkCLt!OZtdCNUB~G21spb!v6|7b(PjubIU;zF^BCsIvC;Ke~z_r_%Y={&hRPuY@#E zcyFeAQ3e)==#knM_5-=sR}zfXfBSUC`%7Y?#+C2b#xkl|YY}qS2a0Ba{o*Xquf` z)0jAXjUH>nhC~gwS;e9tr7Fc-=)}Z7;R-xUR1O2$iW@#!WgCsHD;l#h#Cj*;;)@w@ zjpu^Ep;zanRJ=B6kI75n8j6RcdXzD6j*k%+SS`A^2e~Z4P_P(?u(1SRt6ub%R?awh zqt40L)%+9X*Y;yk3<7LaKlxyhyK?uq!D{=lSb4(BkLRQHWk3#tN^VQT=9Ifnn?L^- zP1(KtnH8}c-pcpA?ZDZzMHO3-Bax@)$6@+u%katy!}}nAjHp5S8szt>2FvHGOxK!| zQeMTO@=w+L;CaZ$?rKNIK9HAHg!O%Vz`%c%Qq=cRd4a8TVnnE@)t_SvfV6uYjpFz# zMKRBU)PE`Rp7Ge%icsBnls{vomn;MrIF_tqD}G<_!{BW`f#g-GRTi~WvYp0|>;uc) z{TCd(S9b%Z1vU!`sJ|YpGcuFbZ#FOHq(YgqXEtl6%ShB4N z=swl+&X!~-Q2CX2H(}$eQ704gS*W_&>d}JK@|EP$6_Z!k3k9Bi5FtdD)Vk5@`EqDl zKa$->#g#~1^J0MYNbr6t3g!U4rxSJ`ib5yrmiUlrnR2L9t5ZD;u_Jr0{Ji6uDtx16 z7gj(yca71d7ygp*v7?20E_}?w6-Q;S>fselTo6q zYFT#GYI@nnn%2M%3zIzd9aZ zJ3HhZ-+Jk0WpS2tGA##uefFN-@{Epgd^7b8cFHW57}SG{9~C1@gA{hC6C^x+{>?ZX zwf#V{Kx+5qI$PSo2C&J4mA#6BPmlBab&>Cl`u48zvmJAGYb&7)@$?)%Y4$+9wD#7% zHL5S}g3NE&bEjR}n}jd>!OIXKSK{859D|8-em)h+^%w%a;2s{U46PNj@gy}-yQyJh z#PUQje?lcgLmmdPmGsOkZOTK46oZlBV~N#8Nhy}PirS5hU~&VfIMfUv%$*7}1oG^% zaH46Q0X3h_vk{dxA%OGoy<{R(J}!Ei+900>J^RrF7@oA>Jm#%s9>FouM-^(ccx%$r zF!1K_@6)>%45TtcW#%+0UlV=q+FviJS{&+V1GXG374Jn-j(t7ZrrP$<8)Hy$1kB8j(CG z7c2Ly9R=G&93>y6PBU&LYpLDfNPeBPZ+Kk%J}e%0B$g0x@)^Zr>K~va5Sg;>zQM$_ zsSV(q6BEx}3Ao0j(QM3P44n?4Aq32{H$6pQd5KC~8bYY(l2VM-#0ueP*@P1k3|*>y zV|l={5-*4uVa)URFee$567p`EGlKEC7{mInSS@<7wxPn{wN`GC<+guT8nIxFSm5i^ z!Za+51K>57dzdEI%~dc^I$3>pT+DysG6Z7l4E;2D%KYfZCoIB)t_ z=C~y_J$r8}lPpYC)Rpvm-O3@q!~@&oubBS5nz}u<{Qa3M@RnlrrnGtED5&e3KP1?^ zU9b>0c45T1b^D=5It9x4Hs7x8<-xxv`!_DAIn6ZjjdY@FG1SgX6H8kM9Lm{#=^uG4 z1au{-v4D>Dmw=Mv8Nu0syZa&giFd8<3wcYw4E|z&!Sz&KC|_kzuu2L)s!T7;TO9m% zH<~hapK1b>k6$oIamGoa1HL1!O&MD|I0a)5rI!B~ys90(kB}NR5C-lt+gI zta?3jrrE$ShAKy4>eRyi3_>S%9=BL4t1}&s(SUwhf)J#7cx^8>$Unmr2N7PuJti6t z@ERQ6EG05KP4{`Tw#=*IX9hwOLRqgCJfiK0!dJzwfo^xa0W$&MXf~eT`>c_N?${=er~Nnlt&ih_Z}EWuGE3Xm_F56)sR&d@|UloQY|^S|8MU&zYAxyZ{fek!!vkHvY^ zI&?I4$cpP~%+fRu)%A6_8-%F*u*N-({2o2}Zo$Ps1nOO(R3zK3mACQWw{K2= zgZRwuSMDJ2z0}G-svv8U9* zz33sM;M9~R6&ec06mV_(H0Y@Ic8EACT%uYW9<6IC@qDS+)6dg8*H7{RQ}8ni*9G(B z-=#i3R^IsN#7ZDM7-f9ro7n-18wf4)GjRgQR=F)@2uWX&BAF76uDm`A|NA?GBWMf=>X8x2*)8pZ(o5vKxuBa^fLaKFWpDFLBWUL551W*^ zyf&hxo2`%jCV^_Ntbk-iXOEHLFs|Rydo2z}f+MlD!AXIl0mQPPtv)%sY3riUQ?hiZmXO z7MRxlvyfUUtuVV1_-VEdTq!fL(wosAz`Gmv34WXM^PK{~7izA+ua;+5cTs{a6miI(3&>I0f6MRHx!t~VPxhGy%* ziTxZfF*&o`ct*n<_qNRZsW1-KC|1riNj@txV0!DlJkirYy;QwcZdcKE;&CuNf|8DYcJ4^4hXcgfYC-cl_V z7Ji&{mMrn5Y9`4?b;>@lA%u&@S_a0O0^A%RCG5?GW+drdJ?=1j$MITxU>DZ=^wI=i zf`~~+>3;XIDXC?+r|Ld>+LUMSIBL2bJG(bA()?=esw^igDiKC`Tt3)wM|c)dDG(H4 zb^59!eq@_t2-)`%YEiIP{t*8%TJ?v$c)5zfp_AVuTa9>6ea0HWtc=AxPU@j*;?Q!y zOhaX^3|y_E1LskLn$@_8<4bQdzSe1|A5I>94^TDMw!IAfOo;-n97`^(9ft=@Yqhlk zmyXxMG3R^?As)22T5t`373plkhlgn~DX|@-v9NHQ87T zq?NO>wjSOo@>%(Hrf}tsjb+_-cJBpi-p2c*^?A_wuFuP0xxg(Jb8w0s^8qkwCoU^UVQu5=7 zzIfc|kE5WZQn5Af1E6C@0vj%)AuEiV-@`3r6c@3blw-D$MQY$q)1@Z4Gi;mY$4~bu z@sW#4RLor*ndG*Y(Ia?wjq2R#{OdFsuoCPLhxa(9?3P?2qX7R0WY{C`!>V088*$IB zuFZK!JUKVFeHeIa9irk;gHkWEo@c11Yh9=?b9|iG& zZVZneLN$xXxyE=xg97gc%S8=ZAU+wN&I(4Q0d7r_+tNRSEt=NC;`98(j%0MFR(75^ zvblLv*wKDJYW#SP-QlP=FuqJVobxIbp{;5(K6e-Q6u7`LtNugFm+2?h9+M*#y7nvn z8aMOAV_f$DaODGrO1G3wpc{Pb&=pIf;ZU>iRMHhZu;j`R{DWo}*2@Qo^U)Mb+S$yPnIq?q!ZK!b{Mth z7t-;^;oWvGQ+fShB?$=FF&0zG7I5N9Otj^;8Q3Hp#(9%iPDJlq#7cB; z&)o0Ju1g1L>UesLwAn@D5Zo;9-6Kyw7Mg=W^s?9#3y<LhnHQl=lkK>AQuDk!9q)e``d$0? z`Jhix%G_>BwB%;}ZrVY3&!Kih#{LjNLxVm3SoOgN4MuW+Vp#A{9CKxZax4m;9Fkr3WB-L>^=p{Z%=AB2(9KCz zRJIAJaBO^Yu!CA=bMJtn##Ml8rk4JHD(tvxbpwr*&2Jx4jrafOki5<;5Q{Thv~65n zNb=@2cr~emR zUR^pyS9Yl8lCPsLS`j~QlI)lj|Cm!`$n9v+>9xe0XGbMhg{PJ1pMy; zcoZNg+PvPAGmhrl6q16F_VGd}6;;){p0`nt(_*=XP^gF@Ogw9-qWnEi9wPCM4K^V^c@_IbzEqPCK`r4wkyV=} zxq$`GLY%Obi+27Kd1-omzVF%-sLqX|Ce6t{Z<#4K!=Oi~+BY^416CZocEP=W9PZ_; z_PMkXNxP)&z2=L8p~+R=VcW|6l~kjF!_YZ+9H^cHPV;M5bu-=oeFoavV=JUjLSaCUw2rneQ;Dr#|L8cCOMl zmi-4Tns1WCOLDmsR9@&G7T5!B^;hbOfDi@tbtGIVlr33Eu3{4j7O^ z{)eC8slMk&v0ub2e7z?FU=J|*B}st-E7Go*ueXbWjdTmj;_@l{OSz0v6M9#T!EX^+VAO#14KYEb zrGq9AfYjM3*f`)RnW?WI9FN+ns6fts7i@l_fBmRvPFb?dwoP)l+|&HEmSTB-$kzed zCj&3p9v=|5Bu92E87`nF{f`ni%xVdqeJ-tbU{Sr60qc+_H}Sv7>L!EY>p|&Bgqly! zfPdLk{(A&++@8+T0e7T=-De;NNtS7jfg-+iKicBK7|*!$6H1&^jkpKZwo@xS-|Jj` zPbO=D$hA@Xm#jNZ(5_t%1LwcxhD*%Fq<=WdtT-y(K?z;$?>)!#v?w@niMS~v%|@OpYrO6~)_kuyiz zHE~H*0?m1$;lEi^8=p%K3-_nKwff-tMrTtV+mpjOo@ykuNA*Chn5AdqCGTH9nwfFc zxb=twwi!5&GsF_7%n2xwSjFC-0vlz@PG%>JvwkrKf5>sG;VI4z(;QsiS~xYgMVq4S z>-;IlSNhH~#r##u#quim*v$I)5B8-dPQ{8UM!zA$&jt@Oxg<xaRK;Oj8UK<>tuAl!pY*05H0dC-L|DLbN)vhwbhNDpB z6&__~T@@~&E3{F{SZ#07&?VhTPa9g+=>ve-f!&k5 zQ!)OY{XC;D2UMrBpZ`EiB@c$eni`1gJm8OlxN$ZDdhX*FaeRGl6vloZF0V9gu8Oha zZex?!h@AA-R4(|FECbduQ+$Q1brr+~F4a(ZY6W~H6zQQ_&Z=M1mtfFkWZgK%J(vm5 z)J6@{8l$F7FEq+a}} z|5RORQV`znw}qIsvowK+#3w&gAl4Pl6K1*Ba@%0PFALRn;5D{RwmAD1g2LOnE~scO z>@-W);$} zS`eUJ{(W!;e8#lJ`+Vr`et0ltlRICE`Bv@$oi%)m`0LC59+2rFG-pm)4PM{27v49X zu%l!->zmcq<=drLZO`7A{xozn^Qacv$122H2dt|TTk}jhczv8)YvO#t7%fiM3!0GyVuW-%a;R8 zz`+EpP|z>jHW{G8Zw{wS!~Dk-tzzb5-O2QUe2XtEhhuPe0s?5K15O}8A+dCd6ousmud;9X^beU zBzwL*_Ua=TRxOL4Fm!H_Cm2_C23uhw836**_<-N>$X%t#>x2#3uas0wd{Nz$m&@T3 zj5FL(7ctVSfQ8qgcLFN|FOx=>@af}T}#!klk&w=xL9YBA||DJbmIng1r{htu+g zMRQM_P)~05Vj{L)2&ayw>ItF~FDKE%=jo=f@A;^-y^IA5PKfevi1ZHz&cU*r3$JUH z$L3nY0p%xKGjI}(T~3Brn)a8fsw%SeQiQ;ge0|u@#q!?#RRRws8E>@6iU1{9KU-q% zeuhFl+|9A%Z0cy(_4nnKj1hpM6n@Us5Zy>uz zWZm2fgl+g@>{N0HK8rA3G|X$G>YX{QzamM!B)VC;=i9aPPh9*=Rd$uZ96=@-J$5J3 znsZ2Nll9`b8zHq+-%6!g41wo4c%w%KkN<2(+dIa6O7sz)L4F-Db@-9j1zzHgS|xOp+Nn)`EIlIT zj#GrMiCnkPbWX}T?*#8Caxpz1`sugb6h>jSDjsH|TN}|AE@umucOFOc?d;H4VY&B? z&saPc7naY+1$paGG82=(^_4gjdco4lK&gCRSFI_EjGdU)3$RoNGvd`j+8Mde{EEx0 zr^WK@{w^J%`3rD^b1o72q`P`qGGpA%%0e;_x{WNx&Xy`MH%ZH(a_OnO^YimhSR1;D zFdQEKv!Wjplbrhk(-r9$pMO-x*aC)6uo~zOI-$#P-Zx1rY1|VZf}yUgEx{kt_dyRe zSPv@lFYk~mu&b-j3ts9QVCtTEl+@9N(sp{kNh07qe79@Mcz~77=K*j}Q;{K>DY44G zQ#=g$9)WSp+_!<&onrjIqMO|9hi%cRFbxbY19i7(SQ8<40@d3k(8TrWCyrj9Rj30~ zZ^OYjgz})tdcS+uX%q2@eo0e8vIFONTF<^V``{x5rzw`kN?^FSj+sip;qT(oY|I+5 zv?3i!40w<-U3Hh=fi9lCnm&)-Eq`zNt-EzZf9)>(_)uC_pRk_AwQYj~{h(JMPQkso z5rdslM*pDnwY~lmPPZrYy+cdsc(409UfrrRu9y^_g2?p&Zt)h)vVKzO5cM~4_G$>+ z(EtKnJJ>t+gxaZzBKXJ3B$4Hp8F2|^RoQDjcI|WeQQTC}e=qS(AmR$!4}E3itk*y+ zg4eHCKP$<0Ed8NC?%>`|uUW`AME%UD;F4BnfL%Lblf{;ty$9(DC?>ns@f?f}%F@N_ z!9O8$JeNgPK;nACuk4M==L~Le!bAV>3dD@JZ7sCqFiM>xHT~u!EJayH7+^^KmZS;Q zDSL=-c(^p9HJ>^=<;i%*({qRXDT)i?|JVLJVvEW!P844)Qb{S@(opZL8d_7qDpIa$ zMLzwVZy-`{R z4!hZPXsW7=Th0arEO;C@1=6WMea1yi=K8JFYBlZNiTW(N3a~1Q33WJWTeH1B#mRkUD`fXX)9h!^ymHs4ErM)$xwD%U3&ne1O1NJXZvnlF)_*kFtuCGaP^2CR6tm_{c*uv@V`dj&i%0W5D4qdsvR2 z;NA1>d1612I>~iGA*XwX42G;kr|6Bllm!<0h|*cgL)_mY8H5CN`%14MXVZ3j9#6#l zb#QREOBT<{1boW$%7`Hk<51xlv{vb8!KDI)?kE0r_@!cDqND-tuB|>Q$nhnk)J)VY zUnLv2oIgvBm}lu!Moo~{>6$9~tD^&#H`bpwUzu2fRfU6Z`d1WTP1iR2B-T>c3JHaUQGPX*O40^MDJj*^ zJfo4pzEEu;AOBZj%3jOF}DuOdbE5jCi2h(&J>6senGWr%ESGa zYFkAwP75Dv`riNO?z!hl-Z?D#!u$I*MfG~(>cd@=T`@Tq>tTt7wMNn}NBom_gQpW8 zlT#wjsx)O(H6LG+zR8d}&-63_T5sC9)FK)M$9k_Ljr&76I>>HNv0{|;JIhCE{sm)1 zDZx(tn*BE;_H2m>s((UYRRFS$fSHL|33{D=0%*xeFLE`4iQ0d`Jf6dhG;?y;Jyx_S z+unDcAos}c3*ysCezg5*1A9a~7)Mk0&#Jj+cloA`2kQN5`1geNp`e3k_j%>G8|uN7 zJxZK2dQSd`s$rLHNCHC>hdOi(u5fSj=W@dOg;_;x5_3v}RsPX)NyHal$xFvQK1qI6 znjt3jPxbZtHr~CX)A|Kc#vg@RF4P-R17Q{f6*;g7d6R2ZO6HYRJ%O4n_R#_Ad0E?4 zDmEi0JW1I_wyt%Se|-;fy;%#2SQ9E@e^tv4zHRoH@;F7gD8M!F^FK}WN2C<4!&oQ+ zf1y`T&3^e>5}q}{C8TFc=1g^ux>`deyPRxkdIMPyOZsblOY(bW^*Gi1XmYACx%~v% z6xQO=@|ES5r)lUQ2_aYHFAU-5Z0Dqn?i%Pd9S1DU=z0OFRiA$WWB^{CRuY^xsDAIg|G_;La|^v8?Hby`Fs@ zH9l>OgKqt8ODku|EIk?7eg2?SDc*!?-nGD>YYF(}9`87eur~DeL}D8I8C5~<@5ACS zV9KcPPk=?=*2UkK?NJ;HV+C}1-s_JZuJ$w$H0xt;)rwNAB+vxGnL$X4DMqcH&`u_0 zIZ9%h#qq9}1`R`i<3YIjBIJK|p7On#USE+1Asq3^Hzz4)cW2XVhQ`?-{>u$F^n)_{ zBn_?&6vbGpKg|sH{QLsNv#ekjFoc!V(j~p}r+9Iv-|3x1eSwUTj{2>X3U`o~GZ$KL zgpM?#Fh)GNX{zko%I%Pj6*ibX51yVsb&M+y-QkQtIoBw!=H-}9g&Ol{e5)OlJq9>d zfM?->oDnK4lK^GjepDGgsKWgvv980!SIj^ei3}k#n0;t)*3#?` z-f4*=2$0RJ6z)TB!>M4BS8ZcGoYwb>T%Y`%n{vP`HFnkTmmAtDm{{zl(z^zftD1~n z;*WB970G&Q{DiM<$N$ zDvF!Us;P23g2zpVX16OPJn;_Q78P(b`CF?mkwkM|$$)4EzZm4ME0u4XI%--ET$noWt0u-VrlnXer8r53HSt+?23L3vr&&w&XqLMHCpLt% zq0{)7Y^Htww1edRoQKCD`G872?z5ZoJPA#z)NQTAJyJGjTc}U`b~a@|NM4NfuGLn| zE+k39w6M=FOaEaevhi=skKBx?M1V0ZzMt0f+s|BGs=n&iic5QUbvVrtlTsaGx;U_M z=li?gkoeve`DxT~gX*oR>QI?sC5TU~<+K62njfr!Hybfk*OwSMJOC%y?&mCX)9-&ps{_;14*Gejpj7*7o04>4Q&g zqfwU(_W2UQ#n#YO4t2mjAAyzxS-TW!=)?_rWx_Lez}4e)@;neL7c~xDSl-aYVLdWS zAOFgh&XsfIRL9rB@gkV+bvwNB%FVn02@ORlQ-UG`%&wB~Dx4eLUr`@GIcwuAHjd(m{b(&I)Wj_K&NhvZv9RaD(=A za0u;N>%pQ6tH{LNa91ObnJ=wV*$ItOC3q7^3u}!8SMSB<8&$;W!9%jT6VPa-QKRje z8}f(jwRII7+hxy}u*}7a(xFE?qFvD&7p-MA*`We%7edJE15e_d*mO$LjRp+(Q(|V~ z!kMku5|IKG_8fH!)O1d!7 zz6;5qt9#~NT}k^X10dl~l4HA1z89&avYV38fl_`7DFFkXI!0IR)4P9rvpMG%_~&_o zxL^mP3p1U$z5W+#d(Q?j#|iz?yog5eZ^?!wnF}6mZYgS&1*V~m|4P0{E*3->k9y|; z%Z)j@H)0qPgU2Q!Zimm}-eiud*`Ey1)h~l(^g`BWEaTdb>s7X;Y;4NuskZUVX|Em7 zCzk}R-rzlVZ`+40XZ_lW8X$zploB-p79!cQkeVR1CUQu-k#NUa_r+ymx<)?W2`5H& zVbE!?cYxgFq_+XEiQS;EP8al;-(@-`q0Q~5c|V1--HkE~efX;CaA1brPZ3lVC2Z4N zabZCQl+Qqxyk2!{T-cz8RpitrpE{f*u8pN>8 zp?bFTdBOCRXNWDgnL5}PMlo;L2V+7WXEKK?+;Y>xqgR-y@X7R!d;+TtH&;s*OBQdkcK#NXpBloV_)BJu4$Xq_7;C^V;n=Peb;*p@1utg&CW6nLTzH{udXT6;}?Xry-Ze)QEsPCmKD=iMv)+ z0(kqYyz?0~eBpmn$Bu+GWtdid?&odd)}#kQB>&Bz=TX!O4NA%E?i(H|CtD}5%Qul> zJ9+oY{wBn|esjzE9mkcbAEfVRei$mux>>|qcQ7vch(qH1b;N!hcMwPS3OsIbzC;Tl zwKrB29N#CY234NB9{anshk^3^W+X4~F_jS(rVaf5A#c9J`^`No#IQ#zc=2$sIUef#)Bs$<|j7X#%h1rVf85oGi(gh zK=IXU5&Nzx>;r?xDxolHEtpn$|F30hjj8%j)EIhw_QWO6onCZz)3vu*zP;`#8GSwB zrQ^zhmVe>Nb*uHwNu10Z!`i#+lWd|({4^v{#SGX73T6-5ldWc@3CrQ;h zUMhXNBKr(RcfDT_oK+@ZbER}9@L#jK#=W0$o8xAj z6&^|1=ZVk@EkCxt+o<%pxlZB-W}3oUmGcU>c>3+*Z^0#~B=7qFR)3bjBk+|Y41Ly) zE9|2PldKO%7C9MMU_PZT88x()>EL4oq_|AnO+j7gKNp47NgN8^FD4=3H>wwO26tmi zb5a7he@)&dR2|P)TjMidwuV#EqJG;}=c48;MdDGw?)g_8BR62y$K3-w`3Y(_5XEv= zKQZ6DmIDn+){;MBBK}hx74I%A7Z(cx|4qbST)tY~+&im#o=%f$85_HwH?_#0B$JS0 zI;dkzV*J1Yuz=`)U}e=9o|+0>9}*4 zx=sdvO;MkDBvh<<=tWvoERuZ0l_jnrsQe>5#N9Ccii_bF#^*fM4GabNROBy!)n+*h45F@I;Hs&P5H;w)4xuxjGT?awgL_t`8- zQ+t3pk@3lzu}f{Y}65^6E7@`ks=nO%Z(Vf$oI>ATF`VApfYqTpJ1 z4DTmy^oP&z(HCRE7W(L1!ktY2ibmDqi#%qE``06_qj$ERhaxU||MNdrWqKM~t+IyN z12wmPCmZ58hYLriUvhL8LT5M-azEzF_TyHqR;87yn@8;YI538avolYJo#Dn2Q_!m* zml)pswu!O3-6l}RN9B8Kjr-Cm>0RD#O8579JoVC@Pk0rcpO~~4q*njec>06elkUT+ zY8K68>!&HHR}q3V(N&Hb@eHLqf3!(BJ81Vd@6F%4Dfv{@|EA6`Opn&1-uqgZkA^jQ zaCQ4LWUGbCmZQO%@r!?0q81N-&Q{GHNW%3-jxziH4h(s&hij6Tg^+emAko2r-|GrJ ze_LL_P<#ofob#FTx70+&YKB$5gV}4V%J{4lPFa6B&M#7AiWNv;?23vo4&O>$n-p#f z2Y2Dh`B!T?M_tqZ`c5SpJ&l&W^W-L!`q2AClv4e7Kv@Jg#e=?>MFn94dERH4Tn;a& z7^3;kc$dg(pyT&F3%pCiM|a3Efa8BX$eqOZ0ZpT(F#~q{cOC2kOSvdMs(N_(iQp*o z0dsS!Gd!5}w-%rl?C$s*C($(#=L)ntM}BI&6I?xjB0k+QV{|oM?ZB|&_lY(VOmo;7 z$|)=F^1~UTG%4ZnFAYdN2vvK*1yJx$b-E}$4cE+dWoD>iM{<@(`9dI4VU?IOS0_6yekxe4TlfeXU0JLKtm0f5dIt zhe1p0mAGH>=xy&yk5BHrMZ^9(uZ^lSb8Am?J8RDfKYe3B&movx&&Dnb{F0@bY@V!2 zLl87Mj80^>Zsq-IG=S~VBN+?eX9e#wyb#vchN=zme7z;Cc9!5H5craWEy;-gS6&|c zAGCwYpZjU%p;*+#yRY{yP6p_CD#hcmLq{k(rj*6k)J$>H7$lmbo?wZIZ=moYzby*smBkmTCC;m|vPK4leI&H=l%u!=1MwOXK>_!T$n;0}S);RT99 z3{-h}ufJTC(OAj;H31J^SMEs4E?=uY-fz={)*&QEPGtH*`1N^Lvx{S8s$cITdgP9Z ztWXr7N{_hu^pyO-QY#v55kkVRW|vyIz03SG{7p_S<%wGyO*~s zS})LHVoG(s)gL6Q;49*G;p)c&=uX}nVElA;a}%_KzPDz&rlI&_{cYRd^jVejtIrcB zolHIsHlf#*Qnaw6+5?X}fdOPRYqZHCzn1oh&qw4S>lGn8D59)*o=>iZPlbZ3nyht+7>aa4jyuWgpi=E*>DUWuSg4;3biXZ6>wfLD=l;0Y+|*iYF1jR#`+-8hj3wqB zta|!%m^u=LbGG@P{e0lW^xO-{sT{z_zbq#vjOV;@cJAR-6T7T9J92Q`li_)DgKx7y z6~oZc&0tg;86j-C_5=VBF&HU4p`AjdoY9e13VM1hBTo`-KI zfr=rd=g9ik_BNGGM|*GUHq{OTZFIN&@q&x~0DS>L^g^HXHh`TT=I`;3vpSv%69ZO$ z;mwoN&oeXq%zz4F*wQ}(j~>et6ZBQEQZJHk+auDD*pyMMJA=7#Ifv%qF@Au zl3&We`l@Rs9~j^G6!WvqWcB*IWm8p@p|-;t0mXCfANdVcYpqWD1gyp~qwj{f_sA4iIZp3(lxL7mjd7VZ$ud1*f}CZ* zStJyW#%-Qn5Y!MXTxi+}pF|#y&hWg3+?GgTmJ9xIePM%( zs1%75a$|w*Yt;Ut;y^3DtSz5s3RV0hr0PR{&663jQ7BUUTYc=C^PG&*M1ZZq$ez(j zc4MfcMN+^3n2g!iM?5)i7vnJydA*3njjKS&b9s#r=R6Dc2Vuf4t$tv;u=XaO?1Ml> z56epv4D3cVOa(A~@v2$Q#(g3PUi-A;;Y1YtC4!vss9w)QS`bop`W-%vxGbA@Yv3Ej z7`uwfLqlt$qTzU1Q5Y-Z$9ABA3m9#o22oPUvez@M)Zg?+w3#q2uny)JXc~tan11tF zmp!M??i;mGrXQ+z!ruK$RsfqD<&BOiQyk2WAG~!(%|4reJ8^1q8gZ$CoZ3uKS1#ET z&as1{NCwaM9@ja!%fxJzSUrsLIJX4x|Hsj}$20x?alBG=!MeJed`nUx{&o$y#0BvW;}XCSGCCFCf*P02_|Af=*( z#p9*|t0iybol>mv8nfv&FPRWvco1I~7Z}1-bN-S>Co<3a=9<)K6-a8n{94HV;}@#A zb&f*tyUQj+v3l+vf*SF)W|N-GYPV%C=C(EWdH%6X#4pm@G5L8!6Lew=e)+w;LhTu# zd;*wX|Hi???_ROtgYEB22;BH8c%;kQ^)?5Gtvcl-w1@T#!N4VL;zfmT;j>3=2IpSj zT1r2=&*9z;mhy3kLQd`IXCs^%O;d;3p9G%n?rU>WddO+JE(3WLv1Xk%5g8cJ_tA?V z)NF$fP_OwZ8+V&D9E^L_@lDnuzU5u+^0i5hrtgB7B_1N zMRONBM8(;;5}Ai=Ucaw)p}-s>r3)4Qsx?!@3EJYgN}u0U)w+eto$gwlNo)DLi)~n= zfb@<1iD0na-&zJg!oNx`u`M&^{P`BWS7vJV`=*6*hfl3`j`pVxL1!A0OWo6l)^luv zSwjc_UlQy=Rr6Pxj;k-MfBV?$cjZU%RPLIVx7T7g#etV?jNYIv((-CJvH+B6@3T(E z0_9k^!qx@Cw$}JKM_D=l`usd+0U!;S2LP48dA-IaK#H=B07ttW^{tLs3$#<4d#59= z=!dgRjS`O$3C$N>%);MadJ0f)zRQngfaZkuqO2>QUO#qqtg`V+DwQqRH@xsS5;2Ya zXx*r<=zMNqruEh!uHAPtr~k;V7Z1f0q?kgu^-H929q8WaCcKc;ytegL9?4u6mub92Z(_FosB(dZQY0#K@dk;s2}IK?ujQ|?nooggySs8zVzsNT2zO+o@C*-|$35zFj&`}T zAMH;)HA($U#r?geY36IE!cXI?Kg=qZzi<8XDRle%z`yO3Oebbsr~tA+RJ)mF0e_2F zF}5nW_kliLy*thrjz5M}=(IvMJ3$PA34cO_)Pyz^lhT^KYB-Y%rbFqizE5ma=I|q$ zPkGw!th}NyMUBE^F8+ST^|OBiZbl%l#f9!i`rJg8SWk8Ra9h=k_)6HasoiTkGXRj0 zD&+E*Gi^j1?yHZ9?o1!S<5T-uL@e_}Q5)RN6bR4Nd>(C6t72C*C<#NfWh4_&D|`^Y z^D59?Bg+o<=ojFj9Gj?cwC1M82^5I62c^~Mx6TmA!(vWhR@PgPq1atR|A|?P0@x8p zUs3D2U3{ZWv>Rld<_WUgdyyKaQix(IoGnHh0CEUP#oF4dmgIt!^r|kr;F^Y1AjJ~T zCR*+|WtJa3F#0A%~l6CBr#pLst?;n4CZv{H05m0+iWY^2(RcMsxCsUnv zrY>_vVR{8<;PkZWn zLS?s2ACVYCe0Om_U$GX(IacJO9QgLlZn#F4Bw`5XHtO*#pl3?ZcvLnUIbYe*7`$tg zUZfi)&w%4%^y)ot73_Le4nK-)-g^`_!O2uk7bZ6ekoo7^aE6xnH^;zYz&N}jX#W(z z&#c-nTdshxEahwdQYMe%aTZi>g}ryH@is?3cTr=Br7u8?^;p+@nOc+KeQah3Rtdb; ziT4_TsdSHw?X!rinD+GYB%kQ90`cAcj28X0=pKWG%anB9ebwW24x-4=-m7a&L=+Uf zpd>m6qxo-dg#McENyK24?hOWx_Kty9pjoAW|vqiIVCx|1jPS6$U(i?)V}yW*AS(hni@m= z{Wp&o(8CekwWtD*)gnV)pBuKcbPrdMT5I4GUbw_MfNRMQ;i~0HhaF8=?dS=#kX%&|gdgdT1M&7!!q9f@lS=Z;5P+CvwwcEkhKhqM316MHnVnJ^4 zRaK#`-e1ZTZvZM7;IR}cIXO8q(G^4`f=f#Q*Cz|>T&3r)+Zy1ijH+Q#h&tgH9g^@u zwNrN=onmpp&bIWV`6ajX#cQkVVWgQTHC|h`vxkS$@6ht8C4$1AXvOD_jpH}4)v|>u zEjhcmC5|y6sH;3rL9@mpbbV(CwTNk+MW`d_g{159Wbe)k--RqB)f9wZ{kF7BFB>FL z>)_~dRM~wdm)*@XJ^(gjsS-%!-q-cHU52C_s0hhJeXCWisD|@JPKOur+f0e~m7aDp zdKfkldTCDE5>2C~$V#uO57veq;_Ln!c6ojsK$>?0-7pbKM*&9deLkbey_6Yd?_0mg zRgqM5{P$Zx8p&7VK+{=wPKEcAhsI&N+Yg^lN>V-)cphfB|#UA&gWgUlP z9IC8qlapeo#}v8IOEbr1 zm@bBU+u(#P3phRaw)?clZ+%Puc7}Y+shrLRXKGbxZep?i);Blyd4#9k zE4~z~=E-5pzKbXn6f&}*>LZpnt2DJecOe)OHO10a8uyFU|Fb?V;M zQ!^v(>i&wM55`_Y#y)>{i5~C3C1o7@5PBu^)@#)d15aUTNnQTQ2@HEP2ao24BM#Hk z3DxFZRZ9cOu<@Af#CV*<2OUvzg1l|W!>Tepol&A?E3Xl1dh)N##bR`WnRC@BO&xje zh0v{Onh|ln2>sPHOHa@3uYhQWc7XS*8si>3r&*`&e3w5{K>5E|d)&7To>qZt(QPI6 z77K+uwzCeGBg%RBdq&&W5I5yz=np&xEEv6`p;UTNeI4n)$3k>mC}vkGBsoSer38{T z&AHKTrjJl~9tz+oxBB8_F1GegA}!u@4ZQ z=L71Tre?MdzsSR2wB|}+JDe!p{wER&wMGpU0;;0Q&Fm)LK{=T1tKcQWxfGZDBzzie z{dmi+YK-!HattHUGuJWPbwAQC+;w+ZK*b<^#aeKlGN{$Ew;n9{ID|xLPAUEA9g$_i z&?A>S2?TT9OpSYPzb4}HEfsTm9@buaUUt(-4j?3k9my4oBh)nGmkg<18d0Al3qLTq9lYV=8IJK?K^v=;?nrzVKQg=9 z@+UdSQbW{Qh080EpDuTR(|FrWq$g>DM?*zN-f?|p`sfaOIHHy#6K zJI40KGPuF&jwxe`YMVLq?uw;9uM%s5m6LSUVZZCzeQF+r-#SAwhB4n}seankOTy`l z>l5`KUq?)=_%2b&q!mj#^5Oox_c8|Melg+{Bl54S?`FIWuC7ASviO0y^Ca!<-|+oG z#&}H}FVu7TaDVNvA@IlGh_OMgI4YXxH(*q(zW`vIm zFSp=zZO^zeubYOu0uANNPig*cZD0#EFj7%Wjdgr-``_3zwYD0+>C|*^p{#Pv^=8}q zmo;A%rY=xSblR^}6fem&^2^9tvN96ZnC{LDp@kghv9vp4+hcENB^AJ@DjWyZVb3L;z%pvQ|x@T<**hOz=bKB00!cu()wG(k5qVr>Cker;814g zC(}F!X^hm2lT)f_<*8VjhlGFnwk`B98>ORxnA8Z9L@zlW$47QJbhL*lbd1>Bhq7VO zc6~`2s&T{q_gorg?mOB4v`^RPvRS&(JwT+KW=0(lbuDIJz8S-M0hlQ?O}<5V9%FCP^%RkxDk#DLR&kbB99R<5|>sd4{khk*Qsm1)MiVVisD{6}R`s*Ikk z3#6CjRVv#93&xWlX@wkSoi)scL-@Y6S%-jzETG)(t3N#04y5TxmbvNs_^FlwZ5u98 z{Tb4)osG?4OAZI<@;`0x82Dd+e$?e>W4B->Zj3g%Td4H2l z2{9gBuC?4u^5UBOnU|7vO;K%*>*ZCeV?}bVZr%Iu!`^u2S!tbT4F2*}aShr9$EYhL?EtEgE>Tc! zZf4DaX<*I+eYIMgsTzVs$-7fi) z#Ie>|kc_IW!R?-I(c3Kb@9kuG`sakglc%p_~}vPj==zX&`uRLk(> z&&%xmZctC;hZwN17`H_Q2-h?Uf9mz@J;%7jaCPs`s2;{S(4HgZkF~`G;ZWK91r1I7gSb#icC&axc2G#HnikK`!Q*YMW0PQzoTSulFxobZAM+3bCPvUyZ{3 z@QrTgZJb^vzsBj)M+a6LlVVmMS>D?q^Vi?$erWFu9@+{X;qCms|a%iX7!HD)x~u;F}Et*gML5aC)D74{2K4+SE%#sw}qgOsbP%bO_$ zQ?p&0#|hqotDG#WARA-X80~DiA9TYy<%rN@VX4Cg9PR zk2LXCcl@KdqX7%Lxu*e(VC&9Ik=Q|OJp2i}xOgXIJV*iOKotxe?2{hqiMBh#pIc;(K)pY)bWuRHP; znCL^!ll)(+P^C?N`y*xU3I4T{+$~!PnjfFm6vk&1443kxm*-IqM?P+?Sf@2d(jA$# zFOUN5bXdb5bH1a#n$}FFa&f%+@34V*^(Q{HKO-#MQsv9Uux0B3HA5R>@1yfiJ-aym z-S!WvPxI!br&qo?`5rFuNGo?%J~HcWJR*JF{K^*CM>S^Ch#JpC zk;SxIS5^i)8$DV1{qm+}9khMAw)h;Rv0i@rV9Y5>J9hTY0snttOS1eBVI5%~YZ%v3 z;jd1|3K-R#(N^?n($-4U|Q z$N8L^tzC%*eMW0;2L@}IY2K~{BlW*?cp30yg*l`0&M9xb3RK>e{QT?SK)(d{{Vwj3 zp_yiD0rKt-_m4bxY90uYlH0FJmVf1cNR)TCJwq^fn`N~-Nwq@Gr^$t&lpIujS7`dt zM}L7}c8pZr69H3C_?GA1MY~P(l~)t~fq$AM9R;YtBC2ORmQ5@l^S{~Khp$erk1^Zf zhIWhGnj}mWdLvp(#znByipste`WAyC>OuH1QPmfl*-DK3LJ2S$3MlVOJ+#vFABWd3YR zgKm>B(Gpei8NWtjgZ%Z&gkQ%M09(in^j8;-B^|**rte>IZM-=JB9=;sZM$T^2Rw8L z*E@gE>5p}MuKyR}?>$-bd=gLJ@eThs)0LZ9sr25N-88GJ?m`w%fg`OAIQjPDAGZ^h zeg!*bPVty2Js*9Abv}nm*^fMIzjpo#yyDSggH;-?zfhz0o-VeS=ZHfs(WI3*eJB@T zA%klP5_P{Y=lv|E;;U>R%D}*qQT!(*3XZEzRDgsIK73_p;CcQ*8Kx-R(bi|17Z9@%_1vvJo5}_m~GghI10bO z|L9qu8*1Fh5j!w|xhHrwfRBI>`lVE!1&~JpMZ+W+7Bj^*4)zu3=%li) zErB#GQ&ZoJ8Vj|*q{=8%37yRu7gM{Ed-!rS#!ni|p;17CoTMym%(GQVOed6*=49;nB z!1~Mpx}XKf!CG9Pq7>clppiCB4j%{dgp*iC{~RX!_IIXNyEsUUy^ zv>f;6XVSFG@uX44&hTq%sE#uRb)cX6o3R+ml{d-&R78R2s#_T6lmEC!sNp?)(EiU_ zSQt>X4@*#A!aMM}@ z<2=h!K056$J`hg3{}H*VJ*o0IRtp4xRDm}|EI=0R>=*#HpIl#3-@Kg0tNE4g1hAFf z@zYXM$AzHyu0@(>dW5!*cI)1oy1Ul}cE13+Ojte$&RFHLs;X|OG@G6Wm#GQ3EK`sL zFcGrgv)}c-CO+A;>}tN$P_D^WQ;zG!l2s{leytc2EHKy1ZiU}_c&;bMZfLZfB0^Ts z4mg1{%XC^~yaQhmofL!B?>%W3x|kZ;I;=nND&6V?GKbihx$JXbZ<4Fb9Z&Z==Y?|N z+~6tVV#{SXrns@c8W{K}D@3T<>c$?Z77ghZa8OzyvX0mpv1zGUxOR$S2t%11E^cOd z5V{&aOiK0^HN9}lyES%xbX+OGN$}i@NfPa~TV5?Lg5%(kA#c7l-2az?Y5v@V)XUn< z_gRxORxEod28Un*AY|=%?i{7$e)j1Ni>qR()`~pXD%MS#Tk*&Bwc9z|&5pa&3?xg{ z8g^f*{_umouU&%4fJl1d!7%5Glat`}-4*c~I3fZNHf|jo7vxlkQzlPN)2Btq0cP_i zr00H6;2{BS%!A3zIgqxKrOKPvI=$sIpB)$?5pF5QbcpGIs zY77AfxBrtCpietpvc?Ef+Ec=0{V3DaB~s=E4fWFu#qWbKaX-5m`}-JBjAgN{e`ik> zE`?a#>3-P^f>lcQ3-Fchd)vuQ^`ty;IzR_iRU#W%C4~ zxDCf4*c%fQc=!sk#G2TWse(QbNYlyf4ad5|yH z`gjDIWEUd;NSn4i(xSL!S5OHL;h$Yj=1yUs2)?XpZ` zZU&VSN`WQ*Uz>*4AKmd)3$lj9qjNm_R& zC?g75?>XX!z0YLpDGhVT?ivQ;C-W)I1abScSKp{n5$kY-`V(+KJcaRf!xadVm!Bqtn4URO^*jRAK68 zf_uMqd4ZPKEWt;Woy<$Zf1oW|xNS!&RvS9&6Te}10oU^f6T=2hhu3*1YK&IL*>nAT z9$GesiFb%Egxtz^Du2XCda~IWsABzgb(zb$K0cq5$t?5QK2L#s^NqM@`hIiCo;7mK z;@@mL7-*+YZxYSM$34ramm=}kc58Y}`J?H>dcV`s0?tj2_-5-GdDtbXB%#K+hTl~I zTJfpIoPD&$CbayN%+E`!G5o`hrfN=tnwKq)q#Ygt1j+o@i&C~}SQ#V0h#3G9Gh4#RRr#%0;_z_8A#4iLGz=M*P2R0g*;9YkR1qjxA zPgrce3f48L>xHKTyL%3{Ol!_Wj2b((HOZ_cJ1+oVwgiecP&%?_o44-79@~UI~0P)t* z`B5~xb14jb;S+K6(*8`u1!%QKsw`wbd>!n{{mem-7KMm*We zd>=}6w8#y->EJt5D(fdJ#37r~0;B%=T6$;qR-wup^MSy#N+`qu<)}l=B4wc~oxiLb z_aa0vSMgPPsk*n(=F1KgCKH*No~-wK=mKzJmsk1wSEFJmdcQdy1MJZNp5AR>{=$|Tglc^`7abcTjXr$`meYk^fZ zclm}rR9PLi95!U%{q!|h1>Uz?mHE6Cskb3NbzXO@D#Gtr>D7m>r@b$JWYrkgX^h|i zj_qJPkD1;njFYm+uG(`bHPM5 zY*I&N<4xdC93jShb=pk-e{5S4m3cr5qVFitl`AyA@w8<4*CB-Z@Sl>1EH!1?aK=1G z&Gs%QEFW2>?6qJ$UwS;rpdp}kDjYBJ!;LJH^u(%>t6-Smbuy&=?(mYs$PYzfd8dZB zYWM$Nj$gMjs$R%5M!mw86-le+gDvBHlc83W>C3gcGlEq+NX7&8Wez8~~vGY`*0GA2;V);l{qCVnd2 z(D>hZM?`L;7vga2*Ac_4dE#Tj%#zI6Lvk2nxeXOu_Q zv)FM^vBZ(3H7>`+^`+9W&HL*oEtIaH@!0T1*5p4#s`ovC_(?XLBf&rL7W~g)3Mn4Q zw~>el9UI}({AS2Z{SREsfjRh|TzMUAWEG_>21b6gaq!Zo)u(^(2zb@ELP%f(T_vu3LU$j?Lc? zMJq-=ABl`4s>k!e>I8kp(8enSH+^pESe$ozgjh_j#4SfE-^5bcg{%&i0Vu??lYjY? zx;<3R&0;YIATm1^xTF1z^~f{okEhyS9+#h4vRWw#!yK1scjcaLiuB z^bvahD|WlTeWzpuas|CKK5tcPSR-EBx{u@_`1RUeFEn*{#yyRW9=< zdK}0!24XrVBD7=gQu}N=b}FIZ#r)+G9I?`!`le$l^iaZ45Nb*_TlS-))t&Md8B0%5 z)GE@~u{V5yH!$Y}y}Bv=>=-<}(Q(UDw|%tEv=L&LUd_sHPEt>uqCS<0Oy8IUKSy(?=_%}%c7`Li3BS6|_c_U@b*Z*+$U(G<; z#o^P~b`xrgoex#6i%l(sVthZ-p+feE+eUgEr~o7`gzGSNeB=MiL|Mvfm+h{xjGYQ< zTO8|EVl`!g&u*?=hCJ9p%t-m050o31hPclSzndiN(4ve3=4!*&$NqBph2SZR)*0;+ zk+SgpHzeIIMY}!wRpp@L5)J8u35;ut7SE&3dVk@e~y_Eho z_mEeQ-|N^6i}pEPGTTa6ynqa4_h_WdLpk-oGBwHaywQ=5^xWirLLB-ugPOCutzXh~ zD2#X~?K_?=eilQdYvKpT`rEBYd(ywTY(eOCRoR8Iil*87_y!c{fvXgvQi1*iP{!q%#5@I)$$^D3JFYk2Sk>m#6vppG)n53w=p z30}&VQvHavDS`v!LwVRPjZrHQYbh@MVC<%&_h~S!nLV(Ud*RW=kF$%uEAS(pkOW5Mk zx56&pCl-)xOVUJ!;~U6(H}BMxG?i6VpNS9Wr~U!>^vtp@ls{&YC}$awZi_@J>knWS3%!a{Qd9lpq2N`Zs-~yVWkU%|Jg6Dt(e1Bu<`Z@w zuwG*)JZq*wW{(O&;n%SKLc6Kd(Vj7&v$uZ>*{pQhxws*>Z;9@%J`! zaB#P?$W*F>ca=(FX%#5DE72m0D94cW+(Ar>yR?WE_&~YvWVtyWt3)1)IrRJ5%S={&bC^w9W!>nNQ?3Qhhd(5K;ceiM~@W|b7_dq!p?bxA=pZzA-UFaJ=z5(zitP@_ir&fLRY9xL^+lXw<&^jx-s4Z)_FUk zDASON=~MzvS(U=oM3}7av%qtK&x{xEN4A;5))#JcbePDK?%cyiqT{9LvJ#i%3>NP4vYQNX_cAjrYA? zgLY@b!nif_<>69|tpwpbMV}iGKd9Y|+BVJerT0?M^wYhmV=t}l*SXPafrJfgEsyMI z=&u*Hw}pf@BtDOg1h7|u_%U#HD+eX$2A?iDFm>JEj99>q65z8D;!NV+`l~RsZOU#6 zG$d^y;2nYcJ?SNR3ZDBVzij^YgE$H%Ed5Ps=Yj1!%us_OQGt988+=vpo}$k0I2*t} z=w!o}o~5-2I7q9If50H|3bWn!UB)h8Wvk$&e(4T(i<@UEx>~^RE-L6fviA+|NNw+y zH@3Z0U8JrfZMz<>CX(l=o2c^p7xq#Dj04=`_$x;LXN@TyB-T?C`J)bS$Qw3DaW0U8 z-tgRQg3ywlWx^^@8UPKqD&g0~U=g~)Q~Ml7T1WIFhp8jT`e9zLLP?VbQ}6r1ty${i zq2Vu5vzhMac2OV4Rk4OGZA7-TR4r_HM zNL&g!I0ojau(B2R+~Jy>ufP@;!m7;E$7+2!N7A&%mG+j>2g#lUisc*W@2wUs*&%QU zpC+THTV_KFaa(4%y~0WU1@~GEfiJE73aWFjF3yVb`$YlGKFHJ094UKzzxA9J?92B_ zblX}4djE;y+b55ugdg6K_|IjErasodhqvB@hFRT=4z-!&IxILbT+2k<+Nm=jmO|~Q z>7Y;$dCGburG+WOj)e~{SiMO8+oQ_r=%`A5IMZkPe1I8_W?4B3l?cM5(mY&788sb3 zT0H~a`9fwScfJnQ;U)0L#7X9%=PS69PzRTQ-?&_Uu~WJq0#k9hQjJ!7co6vLtN41j zQ_2ou!A#+0PZ#IMG^gwv%t(L7=vsOT<_RWop!;i|>?k24@C{=oel0TKvnp8#i@1(yH-<^F z`|@~PPL^)&$Z%4c>qhs51hjE&bNXwPtZK5)Vc=QFR75`QjV}h3wh5I6TR1t1qo=FB zZ;I6Ad^mQurW6vZH!$NF6sI_6YP)r9nYN){V-G&R!nu3?A8yCp4J??X@P?vxN;Us{*$NJkS*Cm#I?sOf(-ZPf)-{^hU)pMT9Si}1(&yt`PDykRsF2xG8)2!(e110 z-Ac+QGu+UsvG_{EZ(kbk_P<>Qh^O}+H1W_y2L!5rmLlBPVKX@R#<0>vzA$4@Eq6V( zrJXdl$nw;M(7+I;)g1V-`vDbf?w4VyJx_HiY+A-2g3PpaydQWwb&Njr=vIe~3zKAX zv%^PWNN5!%DK$u&9?FfBR4(ZX8B^`=4~V7JRd}X8?Y>?NPNAx(ggi9JX9qh($iV4l z(r1xV=~9qN@XBSEsM24@w{g(Nt;jMN*i&Zr&Ruq7hOiUErg^= za@$)V35fO(mH?Us=##OhS)Nx2Ep+j(2oJPxU_vZ zhU>9G*n87Pa2(Lr(YK0rf7u`W($*&HHu4RZj$bN-U+cvAUf~dlKvoQre97*DCQxA8 zo$+a(x=|oPmfb_Y#Y~lpn^`}QYNpwXWC3c5)FF`&dg4dJmvM%H*+!FQ4ko#Nx8SL& zsv#KQr`v=!XLu&hLc#c95;gPJ0sS&J2On z4rqPlJ^xe{Qxyq57mM5C5y(tQ<=dH@MOB)d$D~fN=V%HkZ?L!soCryX2e6g+AmJg1 zOTc6Dd8l0sqIx|k%h#Mb;3w0Yo@F31f{Nb~gwA&6Wrm zHT1pRE#xFRZBEhuPW}b=Xf{Rjx>356pDTMj19hyM&h#1Htl{4CdB3@D=X*=~Agb01 zRr>}NWuQ**B>k2;qf1{C|Kj9-O|99+Dd&ylk!8r#fTpH~rRIwIYfo^ARaqczHM&woNqpt8jn5yu+BZ%3=}>|A zSNSjSTs@`-rh>tUU3~>7$7lQTTPmN#whbo4Ro!ZrR4-{H*gkCW7o%{9N21PhwD6h2 zFE16xZXV0HzZrF`?jkHbGuZ`66svuKnz|;`3%XF`f6?h?asKu}5J0>5siCP(AlIj* z_}?lKH|vNCAG&?R^rjBK(UGC$@C9R0Ps420p16Kp3*F{&H?+oqzmVKalRwAO)EGUy zV>a1(H~u&UN0kaFt?h(IHp`%BG2h$iMy|Uwk}!T+c9gF=1V0NS`#BE2ZT3*6{QZkR z(od*Xmsc02d_KFCK5tk$tR7-pr$68-mi*fa$k%#3hD8d6Oq2hfd$oIlY3w^ayDV&dUhF^UV{PtTPk z&vFKh4UN^v<}nWyOaialLBkRD--n4SZ*l|6E%B9cLf;C^15ekquaHPHB5~-mn~Wb} zi-A_~R9sE_^dWGAESan_^jv9ml&3ovjMwbB%*ivY#%w7p13g~%Oej|=Et?-+oU7{Rj?yA< zbgDIdd6UpH&ux7ukchY_{wZkUj{SC8z)<0f+7PY-ojg$Kz$QZ5WnOtM{!6I$RmCgu z>m;$D?Q<60AC}6R=;5|J;EE|#>jsg?;2wC3Sa0-7s6>`wn!k`6pqBn#bMlN&*LFub z7!3+eRC_|8i^0F#k(+~(3U4%4VP5m9S`9GDjDDsd2A@Ar*&A}Kx30N9tgCM3Sh}8f zpN#FC4q8g|Svp;KyXcycYPZt}y8A!RIcLI~+NmDaklpIlP~@C>_=u@)sL)jC;{L_y zPu{I&F3xHr34Ju+r$I=Ivp#{k-@lKpm9nsQP8|OWYdti~ z95+_^WE%mB*afp-NEQcw-EJax{iY9|*jG&1SF^=^m9p}8+a~^f#VAjVA+Yls&G7we z0D|9&V*DHR>OV|=Gki3xOd8@`JaK+riRDDad7vlj;_3N0H4#XCBDq4#+2Jb%`6 z`pwfNoqelQ@t50R+hT>ZLb+kwlF+}_#J?ZQD|At4_9TP1XJ>adCshlwVqHJ1IBBe+ z+xpVfW9B3R`4vloN36cVh(kT0cX@St4Q~~D7bKg7TJ01Z>7BXF4#x3O7Y%rkT1Cy) zo4}liFF_Kf_wt2F@F{opKP*nE!mP&@-n$jqrS^qDI(DTVZ6F#DpbgE#%VQG&7eIK% zr3?=Q^%8&NDkor@9j3|YLjh>clhZkVF#@cAHGPPEPp0>?y*jWy)dIMU1x_)=FT z|8?Q0~-0yjT`p+X=_41l+c;-DR@ik-a1ql}niV6XLjx6945|}i6K439mMuMH{$mJ0CGbx7At(k1gzj zC;)x-@p<}=zXSHmY$8WNYd$zlc+qJs*rpbHrw1PL0m&P04!*QDFdTopN6M~Iv!%1# ztp_-8%BLie8}~2Ly*_O*80XS9cYae`CS4q2?fC+@Y(E%8zDLB8_O?qmmjtMWY0vrdu)SVKlBzLMs; z>C7Evd-BUdh1=<(g51xiGyJ(_r|#<(i)2Ei#xt$^A|WLn>jC7xs3@7?tuok z&OY#QhJHLOL^wrWvAXC|3mIzGjHvl6o6-a4>pcu?ZRnlqLgUQ&keS{}+~S#9j*UJG zE?>_{P4Y+&w!G3|cTf^yL5XjU+M(;9dtzl7d66pO{LlLS9u*00%C~&E7sISKcK*S~ z;q#95b@HdYJ2uM2=Mm=f*@_+O$s-!KQ&~0A1mC?r@Tr`T2SNW9N0Qv0sGqoKaHd*i z^_WLohPTCN_gcKS$ePZ*v>*+lqm8BNWz~!o_oCra_6b7|#Z$s_J&R;X-tMEg#M?S& zef<0SKfQ0U^-uYwcC`ntwo^#+ioCu0?~_NsDZhohjw;y8RKUYavf#53qe_9Q@ilig zOc=e*cri*AmI1|Bqr?ZhyIvfX>kH_)Q@AsX!{j95b`1=hAula)~16N{?#aKVrAPB3~fG?i-Peb5tMo#r?usel* zKoFGH&7a#u-h7;DS3?FgFw@NJdlBVZpn=$0F1C=Rqo`h-PmOC*)s_z(1#Tkyxc&gd z7^G#XM;^(;E%#pYur~4FvEpal~)tVFyqW*74pcMrMp8tDT0Lr9d|O z-@#KE6T7@MyRXcqJyZvtyvgD|rqM1q0T5DoCteM2X#F$_T6wQBqbLki6Pcb8=aWp`z2oJ~+#5R&~&ZcRbJ5YhWgGXJ$$(qEJzbToL$?`$^H zsU|D7;G7tckV+MnGKt=FlzFz-&Bd^PwjF;Z-r;4rs8Xy*zL*Gmqdt~9VCF~|z!>7E zr2=%EjN{*Xs;m2E2$}Z0(sb%|_$hg#^P(l2n8?XbMfSpT-bP=R`N^QB}fWC zMdlO7&eLN8gwkRrfc)VQf)x?92;9E#x{)b!{U(r=;`X(3Y%arE!2F}({ zKJnP?m?|D*ijB6mLi~0G|q?*QL8-mDv_mh|qfam(AoDeg}Wh z{b#m7XCPb$PwC&4h>k)3^{*}xM58}o4iEpP$*%Tj`|#uNT6@e4G;}dKWdOygcf?o4 z@F-ZWC(-OM3Z;R=b-N?-g142r2jgS<6N0j0z1v?Q=`IJM$M(_#24|aWkhZtQO5J{W zcY0SsKttK2+JkdPwCqAl6j+aoG;NBL`2^9~OepkjiMDWI2 z%4a2Sv$~$2#Z12vtYM<|Qce&h5!(R|)K+x-AyJ=*x_SnU`>^+64@rjbW?G!Jw7Oe` zD}__AOfN~~m=oERqo4uDiE zw(-fySr>)Q`D3jWl|^RD`Q_gFNn>zdA!lp*p6O=$|1U`x-c%^vtvAtS(JM7%`;F#z z)hLVR-N}O?-FC^vj!ciCjlqQ;xPpWbG4whaNvoujn|<@ZA!D!~hrsnh*kyK{c4F{J zQwLzR1S_l;Zji79DM2E)4r=6_DeFB}diN{WR!4Aa4Sg4`*$a@TbTf8{^>QzNIRm=$TD z^LE>va!(KARGPO|8!06qF0XcY7eqq{NTGvGu%}|Y(*B^J^}yGF#oi# zJilvWLhBSY-gX(gIW*_jGRv%w!!Y}WU)Hz5i-)HxI0@ud*`t{awWWMY)A$>Wc>*Pi zTxV;q&Y=Ey@boYpnC>`!nUZpgo5jUzm-@NpqduO$1eODvQtp9l-rl9R(1@RDYFmD%9Fq^UJ+h~OA?7>fL(F%|2L$v(!tLDw!w?a% z$L(Rj_3Z)IHv?YZ0=Kufv)S@fO6a=$=z1Emeg1B?H&8N`aeg?~lhamAgZwdkoI`gk zZ~S2~E|jOO-AlQLqg7`s-zm7X##4Q)jcmqg8n-H9a-@GcJeA$N1Ew z+-P21hA;Q@P;2+Oc&v7-pKM2e)7I#!=gS0b->JyrU!A1<)Ov&RL3rEbmPxrMXEJKj zlhaoVJbmXvbp;5L3t#y$wKr zOo*XF-zVH3@;l}EPI-@;y8+jC32$x&+~423NjVY_$E1AOp7(>cFqoRs@<;&Jco1525<U&xtWIf$wK`D!S{qq`m=K4A;}CIw9B}9pj(xz*UBJ!#fd7Ac=dvU_j+5zNJ!8xfES6%OP%~8SeXPt5Ckcen7Umqmw8bx z_meb3E_SC5{+G5Se zJyT)ygWeuREjD}EW4BhPR9-53^DMM3%IgQ>+D^+s%cE)Qx6LE_XT=%&qK|L1j11jZ zrl&9OMY$g@J)d!IV;$>co;A3PO)PS+9>&ru zv)b~?$SFrXt%H7~^W?92rd-BB`$yTI)#6*}i<~*XQ>)Q+tKWjy5BV(lk3arM;jjPu zA52UXITA;v7}MqJQemzt|S$m*O&l7W+qaN@Z`Jjh00@ z7x&Fp{nCNTvtapsgfsdqK6?L?@PArKeujUK0{h#-_r54E#SG_$HGdZBKhOzi*CsUm z*Kf0xDCq`c0ojw+F|p#KbNMb~d51 zD97()_{akn@%~RFRp_D z5SeZxUA~@vp^5oHZL=;B1vxuCONplJS7auRG3kXsQ<%~Z5;@RenrI4vWGBF+CLa+I z1!7K@(CeQdnDfKmX8%JrkzTVVzFYkx&S%Oa6X$!&nFBEgy51t)Zjmn6TUwM~i7uBw zU$5!P0Xb8Cxm@USxy+Mo3gMw=%4>^L!HkEA9)oj``CS|<*bY#q)^F6`%P2b|(DQo~ zC3|~k>#HxyOEDQqt({sq6?8oLn`foAC}(%ST&iasOFSDT|Mzf4pT$S~-zD_O&+zXN zU|(BERkl}JzB=4^DQ%4&8}ddbtb=(k)~MIIC?~V7O-au>X7=(T!2&;P^x)3l^OI9=4Xwx6FN5CyAECFtwJ5i2cw=VO>$!@-Y76mn z!nuLEBEAYo*i#dzP&`%)_Wu1b~u7FVt$67XK|$eShr{4_Y5$cbn=d0=3{Uu zw{$E@Wj5P=Lo0S$q3a01wz0<3k>hs6l9edw2VxJjpNR5()agF!R3ikM@6Apsd7;`I{Cpp} ze*Ktw@Aq0YPvWR1d3ICUmLC?P@WL``k*Y-YYE1gK=rJj2Di(S6+a*6RFL-hd#z>M^j&gRc?iHCee`{qZxAf7^)l=4$z9gc2OE86(diuJD z7k3v<>(Ul_`Z!G!ef<9CzxyKhFUz~KBTvMXUey&Tr2Dox&@GqW@@3UA-wPc%q_fo_ z=`+);D>mg`>6G_VYB&2sfo?HSq@}&cftaU!un^KGh4etR@If4Mo~HbewPY*BF#AdA zfp0PL{N#3?^_B;$NjXuv44vnGuWd1>A0Sq;oNn1>@+sMgl;e^6CAuNxHgb7tFI~M5 znJ7}2Cqjxt+Cb!WXq$~py`HN~i%u_NZQk>hXf?`urLrwO8|<^fC-h@AmscfQ%Hy#x z*2D3ArjEW?c>a`ZdjBj(;`#MvJmX`Hb7QpqH%G9x-BuYna!d9ZsBat3-*0PdL}ryy zLpd2`nD;=v^#5cf+R);UCh4{53MJdReM`@h38gYktqoE-GB4#5>v;94QbvX?jg+Cz zW*q?wbNN2Y4*G%Ax6^$D_<=&Nne=zj6}f!Zw%i@TOtIyeTUGBRvgWqV*=9tb}Ao^nezf?zDlb$rA}Y+rQn>gd)b z3EH#Jar*eDpZ@*}(Ff@ic}lMu<3JoKB{?h2A&o-Lr%T+ET+*tH1I0*mi%EyKFD-Ve)n zZCAx>k){6J@_Wmdv+~pWj6HqinA4nO$0b()5T#2?WT5rL*lrw{AHgV_G4*AXxdPj` z*p&7BvX+hW9+ds>=Zs$M^{~{pGyEJ)$^X5y+HBQ(^B5j8KGNp)lGHbk$C?wZGAhVF z8@2zv%Q4h*V~v-{tTJjSCnziFA$C1G*1FGt| z{Fz@H%?X=)^4V65H05`Kd!41+jwPqUJr0PLi5~vKAwG)E+=nDsm5YvG)w8J8-+a&Z@X7!= zOP*5N0Afr@-c2s=C%;~=^!4jky4`MRe4Mkk5ew--oi;t+X{WujIO2;{7q>5K8)}t_y!A}E<&Q;aY(`M> zqo=IbPppqv<(5n+l?lB*9-+k_i;|C)&60izocld;tNa*K^O>*lZMU;G)^A>oKm=Dr zw$P&AqVs4<`v5`8_iPh2JGyO=dwSYeDMKB^^V!nXU_H!hLc;>lEBE9CWxDSe1HqDG zOh;plAA<40tBj>@foS#ni>}k-zswb}n_gILQ)>hJRO>I!Q;{KBXUh8@&lj{O=<)W( zImSrgkH7w57E2T9A!|xAr1P$`ft;7dX9q(XB;B|?ux+CJkTeS&kX3=f>;rT>UqZXV zl3TKDx^@K<2C>FQKW8luIRB9{&X@c%GewR>G2dGr)A*zX`E7o<+vReh-+udzE|*Js z&|8@1cDd5*=!8YNi!(op$hZ1(v9<6#cpGMK@&nWKIFJWZ=lBv+V zD1UQ*?mfTGq;2j7_rGFW{dT=qr)6%4PVxVzA=C1jo(6rg?4e!DK7wVK*MvJP%AXN$ zOvvyZ^MqsNmbRRGx{_lMwTnCwWpk6QxD1(o1Ki0)>zsZno0R`N*8w3?U^Ym-bis zqKkG^epS{eV-mY3r`%7u3}2`8Ye$Nge3$-HuA7H6#X3(6y_1oFW#-ir>@mqg+***M zoujncXw~bXv_V>=xb?%tMEN;T9Ii^j0BW7Cu@yjp&@I^A- z1|i>3M+{*=Fi2S5gLWM0Ewf~6_WRia#z9XgEHlYxVR^ofJQ4D*Eb`E|)#WDn46bfR zk7JJywQ`LYYi+~RNrZe4ey*b~w5a)8E4L{7Qf5`RaHhP5~X^W)#jatu7@^T!FuS6-dACJx~`PfWJf^XQg+NimvEM^&(5fRYtFDa zI<8}V3_0~Nx=*h_Puy`|vqu)-*b)&rmg_v037%ZA$}Jf=eXy`}ESXR$d-nN~fAd{R-jL(PQXg=~%K>j?}dnWrsS(;NFyc;+)=m9!leVHhTN)^aM~ z_wT)o@~y^*c1GBE^SZQbqb)jb_EFl$;QkKxeOQJ%R(e%l-G4#)pcmvK*Kn9@`Az zgF=v}if{c`+YhtulGbgPxKYo!+TJ+7iOgwIh`5_1bD-k4^MZ^4b>a2%da2d-_%5UcL*uy;1!c zW#^t8@wsKpj@Unwx?jQF?;fEgW)^jSpO?LTJ`2wu{eOQ%D$zflp>mASZ?jkbGh@ZV zl2JiU#@y0*EE7C=3trut+$*O-S*slL7NrUnlvXE?2koG!mmpFZz~+Us|}X7vWnyIdd!T3SZaPXSKI>M7GG&*>IF= zJD8Z4cCD0RQJ38#VgE&@<=gK|EK*AHY5G_Vt~KahV)g5cC95I2m^9X;Lmivtl67IR zk0psVyT`Elc$6M{7;8+U7->tW+4M@+uL4{iNNCAQWtiTq86BHZcJ9T9zSnG&$^^@X zB`e8$8DZm|yqdrFK<2`p6k|L+Nn7`#x2<0N?;VMkj+>U@cPr_6QIoe|9dEW$YQrn@ z=jIsWdb-iv8YV*RIORZ>K>ymX1!x?fx%U1T3OyrjCd}_R8n4G{0 zI(TS@497gXC8wXyKItqXE8d=mmZ)J^N5OkigE`1z;bec7DB&G%hP z;z&s@XRp(H$<%$$Va}&$tHdhFnd+Qh?x$YlA``j1lUv#fG_BhQee8=Rb9Ifg+1r$2 z(VJ56_4mcjITr7XcP0Ej2wASzuI&IaXJ7PisidUo1bq+(K9wJjI|eXTGKN}dURiz zmbeFFajZY_yT+1RGQldt_IqiqySG2oD3^YC{jubje*~Tn69r={DK<~d zyp;FAcCGd;Th@Lcq&FLzpg!gihBZX(JLiS|$Im}6_mpdNC7Dptv7lakAW1u*btO-Q zPL$+mOM`Mt*OF=Zs?5+EiRA|s`Y-xekMrx#B<4EyQZ9?~5c0-Vcu2!04vpw~o^52^ zPQC>}NjyRze`^^?Yd?JM;N(H9u|D4KHmp`kZOcAjFS>rIonDUkbT7{E5o3t?R`%9A z%8_<^;)(5eb@e_Tbu3v)eh$IeYLC(CKgRK%JhJUpTI1NmXq%S3{jm+3|FJ!`dURhM zEA9yDVBV9FBM-*O4ok0ag2>iZGUA5i&>T2y4BD{jwxT;g*&mU$*e!L~ zLW1OLoCo%^g!EtEuceVmApKclw!KGJ?4XT3?6tv-3-ZMH(ZP7^?ME;7YDc5{hkbs8 zKf<22EuSsfnPB`zrIDcRo>baj=(LBu^?E^HkG9)t!_x8Uda_ozC2L_%ossAI!$OA;(tOik8`wYe!zzCSz3pP`_oin+d+|(ueRiHH*3aU{5lY(@<`Jx` zZLsmfhgZjw<38R(>c4sODlBsxOWqto?@uJJKRubp9$lT+Nh8N^cpUc(Xn(N}o*e69 zf6t;8zX=btfgV3sea~jGel4%TxYcOU5#%g+X)LUBGb$w9YV*nz9g+T-`@;+1P)?*t#Z`SSaM5t78oZleikJ?9X~dHwUL3A`0I9c3=ywA z4cciX=?5a?DeFB*yI%XA{LwtCUuVNP&G}Cf2ifn8OrM$0GVaLU{h`L&Cwn$~^@Upb zoAbbJeMC9~J)cLUql~ceri`9X5^qZ;a*=6WL1Z`&Frg0O$sfV9_;Dus^6gRmtv%GO z2JYip=R}JR_Nj#R*jG6ESN+ulTb?%NMxd!}6Q=7LZw9|<$rmon_c zj^S&edvK&b*7((A(w?O^g0t+N$zGq2j2#WA|7d#r?}@RcEBjESBo})8Y$2H&Ym-j! z^9)|WM{7QO2ISb~6~^FM>6sgrZb_!gP$v7Spsw0Pc~2@)E4MgewL7dDesdfh_tH36@+0$RBzxM&I?vFLGb#0r z=Ty+i^({N4vLVQWuMP9wxZ&L7{J?Fw5?R((F~<2p+`2Bx@Sbzw{{dDhPpmE8gL?n~ N002ovPDHLkV1n&vxsU(= literal 0 HcmV?d00001 diff --git a/backend/.stage-src-20251004-193018/txm/image copy.png b/backend/.stage-src-20251004-193018/txm/image copy.png new file mode 100644 index 0000000000000000000000000000000000000000..0087316dde3871264568215ba97e94269bc21788 GIT binary patch literal 7773 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�LbqnCoo>fWQa@5rq2z`PSw654c_nA}~h*1Tys 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 literal 0 HcmV?d00001 diff --git a/backend/.stage-src-20251004-193018/txm/requirements.txt b/backend/.stage-src-20251004-193018/txm/requirements.txt new file mode 100644 index 0000000..f03eef9 --- /dev/null +++ b/backend/.stage-src-20251004-193018/txm/requirements.txt @@ -0,0 +1,9 @@ +opencv-python>=4.9.0 +numpy>=1.26.0 +Pillow>=10.0.0 +PyYAML>=6.0.1 +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +python-multipart>=0.0.9 +pyzbar>=0.1.9 + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/db/db.sql b/backend/db/db.sql index 333ecffe84299dd3fb09b56a5372ddfc6c791a14..f67e7752e46db3ac8c818468fd420c2505fa3e3e 100644 GIT binary patch literal 136372 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 A+5i9m literal 27235 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 l!TZt&+MZx~K!##@C-pxA8UY8~_y6`C0MY;e diff --git a/backend/db/init_minimal.sql b/backend/db/init_minimal.sql new file mode 100644 index 0000000..e242a02 --- /dev/null +++ b/backend/db/init_minimal.sql @@ -0,0 +1,55 @@ +-- 最小可运行初始化脚本 +-- 说明:与后端默认上下文保持一致(shopId=1, userId=2),并创建管理员账号 +-- 使用前:请将占位符 替换为你生成的 BCrypt 哈希 + +START TRANSACTION; + +-- 1) 店铺(shops):id=1 +INSERT INTO shops (id, name, status, created_at) +VALUES (1, '默认店铺', 1, NOW()) +ON DUPLICATE KEY UPDATE + name=VALUES(name), status=VALUES(status); + +-- 2) 店主用户(users):id=2,归属 shop_id=1 +-- 说明:phone/email 可按需修改;请确保满足唯一约束 +INSERT INTO users ( + id, shop_id, phone, email, name, role, password_hash, status, is_owner, created_at +) VALUES + (2, 1, '19900000000', NULL, '店主', 'owner', NULL, 1, 1, NOW()) +ON DUPLICATE KEY UPDATE + shop_id=VALUES(shop_id), + name=VALUES(name), + role=VALUES(role), + status=VALUES(status), + is_owner=VALUES(is_owner); + +-- 3) 计量单位(product_units):至少 1 条(示例:个) +INSERT INTO product_units (shop_id, user_id, name, created_at) +VALUES (1, 2, '个', NOW()) +ON DUPLICATE KEY UPDATE + name=VALUES(name); + +-- 4) 商品分类(product_categories):至少 1 条(示例:默认分类) +INSERT INTO product_categories (shop_id, user_id, name, parent_id, sort_order, created_at) +VALUES (1, 2, '默认分类', NULL, 0, NOW()) +ON DUPLICATE KEY UPDATE + name=VALUES(name); + +-- 5) 管理员账号(admins):用于管理员端登录(用户名与手机号二选一即可) +-- 必须提供 BCrypt 哈希,否则登录会报 NO_PASSWORD +-- 将 替换为你生成的哈希(例如明文口令 Admin@12345 对应的哈希) +INSERT INTO admins (username, phone, password_hash, status, created_at) +VALUES ('admin', '13800000000', '', 1, NOW()) +ON DUPLICATE KEY UPDATE + password_hash=VALUES(password_hash), + status=VALUES(status); + +COMMIT; + +-- 验证建议: +-- 1) 后端默认上下文可用(shop_id=1, user_id=2) +-- 2) 管理员可用:使用 username=admin 或 phone=13800000000 + 明文口令(与哈希对应) +-- 3) 商品录入不报“缺少单位/分类” + + + diff --git a/backend/env.example b/backend/env.example new file mode 100644 index 0000000..a13fc57 --- /dev/null +++ b/backend/env.example @@ -0,0 +1,63 @@ +# 数据库配置(生产环境必须设置) +DB_URL= +DB_USER= +DB_PASSWORD= + +# CORS(按需收紧) +CORS_ALLOWED_ORIGINS=* + +# 邮件 SMTP(如需发送邮件验证码/通知) +MAIL_HOST=smtp.qq.com +MAIL_PORT=465 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_PROTOCOL=smtps +MAIL_FROM= +MAIL_SUBJECT_PREFIX=[配件查询] +MAIL_CONNECT_TIMEOUT_MS=5000 +MAIL_READ_TIMEOUT_MS=5000 +MAIL_WRITE_TIMEOUT_MS=5000 + +# 附件占位与直链校验 +ATTACHMENTS_PLACEHOLDER_IMAGE= +ATTACHMENTS_PLACEHOLDER_URL=/api/attachments/placeholder +ATTACHMENTS_URL_SSRF_PROTECTION=true +ATTACHMENTS_URL_ALLOW_PRIVATE_IP=false +ATTACHMENTS_URL_FOLLOW_REDIRECTS=true +ATTACHMENTS_URL_MAX_REDIRECTS=2 +ATTACHMENTS_URL_CONNECT_TIMEOUT_MS=3000 +ATTACHMENTS_URL_READ_TIMEOUT_MS=5000 +ATTACHMENTS_URL_MAX_SIZE_MB=5 +ATTACHMENTS_URL_ALLOWLIST= +ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES=image/jpeg,image/png,image/gif,image/webp,image/svg+xml + +# 本地上传(如启用直传) +ATTACHMENTS_DIR=./data/attachments +ATTACHMENTS_UPLOAD_MAX_SIZE_MB=5 +ATTACHMENTS_UPLOAD_ALLOWED_CONTENT_TYPES=image/jpeg,image/png,image/gif,image/webp,image/svg+xml + +# 应用默认值(可按需覆盖) +APP_DEFAULT_SHOP_ID=1 +APP_DEFAULT_USER_ID=2 +APP_DEFAULT_DICT_SHOP_ID=0 +APP_ACCOUNT_CASH_NAME=现金 +APP_ACCOUNT_BANK_NAME=银行存款 +APP_ACCOUNT_WECHAT_NAME=微信 +APP_ACCOUNT_ALIPAY_NAME=支付宝 + +# 管理端请求头(与前端一致) +ADMIN_AUTH_HEADER=X-Admin-Id + +# Python 条码识别子进程(需要时启用) +PY_BARCODE_ENABLED=false +PY_BARCODE_WORKDIR=./txm +PY_BARCODE_PYTHON=python +PY_BARCODE_APP_MODULE=app.server.main +PY_BARCODE_USE_MODULE=true +PY_BARCODE_HOST=127.0.0.1 +PY_BARCODE_PORT=8000 +PY_BARCODE_HEALTH=/openapi.json +PY_BARCODE_TIMEOUT=20 +PY_BARCODE_LOG= +PY_BARCODE_MAX_UPLOAD_MB=8 + diff --git a/backend/scripts/package-backend.ps1 b/backend/scripts/package-backend.ps1 new file mode 100644 index 0000000..403d22b --- /dev/null +++ b/backend/scripts/package-backend.ps1 @@ -0,0 +1,33 @@ +param( + [string]$OutputPath, + [switch]$IncludeData +) + +$ErrorActionPreference = 'Stop' + +$BackendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$Time = Get-Date -Format 'yyyyMMddHHmmss' +if (-not $OutputPath) { + $OutputPath = Join-Path $BackendRoot ("backend_source_$Time.zip") +} + +$Staging = Join-Path $env:TEMP ("backend_source_$Time") +if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging } +New-Item -ItemType Directory -Path $Staging | Out-Null + +# 构建 Robocopy 排除目录/文件列表 +$excludeDirs = @('target','dist','node_modules','.git','.idea','.vscode','txm\__pycache__','txm\venv','txm\.venv','txm\debug_out','logs') +if (-not $IncludeData) { $excludeDirs += 'data' } +$excludeFiles = @('*.log','*.jar','*.iml','*.pyc','run.log','backend_source_*.zip') + +# 复制源码到临时目录(保留 .mvn 以支持 mvnw) +robocopy $BackendRoot $Staging /MIR /NFL /NDL /NJH /NJS /R:1 /W:1 /XD $excludeDirs /XF $excludeFiles | Out-Null + +if (Test-Path $OutputPath) { Remove-Item -Force $OutputPath } +Compress-Archive -Path (Join-Path $Staging '*') -DestinationPath $OutputPath -Force + +Write-Host "Created: $OutputPath" + +# 清理临时目录 +Remove-Item -Recurse -Force $Staging + diff --git a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java index 9d8a468..7fa443d 100644 --- a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java +++ b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeAutoStarter.java @@ -26,8 +26,17 @@ public class PythonBarcodeAutoStarter implements ApplicationRunner { return; } log.info("启动 Python 条码识别服务..."); - manager.startIfEnabled(); - log.info("Python 条码识别服务已就绪"); + try { + manager.startIfEnabled(); + log.info("Python 条码识别服务已就绪"); + } catch (RuntimeException ex) { + if (properties.isFailOnError()) { + // 让应用启动失败,遵循严格模式 + throw ex; + } + // 宽松模式:记录错误并允许应用继续启动 + log.error("Python 条码识别服务启动失败:{}。已忽略错误并继续启动后端(python.barcode.fail-on-error=false)", ex.getMessage()); + } } @PreDestroy diff --git a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java index 62e18ed..0480ab3 100644 --- a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java +++ b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProcessManager.java @@ -1,5 +1,7 @@ package com.example.demo.barcode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -19,6 +21,7 @@ public class PythonBarcodeProcessManager { private final PythonBarcodeProperties properties; private final RestTemplate restTemplate; private Process process; + private static final Logger log = LoggerFactory.getLogger(PythonBarcodeProcessManager.class); public PythonBarcodeProcessManager(PythonBarcodeProperties properties) { this.properties = properties; @@ -54,6 +57,13 @@ public class PythonBarcodeProcessManager { pb.redirectOutput(new File(properties.getLogFile())); } + if (log.isInfoEnabled()) { + log.info("启动 Python 服务: cmd={} cwd={} host={} port={} healthPath={} timeoutSec={}", + String.join(" ", cmd), + new File(properties.getWorkingDir()).getAbsolutePath(), + properties.getHost(), properties.getPort(), properties.getHealthPath(), properties.getStartupTimeoutSec()); + } + try { process = pb.start(); } catch (IOException e) { @@ -94,13 +104,31 @@ public class PythonBarcodeProcessManager { } public boolean checkHealth() { - String url = String.format("http://%s:%d%s", properties.getHost(), properties.getPort(), properties.getHealthPath()); - try { - restTemplate.getForObject(url, String.class); - return true; - } catch (RestClientException ex) { - return false; + List hosts = new ArrayList<>(); + String h = properties.getHost(); + hosts.add(h); + // 常见误配置容错:服务绑定 0.0.0.0 / :: 时,探活应连回环地址 + if ("0.0.0.0".equals(h) || "localhost".equalsIgnoreCase(h)) { + hosts.add("127.0.0.1"); } + if ("::".equals(h) || "[::]".equals(h) || "::1".equals(h)) { + hosts.add("[::1]"); + } + for (String host : hosts) { + String url = String.format("http://%s:%d%s", host, properties.getPort(), properties.getHealthPath()); + try { + restTemplate.getForObject(url, String.class); + if (log.isDebugEnabled() && !host.equals(h)) { + log.debug("健康检查通过(使用回退主机名):{}", url); + } + return true; + } catch (RestClientException ex) { + if (log.isDebugEnabled()) { + log.debug("健康检查失败: url={} err={}", url, ex.toString()); + } + } + } + return false; } } diff --git a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java index bfd0b3c..980243d 100644 --- a/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java +++ b/backend/src/main/java/com/example/demo/barcode/PythonBarcodeProperties.java @@ -40,6 +40,12 @@ public class PythonBarcodeProperties { /** 上传大小限制(MB),用于 Java 侧预校验,需与 Python 端配置保持一致 */ private int maxUploadMb = 8; + /** + * 当 Python 条码服务启动或健康检查失败时,是否让整个后端应用启动失败。 + * 默认 false:仅记录错误并继续启动,从而不影响其他功能可用性。 + */ + private boolean failOnError = false; + public boolean isEnabled() { return enabled; } @@ -127,6 +133,14 @@ public class PythonBarcodeProperties { public void setMaxUploadMb(int maxUploadMb) { this.maxUploadMb = maxUploadMb; } + + public boolean isFailOnError() { + return failOnError; + } + + public void setFailOnError(boolean failOnError) { + this.failOnError = failOnError; + } } diff --git a/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java b/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java index 0be5f01..2f76564 100644 --- a/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java +++ b/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java @@ -57,7 +57,6 @@ public class ProductDtos { public String spec; public String origin; public Long categoryId; - public Long unitId; public String dedupeKey; public BigDecimal safeMin; public BigDecimal safeMax; diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b8555d2..2dab264 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -7,11 +7,11 @@ spring.application.name=demo # 正确的配置 # 格式为: jdbc:mysql://<主机名>:<端口号>/<数据库名>?参数 # 默认附带 MySQL 8 推荐参数,避免握手/时区/编码问题 -spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci} +spring.datasource.url=${DB_URL} # 用户名和密码直接写值 -spring.datasource.username=${DB_USER:root} -spring.datasource.password=${DB_PASSWORD:TONA1234} +spring.datasource.username=${DB_USER} +spring.datasource.password=${DB_PASSWORD} # JPA 基本配置 spring.jpa.hibernate.ddl-auto=none @@ -106,3 +106,4 @@ python.barcode.health-path=${PY_BARCODE_HEALTH:/openapi.json} python.barcode.startup-timeout-sec=${PY_BARCODE_TIMEOUT:20} python.barcode.log-file=${PY_BARCODE_LOG:} python.barcode.max-upload-mb=${PY_BARCODE_MAX_UPLOAD_MB:8} +python.barcode.fail-on-error=${PY_BARCODE_FAIL_ON_ERROR:false} diff --git a/backend/txm/app/__pycache__/pyzbar_engine.cpython-311.pyc b/backend/txm/app/__pycache__/pyzbar_engine.cpython-311.pyc index 742d34127fcaeda674194b7dd8bbdbe6e5e4bb3e..c746ab59fbc483c0956aa0e5ee2feacd41f4ad6d 100644 GIT binary patch delta 1856 zcmZ`3TWl0n^xmD>otd52KHA-uQoC$h3N=!orBxoqHZ2XIl)A-LmO``bOiP<~OJ)ZP zI!jAJVnZ;bxl#Io$ws7j1)Q|#+Fqg zRjNXoCn78Mh^WdDmtu=J0fjvXsJvH>v5V^joQS`s!#O|+6wUnZjeQ+~SunAZ+(%W#6=v6+H{cW^c%mX?%SL$O_(5T_?KU zZ92E<3Qm`;&y=lC3-uYHJ|)zXcD7QsYRh$M06mRJ3j4@gtha(L)T}YOqaaPS8&r}e znDedD`GQ&(3;Z0gm-1qg0q0_hQfN}aEGJF8t9A(WAU%JP5E{u($UwR)ABjesq>lHk zOwcfRyY4R-{<}lM0VN66E{xr9XtG{L%DM6mhXKbk2aR0HH-nL0UdUq51~zP$nK@|v zQvUGZFnfDJN-zmF!RZV+#(8VlIUD!@E<(a-IKgjAf;adPc}bgeSvAyfit{r6gueiO z`GymsVNZJCY7{vP(GV1YxScC-4XJllx>qkHL&$LHaQ4;&)@v@qrHF;w4w6)m25m8{ z$O*UMHssf^R%^&fpOu|_?JUN*eaIh9Kh8~va#_>i>jvCirg-qk>-|x!PmK)?$J9ej zu=S!QaA*3RfMp8YIdk#$l@ITJcJa^8Pu{-v(YLivDz0m$V|08VIvO`Ah$72`K+yU+OuM4?9|Dh2HKtWs3R;Qk zQO&xsS~KYtYH$-dE>uAs7lajf2l-amIk^{%Er9VKF+x-5mROV#pP8LZi**^XZfaZZ z6Qk^&b5bhM_$w=&l1@k|fA`JM%QwTl>G1wcc>hn~{vX2qsey^qkvGy|BNH~#p+qK> zNV7*X?9mi^G%LHOx!b&Sa`P;Au0B(_Ap7{P^{vl&3lE zY0h|>$@k)YTtYsTR&99^id#CKHH$M@?5GtCn)P?msx6Y68rOb!Ks~4rkH<_~pQ>#n z|48L^)@-yki&)KGKw6c>2Z20fH;;`ghep+AZ4gA50E!Qj4!Lb|fn~5iOTUzQ+M6v| zo}!E4eUN2b3y8-40?C$?gCSd5mZE*x;?k+Etj9OCqwqWcSl}SUTBRcFnGK$)T|mHG zsmd*1HQb=SAz#Q%TZ*tZ**0IEscNze-Y3bL3hYV---lpeu0t>^HyTpiJ>R_mJXz-| cT?d++`e`I?{aj|9mFTQ}BQ?)%pgw{1U(pE1vH$=8 delta 1185 zcmZ`&O=whC6u#%ZnfG_!ou8LzGl{X8B;q7i(!?>PslOp8l{SjmNQ~&neU1(J^UZ6a znciy4`gIH0)CX4XN_`$m!(Bwps8^WAg5bI+N> z_g?Nl{~A5Gu9?dJ z3f7GMsc55xX>}6|;z#hg(u40z4JckR+qwfys6}m)(CoP)G%xPQ@tD+)Gl*5~vlBYlqjKK6;n)tfV7J!r0=(3#Q%GMkMOIR}N za6ko}t}86e!IHXjlxtjHH9n%-lC~wUfQe|)ZWL)C!Gz6C%+fS;g#R1oD2;bE-6#Cr zc$0SSZ<3ivaM~=oIpB7X6iE^P3A}z0FDCE>?E@R%(tWAzN65nQ*#pVgc$F>u{LAH> zchQ~8|1js`3;Q$-VK>{SjmR#%Z*YR`-I1*7GxK?QpRbyE*Z)pp*Tbi*3$plzwK6ZP zQjPGOpYui#Vte5r_QiVTL*ubDjN(G<`_Iox%0amcze&k1l2@#?!geoRFSCIP8(2Jn zd*U~owxyP0zBvC+YsncdJHr)c7@x$~!NL293>?Bzq78Jklbr*hN~>x?sy2M~A@O>p z^SY(kFb>Vm^B-s2Bi@uehMz}a7oJOwwXcycO56Qv8mi9e!w$3*#~!p-db2em@yDjW l0}{pVzxr!Lg2%me_bgjFGx7XuiSd?`O3RCScso;X@IB=@^IZS{ diff --git a/backend/txm/app/pyzbar_engine.py b/backend/txm/app/pyzbar_engine.py index be59190..b842864 100644 --- a/backend/txm/app/pyzbar_engine.py +++ b/backend/txm/app/pyzbar_engine.py @@ -3,7 +3,17 @@ from typing import Dict, List, Tuple import cv2 import numpy as np import logging -from pyzbar.pyzbar import decode, ZBarSymbol + +# 尝试延迟引入 pyzbar;在未安装 zbar 运行库的环境下,保证服务可启动, +# 后续仅使用自研 EAN-13 解码或返回空结果。 +try: + from pyzbar.pyzbar import decode as _pyzbar_decode, ZBarSymbol as _ZBarSymbol + _PYZBAR_AVAILABLE = True +except Exception as _e: # ImportError 或 zbar 动态库缺失 + _PYZBAR_AVAILABLE = False + _PYZBAR_IMPORT_ERR = _e + _pyzbar_decode = None + _ZBarSymbol = None def _prepare_images(gray: np.ndarray, try_invert: bool, rotations: List[int]) -> List[Tuple[np.ndarray, int, bool]]: @@ -29,7 +39,7 @@ def _prepare_images(gray: np.ndarray, try_invert: bool, rotations: List[int]) -> return images -def _collect_supported_symbols() -> List[ZBarSymbol]: +def _collect_supported_symbols() -> List["_ZBarSymbol"]: names = [ "EAN13", "EAN8", @@ -42,16 +52,22 @@ def _collect_supported_symbols() -> List[ZBarSymbol]: "ITF", "I25", ] - symbols: List[ZBarSymbol] = [] + symbols: List["_ZBarSymbol"] = [] + if not _PYZBAR_AVAILABLE: + return symbols for n in names: - if hasattr(ZBarSymbol, n): - symbols.append(getattr(ZBarSymbol, n)) + if hasattr(_ZBarSymbol, n): + symbols.append(getattr(_ZBarSymbol, n)) # 若列表为空,退回由 zbar 自行决定的默认集合 return symbols def decode_with_pyzbar(image_bgr: np.ndarray, try_invert: bool, rotations: List[int]) -> List[Dict[str, str]]: logger = logging.getLogger("pyzbar_engine") + if not _PYZBAR_AVAILABLE or _pyzbar_decode is None: + # 缺少 pyzbar/zbar,返回空集合并提示 + logger.warning("pyzbar 或 zbar 未就绪,已跳过 pyzbar 解码: %s", str(locals().get('_PYZBAR_IMPORT_ERR', ''))) # type: ignore + return [] # 输入 BGR,转灰度 gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) results: List[Dict[str, str]] = [] @@ -59,7 +75,7 @@ def decode_with_pyzbar(image_bgr: np.ndarray, try_invert: bool, rotations: List[ logger.debug("调用 pyzbar: symbols=%d, rotations=%s, try_invert=%s", len(symbols) if symbols else 0, rotations, try_invert) for img, ang, inv in _prepare_images(gray, try_invert=try_invert, rotations=rotations): # pyzbar 要求 8bit 图像 - decoded = decode(img, symbols=symbols or None) + decoded = _pyzbar_decode(img, symbols=symbols or None) for obj in decoded: data = obj.data.decode("utf-8", errors="ignore") typ = obj.type diff --git a/backend/txm/debug_out/txm.log b/backend/txm/debug_out/txm.log index 855f779..0471954 100644 --- a/backend/txm/debug_out/txm.log +++ b/backend/txm/debug_out/txm.log @@ -1,260 +1,14 @@ -2025-09-25 18:11:37.819 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:11:37.822 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:11:37.824 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:11:37.869 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:16.127 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:16.130 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:18:16.132 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:18:16.169 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:27.234 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:27.236 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:18:27.237 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:18:27.259 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:39.703 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:18:39.704 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:18:39.706 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:18:39.725 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:19:45.375 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:19:45.379 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:19:45.381 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:19:45.412 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:25:44.956 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:25:44.960 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:25:44.962 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:25:44.991 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:29:51.305 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:29:51.308 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:29:51.310 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:29:51.343 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:36:45.835 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:36:45.839 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:36:45.842 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:36:45.875 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:57:39.253 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 18:57:39.256 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 18:57:39.258 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 18:57:39.300 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:01:51.499 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:01:51.501 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 19:01:51.503 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 19:01:51.524 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:05:33.804 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:05:33.805 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 19:05:33.807 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 19:05:33.826 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:07:30.632 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:07:30.634 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 19:07:30.636 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 19:07:30.656 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:31:55.243 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:31:55.244 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 19:31:55.246 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 19:31:55.266 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:54:34.920 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 19:54:34.922 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 19:54:34.923 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 19:54:34.943 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 20:01:17.819 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 20:01:17.820 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 20:01:17.822 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 20:01:17.842 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 22:09:07.584 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 22:09:07.588 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 22:09:07.607 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 22:09:07.852 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 22:59:54.417 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-25 22:59:54.419 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-25 22:59:54.420 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-25 22:59:54.441 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 10:39:41.180 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 10:39:41.186 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 10:39:41.203 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 10:39:41.484 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 12:58:15.810 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 12:58:15.815 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 12:58:15.835 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 12:58:16.257 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 14:35:51.088 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 14:35:51.092 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 14:35:51.094 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 14:35:51.130 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 14:55:28.260 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 14:55:28.262 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 14:55:28.263 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 14:55:28.284 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 16:04:25.251 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 16:04:25.255 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 16:04:25.272 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 16:04:25.538 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 21:19:53.746 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 21:19:53.758 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 21:19:53.765 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 21:19:53.862 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 22:56:27.812 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 22:56:27.814 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 22:56:27.815 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 22:56:27.839 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 23:05:41.132 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 23:05:41.133 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 23:05:41.135 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 23:05:41.171 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 23:47:53.925 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-27 23:47:53.926 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-27 23:47:53.928 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-27 23:47:53.957 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 01:24:20.344 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 01:24:20.346 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 01:24:20.363 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 01:24:20.585 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:00:52.538 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:00:52.542 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 22:00:52.560 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 22:00:52.876 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:02:38.951 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:02:38.954 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 22:02:38.956 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 22:02:38.980 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:29:34.466 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:29:34.468 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 22:29:34.476 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 22:29:34.670 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:35:16.776 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:35:16.779 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 22:35:16.780 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 22:35:16.811 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:38:31.014 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 22:38:31.017 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 22:38:31.020 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 22:38:31.048 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:06:13.053 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:06:13.055 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:06:13.057 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:06:13.108 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:15:20.745 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:15:20.749 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:15:20.753 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:15:20.798 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:22:54.219 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:22:54.223 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:22:54.226 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:22:54.264 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:23:45.474 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:23:45.482 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:23:45.490 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:23:45.530 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:41:40.864 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:41:40.869 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:41:40.873 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:41:40.910 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:50:01.655 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-28 23:50:01.658 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-28 23:50:01.661 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-28 23:50:01.696 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 00:03:06.082 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 00:03:06.095 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 00:03:06.106 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 00:03:06.388 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 11:43:33.011 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 11:43:33.016 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 11:43:33.032 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 11:43:33.358 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 11:59:27.297 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 11:59:27.298 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 11:59:27.306 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 11:59:27.418 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:21:49.423 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:21:49.425 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 12:21:49.427 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 12:21:49.456 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:24:55.373 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:24:55.375 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 12:24:55.389 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 12:24:55.608 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:54:19.142 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 12:54:19.144 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 12:54:19.146 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 12:54:19.168 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 13:00:11.536 | DEBUG | python_multipart.multipart | Calling on_part_begin with no data -2025-09-29 13:00:11.536 | DEBUG | python_multipart.multipart | Calling on_header_field with data[38:57] -2025-09-29 13:00:11.536 | DEBUG | python_multipart.multipart | Calling on_header_value with data[59:125] -2025-09-29 13:00:11.536 | DEBUG | python_multipart.multipart | Calling on_header_end with no data -2025-09-29 13:00:11.536 | DEBUG | python_multipart.multipart | Calling on_header_field with data[127:139] -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_header_value with data[141:151] -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_header_end with no data -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_header_field with data[153:167] -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_header_value with data[169:174] -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_header_end with no data -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_headers_finished with no data -2025-09-29 13:00:11.538 | DEBUG | python_multipart.multipart | Calling on_part_data with data[178:69513] -2025-09-29 13:00:11.539 | DEBUG | python_multipart.multipart | Calling on_part_data with data[0:14631] -2025-09-29 13:00:11.539 | DEBUG | python_multipart.multipart | Calling on_part_end with no data -2025-09-29 13:00:11.539 | DEBUG | python_multipart.multipart | Calling on_end with no data -2025-09-29 13:00:11.550 | DEBUG | app.server.main | /api/barcode/scan 收到图像: shape=(1440, 1080, 3), dtype=uint8 -2025-09-29 13:00:11.559 | DEBUG | pyzbar_engine | 调用 pyzbar: symbols=8, rotations=[0, 90, 180, 270], try_invert=True -2025-09-29 13:00:11.802 | DEBUG | pyzbar_engine | pyzbar 返回结果数: 1 -2025-09-29 13:00:11.803 | DEBUG | EAN13Recognizer | pyzbar 返回 1 条结果 -2025-09-29 13:00:11.803 | DEBUG | EAN13Recognizer | 输入尺寸=(1707, 1280, 3), 预处理后尺寸=(1707, 1280, 3) -2025-09-29 13:00:11.804 | DEBUG | pyzbar_engine | 调用 pyzbar: symbols=8, rotations=[0, 90, 180, 270], try_invert=True -2025-09-29 13:00:12.040 | DEBUG | pyzbar_engine | pyzbar 返回结果数: 1 -2025-09-29 13:00:12.040 | DEBUG | EAN13Recognizer | pyzbar 识别到 1 条结果 -2025-09-29 13:00:12.056 | DEBUG | EAN13Recognizer | ROI bbox=(372, 627, 654, 190) -2025-09-29 13:00:12.060 | DEBUG | EAN13Recognizer | 透视矫正后尺寸=(120, 413) -2025-09-29 13:00:12.098 | DEBUG | EAN13Recognizer | 自研 EAN13 解码失败 -2025-09-29 13:00:12.099 | DEBUG | EAN13Recognizer | recognize_any 未命中 EAN13, others=1 -2025-09-29 13:00:12.100 | INFO | app.server.main | /api/barcode/scan 命中非 EAN: type=CODE128, code=84455470401081732071, cost_ms=561.2 -2025-09-29 13:11:28.027 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 13:11:28.029 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 13:11:28.030 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 13:11:28.059 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 19:35:33.086 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 19:35:33.087 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 19:35:33.105 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 19:35:33.458 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:02:28.561 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:02:28.563 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 21:02:28.579 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 21:02:28.828 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:13:29.629 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:13:29.631 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 21:13:29.632 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 21:13:29.653 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:26:27.378 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:26:27.379 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 21:26:27.382 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 21:26:27.404 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:30:25.753 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:30:25.754 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 21:30:25.756 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 21:30:25.775 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:51:40.924 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 21:51:40.926 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 21:51:40.927 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 21:51:40.952 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:10:05.539 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:10:05.541 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 22:10:05.542 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 22:10:05.564 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:16:24.106 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:16:24.107 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 22:16:24.108 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 22:16:24.128 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:40:06.467 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:40:06.469 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 22:40:06.470 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 22:40:06.491 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:47:48.473 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:47:48.474 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 22:47:48.477 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 22:47:48.497 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:51:18.065 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 22:51:18.066 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 22:51:18.068 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 22:51:18.090 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 23:25:18.836 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 23:25:18.837 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 23:25:18.839 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 23:25:18.861 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 23:34:39.749 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} -2025-09-29 23:34:39.750 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 -2025-09-29 23:34:39.751 | DEBUG | asyncio | Using proactor: IocpProactor -2025-09-29 23:34:39.773 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} +2025-10-05 16:06:08.523 | DEBUG | EAN13Recognizer | ü: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} +2025-10-05 16:06:08.525 | INFO | __main__ | FastAPI : 127.0.0.1:8000 +2025-10-05 16:06:08.527 | DEBUG | asyncio | Using proactor: IocpProactor +2025-10-05 16:06:08.548 | DEBUG | EAN13Recognizer | ü: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} +INFO: Started server process [18768] +INFO: Waiting for application startup. +INFO: Application startup complete. +ERROR: [Errno 10048] error while attempting to bind on address ('127.0.0.1', 8000): ͨÿ׽ֵַ(Э/ַ/˿)ֻʹһΡ +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. + DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} +2025-10-05 16:06:08.525 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000 +2025-10-05 16:06:08.527 | DEBUG | asyncio | Using proactor: IocpProactor +2025-10-05 16:06:08.548 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]} diff --git a/doc/admin_development.md b/doc/admin_development.md deleted file mode 100644 index acb3bce..0000000 --- a/doc/admin_development.md +++ /dev/null @@ -1,28 +0,0 @@ -## 管理端开发说明(当前实现) - -### 1. 模块概览 -- **VIP 系统**:`/vip/system` 管理统一售价与充值记录,`/vip/list` 维护店铺会员状态。价格管理页面引用 `/api/admin/vip/system/price`,充值列表读取 `/api/admin/vip/system/recharges`。 -- **公告管理**:`/notice/list` 支持公告检索、创建、编辑、发布与下线,对应接口 `/api/admin/notices` 及其子路径。 -- **咨询回复**:`/consult` 基于 `/api/admin/consults` 完成列表、单次回复、标记已解决。 -- **用户/配件/供应商**:`/users`、`/parts`、`/supplier` 依次调用 `/api/admin/users`、`/api/admin/parts`、`/api/suppliers` 进行检索与维护。 -- **附件与图片**:图片上传统一走 `/api/attachments`,列表页展示时需通过 `withBaseUrl()` 处理相对路径。 - -### 2. 认证与上下文 -- 后端默认启用 `AdminAuthInterceptor`,优先校验 Bearer Token;未登录情况下回退到请求头 `X-Admin-Id` 与 `X-User-Id`。 -- 管理端前端目前仍使用本地存储写入 `USER_ID`/`ADMIN_ID`(详见 `admin/src/api/http.ts`);接入登录页时需改为在登录成功后写入 Token 并移除默认 ID。 -- 所有请求必须附带 `X-Shop-Id`(默认 1,可在本地存储或环境变量覆盖),以匹配租户范围。 - -### 3. 接口要点 -- **VIP 列表**:`GET /api/admin/vips` 已落地;`POST /api/admin/vips` 需要传入 `shopId` 与 `userId` 才能成功创建。 -- **VIP 价格**:`GET/PUT /api/admin/vip/system/price` 会清空 `vip_price` 再写入单条记录,不允许并行修改;可考虑后续改为 `UPDATE` 语句。 -- **VIP 充值**:`GET /api/admin/vip/system/recharges` 支持关键字(姓名/手机号)过滤,默认按创建时间倒序。 -- **公告管理**:创建与更新均支持标签、置顶、时间窗,未填写时间默认为即时生效/长期有效。 -- **附件上传**:上传成功返回 `url` 与元信息,若需要落库请在业务表维持引用;多次上传同一文件会复用记录(按 hash 去重)。 - -### 4. 条码识别接入 -- 管理端不提供扫码入口,仅用户端调用 `/api/barcode/scan`。 -- 后端代理服务会将图片转发至 Python TXM(FastAPI)并返回首个匹配条码,需保持 Java 与 Python 两侧的 `PY_BARCODE_MAX_UPLOAD_MB` 一致。 -- 部署时应通过环境变量配置: - - `PY_BARCODE_HOST`/`PY_BARCODE_PORT`:Python 服务地址,默认 `127.0.0.1:8000`。 - - `PY_BARCODE_MAX_UPLOAD_MB`:上传大小限制,默认 8MB。 -- 小程序端调用扫码接口需将后端域名加入“request 合法域名”并启用 HTTPS。 diff --git a/doc/admin_requirements.md b/doc/admin_requirements.md deleted file mode 100644 index 761b428..0000000 --- a/doc/admin_requirements.md +++ /dev/null @@ -1,26 +0,0 @@ -# 管理端需求文档 - -## 1. 页面结构 -- **VIP 系统**:含价格配置、充值记录与会员列表三块;价格修改需立即生效并同步到前端 `VIP_PRICE` 显示;列表支持手机号模糊检索。 -- **公告管理**:支持公告的新增、编辑、发布、下线、置顶,字段包括标题、内容、标签、有效期、生效状态。 -- **咨询回复**:列出店铺咨询,管理员可单次回复并标记已解决。 -- **用户管理**:展示用户基本信息,支持编辑、启停(黑名单)。 -- **用户配件管理**:适配用户提交的配件数据,允许管理员编辑品牌/型号/规格与图片链接。 -- **供应商管理**:列表检索、创建、编辑供应商信息,含欠款字段展示。 -- **主数据字典**:主单位、主类别维护入口,仅平台管理员可用。 - -## 2. 认证约束 -- 所有接口通过 `AdminAuthInterceptor` 鉴权:优先 Bearer Token,其次 `X-Admin-Id`/`X-User-Id`。 -- 请求必须携带 `X-Shop-Id`,缺省取 1;多租户数据严格隔离。 -- 登录功能正在开发中,当前临时通过本地存储写入管理员 ID。 - -## 3. 功能约束 -- 禁止硬编码配置值,价格、库存等需从后端接口读取。 -- 上传图片统一调用 `/api/attachments`,返回的 `url` 需落库或直接引用。 -- 所有列表接口支持分页(默认 `page=1&size=20`);前端需预留分页扩展点。 -- 删除功能未启用,均以启停或逻辑状态位代替。 - -## 4. 未完成功能 -- 管理端登录页与权限粒度控制尚未上线。 -- 配件审核流仅完成基础编辑,驳回/通过流程待接入。 -- 公告板目前缺少多语言与富文本支持。 diff --git a/doc/database_documentation.md b/doc/database_documentation.md index cdf105f..915f7e2 100644 --- a/doc/database_documentation.md +++ b/doc/database_documentation.md @@ -1,801 +1,291 @@ -## partsinquiry 数据库文档 - -更新日期:2025-09-27(对齐远程线上库结构;提醒:`backend/db/db.sql` 尚未覆盖 VIP 相关表,请勿直接依赖本地脚本) - -说明:本文件根据远程库 mysql.tonaspace.com 中 `partsinquiry` 的实际结构生成,字段/索引/外键信息以线上为准。如需执行结构变更,请通过 MysqlMCP,并在成功后更新此文档和 `backend/db/db.sql`。差异概览: -- ✅ 线上已存在 `vip_users`、`vip_price`、`vip_recharges`、`attachments` 表;`backend/db/db.sql` 仍缺少对应建表语句。 -- ✅ 线上 `admins` 表存储平台管理员,管理端接口通过 `AdminAuthInterceptor` 校验。 -- ❌ `global_skus` 及配件审核体系仅部分表有数据,审批流程仍在试运行阶段。 - - ✅ 新增“模板化配件参数”相关结构:`part_templates`、`part_template_params`;为 `products` 与 `part_submissions` 增加 `template_id` 与 `dedupe_key`,并建立唯一与辅助索引。 - -### shops -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 店铺/租户ID | -| name | VARCHAR(100) | NOT NULL | | 店铺名称 | -| status | TINYINT UNSIGNED | NOT NULL | 1 | 状态:1启用 0停用 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明: -- id: 主键,自增 -- name: 店铺名称 -- status: 店铺状态(1启用/0停用) -- created_at/updated_at: 创建/更新时间 -- deleted_at: 逻辑删除时间 - -### users -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 用户ID | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 所属店铺 | -| phone | VARCHAR(32) | YES | | 手机号 | -| email | VARCHAR(128) | YES | | 邮箱 | -| name | VARCHAR(64) | NOT NULL | | 姓名 | -| role | VARCHAR(32) | NOT NULL | staff | 角色:owner/staff/finance/... | -| password_hash | VARCHAR(255) | YES | | 密码哈希(若采用短信登录可为空) | -| status | TINYINT UNSIGNED | NOT NULL | 1 | 状态:1启用 0停用 | -| is_owner | TINYINT(1) | NOT NULL | 0 | 是否店主 | -(字段已调整:移除 `is_platform_admin`,平台管理员改为独立表 `admins`) -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_users_shop` (`shop_id`) - UNIQUE: `uk_users_phone` (`phone`) - UNIQUE: `ux_users_shop_phone` (`shop_id`,`phone`) - UNIQUE: `ux_users_email` (`email`) - -字段说明: -- shop_id: 归属店铺 -- role: 角色标识字符串 -- is_owner: 是否店主标记 -- 平台管理员:请参见 `admins` 表 -### admins -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 管理员ID | -| username | VARCHAR(64) | NOT NULL | | 登录名/展示名 | -| phone | VARCHAR(32) | YES | | 手机号 | -| password_hash | VARCHAR(255) | YES | | 密码哈希 | -| status | TINYINT UNSIGNED | NOT NULL | 1 | 状态:1启用 0停用 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -说明:平台管理员不隶属店铺,不具备店铺资源(店铺/配件)归属,仅用于管理端功能。 - -**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_admins_username` (`username`) - UNIQUE: `ux_admins_phone` (`phone`) -- 其余同名含义 - -### user_identities -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| provider | ENUM('wechat_mp','wechat_app') | NOT NULL | | 身份提供方:小程序/APP | -| openid | VARCHAR(64) | NOT NULL | | | -| unionid | VARCHAR(64) | YES | | | -| nickname | VARCHAR(64) | YES | | | -| avatar_url | VARCHAR(512) | YES | | | -| last_login_at | DATETIME | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -说明:当前 `user_identities` 仅支持微信身份;短信登录采用 `users.phone` 作为全局唯一身份,不新增 identity 记录。 - -### sms_codes -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| phone | VARCHAR(32) | NOT NULL | | 手机号 | -| scene | VARCHAR(32) | NOT NULL | login | 场景,默认为 login | -| code_hash | CHAR(64) | NOT NULL | | 验证码哈希(SHA-256)| -| salt | CHAR(32) | NOT NULL | | 加盐字符串 | -| expire_at | DATETIME | NOT NULL | | 过期时间 | -| status | TINYINT UNSIGNED | NOT NULL | 0 | 0=active,1=used,2=expired,3=blocked | -| fail_count | TINYINT UNSIGNED | NOT NULL | 0 | 错误次数 | -| ip | VARCHAR(45) | YES | | 发送IP | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_phone_created_at` (`phone`,`created_at`) - KEY: `idx_phone_scene_status` (`phone`,`scene`,`status`) - KEY: `idx_expire_at` (`expire_at`) - KEY: `idx_ip_created_at` (`ip`,`created_at`) - -字段说明: -- provider: wechat_mp(小程序)、wechat_app(APP) -- openid/unionid: 微信身份标识 - -### vip_users -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 店铺 | -| user_id | BIGINT UNSIGNED | NOT NULL | | 用户 | -| is_vip | TINYINT(1) | NOT NULL | 1 | 是否VIP(1是 0否) | -| status | TINYINT UNSIGNED | NOT NULL | 0 | 启用状态:1启用 0停用(审核通过后启用) | -| expire_at | DATETIME | YES | | 到期时间 | -| remark | VARCHAR(255) | YES | | 备注/审核说明 | -| reviewer_id | BIGINT UNSIGNED | YES | | 审核人 | -| reviewed_at | DATETIME | YES | | 审核时间 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_vu_shop_user` (`shop_id`,`user_id`) - KEY: `idx_vu_shop_status` (`shop_id`,`status`) -**Foreign Keys**: - `fk_vu_shop`: `shop_id` → `shops(id)` - `fk_vu_user`: `user_id` → `users(id)` - `fk_vu_reviewer`: `reviewer_id` → `users(id)` - -### vip_price -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| price | DECIMAL(10,2) | NOT NULL | | 全局价格(仅一条记录) | - -说明:该表为全局配置表,仅包含一条记录用于表示当前 VIP 单月价格。 - -### vip_recharges -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 充值记录ID | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 店铺ID | -| user_id | BIGINT UNSIGNED | NOT NULL | | 用户ID | -| price | DECIMAL(10,2) | NOT NULL | | 本次充值价格(元) | -| duration_days | INT | NOT NULL | | 本次续期天数 | -| expire_from | DATETIME | YES | | 生效前到期时间(可空) | -| expire_to | DATETIME | NOT NULL | | 生效后到期时间 | -| channel | VARCHAR(32) | NOT NULL | oneclick | 渠道(oneclick/…) | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_vr_shop` (`shop_id`) - KEY: `idx_vr_user` (`user_id`) -**Foreign Keys**: - `fk_vr_shop`: `shop_id` → `shops(id)` - `fk_vr_user`: `user_id` → `users(id)` -### normal_admin_audits(普通管理员申请/审批审计日志) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 店铺 | -| user_id | BIGINT UNSIGNED | NOT NULL | | 用户 | -| action | ENUM('apply','approve','reject','revoke','expire') | NOT NULL | | 操作类型 | -| remark | VARCHAR(255) | YES | | 备注 | -| operator_admin_id | BIGINT UNSIGNED | YES | | 平台管理员ID(apply时可空) | -| previous_role | VARCHAR(32) | YES | | 变更前角色 | -| new_role | VARCHAR(32) | YES | | 变更后角色 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 创建时间 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_naudit_shop_time` (`shop_id`,`created_at`) - KEY: `idx_naudit_user_time` (`user_id`,`created_at`) -**Foreign Keys**: - `fk_naudit_shop`: `shop_id` → `shops(id)` - `fk_naudit_user`: `user_id` → `users(id)` - `fk_naudit_admin`: `operator_admin_id` → `admins(id)` - - -### wechat_sessions -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| provider | ENUM('wechat_mp','wechat_app') | NOT NULL | | | -| openid | VARCHAR(64) | NOT NULL | | | -| session_key | VARCHAR(128) | NOT NULL | | | -| expires_at | DATETIME | NOT NULL | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明: -- session_key/expires_at: 会话密钥与过期时间 - -### system_parameters -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | 创建/最后修改人 | -| key | VARCHAR(64) | NOT NULL | | 参数键 | -| value | JSON | NOT NULL | | 参数值(JSON) | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明: -- key/value: 键/值(JSON) - -### product_units(含全局字典约定) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(16) | NOT NULL | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(product_units): -- name: 单位名称,如 件/个/箱 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_units_shop` (`shop_id`) - UNIQUE: `ux_units_shop_name` (`shop_id`,`name`) - -全局字典约定(方案A): -- 以 `shop_id=0` 作为“全局字典库”的承载店铺(不对应真实租户)。 -- 单位接口 `/api/product-units` 始终返回 `shop_id=0` 的记录;新建/修改/删除仅写入 `shop_id=0`。 -- 兼容历史:不强制迁移既有数据,各店已有单位保留。 -**Foreign Keys**: - `fk_units_shop`: `shop_id` → `shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_units_user`: `user_id` → `users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - -### global_skus -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| name | VARCHAR(120) | NOT NULL | | SKU名称 | -| brand | VARCHAR(64) | YES | | | -| model | VARCHAR(64) | YES | | | -| spec | VARCHAR(128) | YES | | | -| barcode | VARCHAR(32) | YES | | | -| unit_id | BIGINT UNSIGNED | YES | | | -| tags | JSON | YES | | | -| status | ENUM('published','offline') | NOT NULL | published | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(global_skus): -- name/brand/model/spec/barcode: SKU 基本属性 -- unit_id: 对应的计量单位 -- tags: 结构化标签 JSON -- status: 上架状态(published/offline) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_global_skus_brand_model` (`brand`,`model`) - UNIQUE: `ux_global_skus_barcode` (`barcode`) -**Foreign Keys**: - `fk_globalsku_unit`: `unit_id` → `product_units(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - -### product_categories(含全局字典约定) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(64) | NOT NULL | | | -| parent_id | BIGINT UNSIGNED | YES | | | -| sort_order | INT | NOT NULL | 0 | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(product_categories): -- parent_id: 父分类,可为空 -- sort_order: 排序 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_categories_shop` (`shop_id`) - KEY: `idx_categories_parent` (`parent_id`) - UNIQUE: `ux_categories_shop_name` (`shop_id`,`name`) - -全局字典约定(方案A): -- 类别接口 `/api/product-categories` 始终返回 `shop_id=0` 的记录;新建/修改/删除仅写入 `shop_id=0`。 -- 历史上各店铺已存在的基础项,后续逐步收敛至 `shop_id=0` 字典;为兼容引用,暂不强制删除。 -**Foreign Keys**: - `fk_categories_shop`: `shop_id` → `shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_user`: `user_id` → `users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_parent`: `parent_id` → `product_categories(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - -### products -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(120) | NOT NULL | | 供全文检索 | -| category_id | BIGINT UNSIGNED | YES | | | -| unit_id | (已移除) | | | | -| template_id | BIGINT UNSIGNED | YES | | 关联的模板 | -| brand | VARCHAR(64) | YES | | | -| model | VARCHAR(64) | YES | | | -| spec | VARCHAR(128) | YES | | | -| origin | VARCHAR(64) | YES | | | -| barcode | VARCHAR(32) | YES | | | -| dedupe_key | VARCHAR(512) | YES | | 去重键(规范化后计算) | -| alias | VARCHAR(120) | YES | | | -| is_blacklisted | TINYINT(1) | NOT NULL | 0 | 黑名单标记(管理端可控) | -| description | TEXT | YES | | | -| global_sku_id | BIGINT UNSIGNED | YES | | | -| safe_min | DECIMAL(18,3) | YES | | | -| safe_max | DECIMAL(18,3) | YES | | | -| search_text | TEXT | YES | | 供全文检索的聚合字段(名称/品牌/型号/规格/别名) | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(products): -- category_id/unit_id/global_sku_id: 归属分类/单位/全局SKU -- safe_min/safe_max: 安全库存上下限 -- search_text: 聚合检索字段(触发器维护) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_products_shop` (`shop_id`) - KEY: `idx_products_category` (`category_id`) - KEY: `idx_products_template` (`template_id`) - KEY: `idx_products_dedupe` (`dedupe_key`) - KEY: `idx_products_shop_blacklist` (`shop_id`,`is_blacklisted`) - FULLTEXT: `ft_products_search` (`name`,`brand`,`model`,`spec`,`search_text`) - UNIQUE: `ux_products_shop_barcode` (`shop_id`,`barcode`) - UNIQUE: `ux_products_template_name_model` (`template_id`,`name`,`model`) -**Foreign Keys**: - `fk_products_shop`: `shop_id` → `shops(id)` - `fk_products_user`: `user_id` → `users(id)` - `fk_products_category`: `category_id` → `product_categories(id)` - `fk_products_template`: `template_id` → `part_templates(id)` - `fk_products_globalsku`: `global_skus(id)` - -### part_submissions(配件提交与审核) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 提交ID | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 店铺 | -| user_id | BIGINT UNSIGNED | NOT NULL | | 提交用户 | -| name | VARCHAR(255) | YES | | 配件名称 | -| external_code | VARCHAR(255) | YES | | 外部编码 | -| model_unique | VARCHAR(255) | NOT NULL | | 规范化型号(唯一校验) | -| brand | VARCHAR(64) | YES | | 品牌 | -| spec | VARCHAR(128) | YES | | 规格 | -| unit_id | BIGINT UNSIGNED | YES | | 单位 | -| category_id | BIGINT UNSIGNED | YES | | 分类 | -| template_id | BIGINT UNSIGNED | YES | | 模板 | -| attributes | JSON | YES | | 参数JSON | -| images | JSON | YES | | 图片URL数组JSON | -| size | VARCHAR(64) | YES | | 兼容历史字段 | -| aperture | VARCHAR(64) | YES | | 兼容历史字段 | -| compatible | VARCHAR(255) | YES | | 兼容机型文本 | -| barcode | VARCHAR(32) | YES | | 条码 | -| dedupe_key | VARCHAR(512) | YES | | 去重键 | -| remark | TEXT | YES | | 备注 | -| status | ENUM('pending','approved','rejected') | NOT NULL | | 审核状态 | -| reviewer_id | BIGINT UNSIGNED | YES | | 审核人 | -| product_id | BIGINT UNSIGNED | YES | | 关联生成的商品ID | -| global_sku_id | BIGINT UNSIGNED | YES | | 关联全局SKU | -| reviewed_at | DATETIME | YES | | 审核时间 | -| review_remark | VARCHAR(255) | YES | | 审核备注 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_ps_template` (`template_id`) - KEY: `idx_ps_dedupe` (`dedupe_key`) - UNIQUE: `ux_ps_template_name_model` (`template_id`,`name`,`model_unique`) -**Foreign Keys**: - `fk_ps_template`: `template_id` → `part_templates(id)` - -### product_aliases -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| alias | VARCHAR(120) | NOT NULL | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(product_aliases): -- alias: 商品别名(同义词) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_alias_product` (`product_id`) - UNIQUE: `ux_product_alias` (`product_id`,`alias`) -**Foreign Keys**: - `fk_alias_shop`: `shop_id` → `shops(id)` - `fk_alias_user`: `user_id` → `users(id)` - `fk_alias_product`: `product_id` → `products(id)` - -### product_images -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| url | VARCHAR(512) | NOT NULL | | | -| hash | VARCHAR(64) | YES | | 内容哈希(去重) | -| sort_order | INT | NOT NULL | 0 | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明(product_images): -- url/hash: 图片地址/内容哈希 -- sort_order: 展示顺序 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_images_product` (`product_id`) - UNIQUE: `ux_product_image_hash` (`product_id`,`hash`) -**Foreign Keys**: - `fk_pimg_shop`: `shop_id` → `shops(id)` - `fk_pimg_user`: `user_id` → `users(id)` - `fk_pimg_product`: `product_id` → `products(id)` ON DELETE CASCADE - -### part_templates(配件模板) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 模板ID | -| category_id | BIGINT UNSIGNED | NOT NULL | | 绑定分类 | -| name | VARCHAR(120) | NOT NULL | | 配件名 | -| model_rule | VARCHAR(255) | YES | | 型号规则(说明/正则,可空) | -| status | TINYINT UNSIGNED | NOT NULL | 1 | 1启用 0停用 | -| created_by_admin_id | BIGINT UNSIGNED | YES | | 创建管理员 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | 软删标记 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_pt_category` (`category_id`) - KEY: `idx_pt_status` (`status`) - KEY: `idx_pt_admin` (`created_by_admin_id`) - KEY: `idx_part_templates_deleted_at` (`deleted_at`) -**Foreign Keys**: - `fk_pt_category`: `category_id` → `product_categories(id)` - `fk_pt_admin`: `created_by_admin_id` → `admins(id)` - -### part_template_params(模板参数字段) -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| template_id | BIGINT UNSIGNED | NOT NULL | | 所属模板 | -| field_key | VARCHAR(64) | NOT NULL | | 参数键(字母/下划线) | -| field_label | VARCHAR(120) | NOT NULL | | 参数名(展示) | -| type | ENUM('string','number','boolean','enum','date') | NOT NULL | | 参数类型 | -| required | TINYINT(1) | NOT NULL | 0 | 是否必填 | -| unit | VARCHAR(32) | YES | | 单位(文本) | -| enum_options | JSON | YES | | 枚举项(type=enum) | -| searchable | TINYINT(1) | NOT NULL | 0 | 参与检索 | -| fuzzy_searchable | TINYINT(1) | NOT NULL | 0 | 可模糊查询(仅数值型) | -| fuzzy_tolerance | DECIMAL(18,6) | YES | | 容差;NULL 使用平台默认 | -| dedupe_participate | TINYINT(1) | NOT NULL | 0 | 参与去重键 | -| sort_order | INT | NOT NULL | 0 | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_ptp_template` (`template_id`) - KEY: `idx_ptp_sort` (`template_id`,`sort_order`) - UNIQUE: `ux_ptp_field_key` (`template_id`,`field_key`) -**Foreign Keys**: - `fk_ptp_template`: `template_id` → `part_templates(id)` ON DELETE CASCADE - -### product_prices -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| purchase_price | DECIMAL(18,2) | NOT NULL | 0.00 | | -| retail_price | DECIMAL(18,2) | NOT NULL | 0.00 | | -| wholesale_price | DECIMAL(18,2) | NOT NULL | 0.00 | | -| big_client_price | DECIMAL(18,2) | NOT NULL | 0.00 | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明(product_prices): -- purchase_price: 当前进价(用于近似毛利) -- retail/wholesale/big_client_price: 售价列 - -**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_prices_shop` (`shop_id`) -**Foreign Keys**: - `fk_prices_product`: `product_id` → `products(id)` ON DELETE CASCADE - `fk_prices_shop`: `shop_id` → `shops(id)` - `fk_prices_user`: `user_id` → `users(id)` - -### inventories -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| quantity | DECIMAL(18,3) | NOT NULL | 0.000 | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明(inventories): -- quantity: 当前库存数量(按商品一行聚合) - -**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_inventories_shop` (`shop_id`) -**Foreign Keys**: - `fk_inv_product`: `product_id` → `products(id)` ON DELETE CASCADE - `fk_inv_shop`: `shop_id` → `shops(id)` - `fk_inv_user`: `user_id` → `users(id)` - -### customers -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(120) | NOT NULL | | | -| phone | VARCHAR(32) | YES | | 座机 | -| address | VARCHAR(255) | YES | | 送货地址 | -| mobile | VARCHAR(32) | YES | | 手机 | -| contact_name | VARCHAR(64) | YES | | 联系人 | -| price_level | ENUM('零售价','批发价','大单报价') | NOT NULL | 零售价 | 默认售价列(中文存储) | -| status | TINYINT UNSIGNED | NOT NULL | 1 | | -| ar_opening | DECIMAL(18,2) | NOT NULL | 0.00 | 期初应收 | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(customers): -- price_level: 默认售价列(中文存储:零售价/批发价/大单报价) -- ar_opening: 期初应收 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_customers_shop` (`shop_id`) - KEY: `idx_customers_phone` (`phone`) - KEY: `idx_customers_mobile` (`mobile`) -**Foreign Keys**: - `fk_customers_shop`: `shop_id` → `shops(id)` - `fk_customers_user`: `user_id` → `users(id)` - -### suppliers -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(120) | NOT NULL | | | -| contact_name | VARCHAR(64) | YES | | 联系人 | -| mobile | VARCHAR(32) | YES | | 手机 | -| phone | VARCHAR(32) | YES | | 电话 | -| address | VARCHAR(255) | YES | | 经营地址 | -| status | TINYINT UNSIGNED | NOT NULL | 1 | | -| ap_opening | DECIMAL(18,2) | NOT NULL | 0.00 | 期初应付 | -| ap_payable | DECIMAL(18,2) | NOT NULL | 0.00 | 当前应付(实时维护) | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(suppliers): -- ap_opening/ap_payable: 期初应付/当前应付 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_suppliers_shop` (`shop_id`) - KEY: `idx_suppliers_phone` (`phone`) - KEY: `idx_suppliers_mobile` (`mobile`) -**Foreign Keys**: - `fk_suppliers_shop`: `shop_id` → `shops(id)` - `fk_suppliers_user`: `user_id` → `users(id)` - -### accounts -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| name | VARCHAR(64) | NOT NULL | | | -| type | ENUM('cash','bank','alipay','wechat','other') | NOT NULL | cash | | -| bank_name | VARCHAR(64) | YES | | 银行名称(type=bank 可用) | -| bank_account | VARCHAR(64) | YES | | 银行账号(type=bank 可用) | -| balance | DECIMAL(18,2) | NOT NULL | 0.00 | | -| status | TINYINT UNSIGNED | NOT NULL | 1 | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(accounts): -- type: 账户类型(cash/bank/alipay/wechat/other) -- bank_name/bank_account: 银行账户信息(type=bank 时使用) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_accounts_shop` (`shop_id`) - UNIQUE: `ux_accounts_shop_name` (`shop_id`,`name`) -**Foreign Keys**: - `fk_accounts_shop`: `shop_id` → `shops(id)` - `fk_accounts_user`: `user_id` → `users(id)` - -### sales_orders -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | 创建人 | -| customer_id | BIGINT UNSIGNED | YES | | | -| order_no | VARCHAR(32) | NOT NULL | | | -| order_time | DATETIME | NOT NULL | | | -| status | ENUM('draft','approved','returned','void') | NOT NULL | draft | | -| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 应收合计 | -| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已收合计 | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(sales_orders): -- status: 单据状态(draft/approved/returned/void) -- amount/paid_amount: 应收/已收合计 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sales_shop_time` (`shop_id`,`order_time`) - KEY: `idx_sales_customer` (`customer_id`) - UNIQUE: `ux_sales_order_no` (`shop_id`,`order_no`) -**Foreign Keys**: - `fk_sales_shop`: `shop_id` → `shops(id)` - `fk_sales_user`: `user_id` → `users(id)` - `fk_sales_customer`: `customer_id` → `customers(id)` - -### sales_order_items -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| order_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| quantity | DECIMAL(18,3) | NOT NULL | | | -| unit_price | DECIMAL(18,2) | NOT NULL | | | -| discount_rate | DECIMAL(5,2) | NOT NULL | 0.00 | 折扣百分比0-100 | -| cost_price | DECIMAL(18,2) | NOT NULL | 0.00 | 记录生成单据时的成本单价 | -| cost_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 成本金额 = 数量×成本单价 | -| amount | DECIMAL(18,2) | NOT NULL | | | - -字段说明(sales_order_items): -- quantity/unit_price/discount_rate/amount: 数量/单价/折扣%/行金额 -- cost_price/cost_amount: 记录生成单据时的成本(用于利润分析) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_soi_order` (`order_id`) - KEY: `idx_soi_product` (`product_id`) -**Foreign Keys**: - `fk_soi_order`: `order_id` → `sales_orders(id)` ON DELETE CASCADE - `fk_soi_product`: `product_id` → `products(id)` - -### purchase_orders -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| supplier_id | BIGINT UNSIGNED | YES | | | -| order_no | VARCHAR(32) | NOT NULL | | | -| order_time | DATETIME | NOT NULL | | | -| status | ENUM('draft','approved','void','returned') | NOT NULL | draft | | -| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 应付合计 | -| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已付合计 | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(purchase_orders/purchase_order_items): -- 与销售单结构类似,含应付与明细 - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_purchase_shop_time` (`shop_id`,`order_time`) - KEY: `idx_purchase_supplier` (`supplier_id`) - UNIQUE: `ux_purchase_order_no` (`shop_id`,`order_no`) -**Foreign Keys**: - `fk_purchase_shop`: `shop_id` → `shops(id)` - `fk_purchase_user`: `user_id` → `users(id)` - `fk_purchase_supplier`: `supplier_id` → `suppliers(id)` - -### purchase_order_items -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| order_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| quantity | DECIMAL(18,3) | NOT NULL | | | -| unit_price | DECIMAL(18,2) | NOT NULL | | | -| amount | DECIMAL(18,2) | NOT NULL | | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_poi_order` (`order_id`) - KEY: `idx_poi_product` (`product_id`) -**Foreign Keys**: - `fk_poi_order`: `order_id` → `purchase_orders(id)` ON DELETE CASCADE - `fk_poi_product`: `product_id` → `products(id)` - -### payments -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| biz_type | ENUM('sale','purchase','other') | NOT NULL | | | -| biz_id | BIGINT UNSIGNED | YES | | 业务表ID:sales_orders/purchase_orders/other_transactions | -| account_id | BIGINT UNSIGNED | NOT NULL | | | -| direction | ENUM('in','out') | NOT NULL | | 收款/付款 | -| amount | DECIMAL(18,2) | NOT NULL | | | -| pay_time | DATETIME | NOT NULL | | | -| remark | VARCHAR(255) | YES | | | -| category | VARCHAR(64) | YES | | 分类 key(主要用于其他收支) | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -字段说明(payments): -- biz_type/biz_id: 业务来源及关联主键 -- direction: in 收款 / out 付款 -- account_id: 使用的结算账户 -- category: 分类 key(主要用于其他收支;用于台账明细展示与统计) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_payments_shop_time` (`shop_id`,`pay_time`) - KEY: `idx_payments_biz` (`biz_type`,`biz_id`) -**Foreign Keys**: - `fk_payments_shop`: `shop_id` → `shops(id)` - `fk_payments_user`: `user_id` → `users(id)` - `fk_payments_account`: `account_id` → `accounts(id)` - -### inventory_movements -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| source_type | VARCHAR(32) | NOT NULL | | 来源:sale/purchase/sale_return/purchase_return/adjust/audit | -| source_id | BIGINT UNSIGNED | YES | | 来源表ID(可空) | -| qty_delta | DECIMAL(18,3) | NOT NULL | | 数量增减,出库为负,入库为正 | -| amount_delta | DECIMAL(18,2) | YES | | 金额增减(可空) | -| cost_price | DECIMAL(18,2) | YES | | 业务发生时的成本单价(可空) | -| cost_amount | DECIMAL(18,2) | YES | | 成本金额(可空) | -| reason | VARCHAR(64) | YES | | 原因/类别 | -| tx_time | DATETIME | NOT NULL | | 业务时间 | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_im_shop_time` (`shop_id`,`tx_time`) - KEY: `idx_im_product` (`product_id`) - -### sales_return_orders -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| customer_id | BIGINT UNSIGNED | YES | | | -| order_no | VARCHAR(32) | NOT NULL | | | -| order_time | DATETIME | NOT NULL | | | -| status | ENUM('approved','void') | NOT NULL | approved | | -| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 退货金额合计 | -| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已退/已收合计 | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_sr_order_no` (`shop_id`,`order_no`) - KEY: `idx_sr_shop_time` (`shop_id`,`order_time`) -**Foreign Keys**: - `fk_sr_customer`: `customer_id` → `customers(id)` - `fk_sr_user`: `user_id` → `users(id)` - -### sales_return_order_items -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| order_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| quantity | DECIMAL(18,3) | NOT NULL | | | -| unit_price | DECIMAL(18,2) | NOT NULL | | | -| discount_rate | DECIMAL(5,2) | NOT NULL | 0.00 | 折扣百分比0-100 | -| cost_price | DECIMAL(18,2) | NOT NULL | 0.00 | 退货时对应的成本单价 | -| cost_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 成本金额 = 数量×成本单价 | -| amount | DECIMAL(18,2) | NOT NULL | | 行金额 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sroi_order` (`order_id`) - KEY: `idx_sroi_product` (`product_id`) -**Foreign Keys**: - `fk_sroi_order`: `order_id` → `sales_return_orders(id)` - `fk_sroi_product`: `product_id` → `products(id)` - -### purchase_return_orders -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| supplier_id | BIGINT UNSIGNED | YES | | | -| order_no | VARCHAR(32) | NOT NULL | | | -| order_time | DATETIME | NOT NULL | | | -| status | ENUM('approved','void') | NOT NULL | approved | | -| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 退货金额合计 | -| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已付合计(退款/扣减) | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -**Indexes**: - PRIMARY KEY: `id` - UNIQUE: `ux_pr_order_no` (`shop_id`,`order_no`) - KEY: `idx_pr_shop_time` (`shop_id`,`order_time`) -**Foreign Keys**: - `fk_pr_supplier`: `supplier_id` → `suppliers(id)` - `fk_pr_user`: `user_id` → `users(id)` - -### purchase_return_order_items -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| order_id | BIGINT UNSIGNED | NOT NULL | | | -| product_id | BIGINT UNSIGNED | NOT NULL | | | -| quantity | DECIMAL(18,3) | NOT NULL | | | -| unit_price | DECIMAL(18,2) | NOT NULL | | | -| amount | DECIMAL(18,2) | NOT NULL | | 行金额 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_proi_order` (`order_id`) - KEY: `idx_proi_product` (`product_id`) -**Foreign Keys**: - `fk_proi_order`: `order_id` → `purchase_return_orders(id)` - `fk_proi_product`: `product_id` → `products(id)` - -### other_transactions -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| shop_id | BIGINT UNSIGNED | NOT NULL | | | -| user_id | BIGINT UNSIGNED | NOT NULL | | | -| type | ENUM('income','expense') | NOT NULL | | | -| category | VARCHAR(64) | NOT NULL | | | -| counterparty_type | VARCHAR(32) | YES | | customer/supplier/other | -| counterparty_id | BIGINT UNSIGNED | YES | | | -| account_id | BIGINT UNSIGNED | NOT NULL | | | -| amount | DECIMAL(18,2) | NOT NULL | | | -| tx_time | DATETIME | NOT NULL | | | -| remark | VARCHAR(255) | YES | | | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | | - -字段说明(other_transactions): -- type/category: 收入/支出与分类 -- counterparty_type/id: 往来单位(可空) - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_ot_shop_time` (`shop_id`,`tx_time`) - -### consults -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 咨询ID | -| shop_id | BIGINT UNSIGNED | NOT NULL | | 所属店铺 | -| user_id | BIGINT UNSIGNED | NOT NULL | | 提问用户 | -| topic | VARCHAR(120) | NO | | 主题(可空字符串) | -| message | TEXT | NO | | 咨询内容 | -| status | ENUM('open','resolved','closed') | NO | open | 状态:未解决/已解决/关闭 | -| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_consult_shop_status` (`shop_id`,`status`) - KEY: `fk_consult_user` (`user_id`) -**Foreign Keys**: - `fk_consult_shop`: `shop_id` → `shops(id)` - `fk_consult_user`: `user_id` → `users(id)` - -### consult_replies -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 回复ID | -| consult_id | BIGINT UNSIGNED | NOT NULL | | 所属咨询 | -| user_id | BIGINT UNSIGNED | NOT NULL | | 回复人(管理员) | -| content | TEXT | NOT NULL | | 回复内容 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | 回复时间 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_cr_consult` (`consult_id`) - KEY: `fk_cr_user` (`user_id`) -**Foreign Keys**: - `fk_cr_consult`: `consult_id` → `consults(id)` - `fk_cr_user`: `user_id` → `users(id)` - -``` -**触发器**: -- `trg_consult_replies_ai`: AFTER INSERT ON `consult_replies` → 更新 `consults.status='resolved'` 且 `updated_at=NOW()` -``` - -### notices -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| title | VARCHAR(120) | NOT NULL | | 标题 | -| content | VARCHAR(500) | NOT NULL | | 内容(跑马灯/简讯) | -| tag | VARCHAR(32) | YES | | 标签(如“活动”) | -| is_pinned | TINYINT(1) | NOT NULL | 0 | 是否置顶 | -| starts_at | DATETIME | YES | | 生效开始时间(为空立即生效) | -| ends_at | DATETIME | YES | | 生效结束时间(为空长期) | -| status | ENUM('draft','published','offline') | NOT NULL | published | 状态 | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| deleted_at | DATETIME | YES | | 逻辑删除 | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_notices_time` (`starts_at`,`ends_at`) - -字段说明: -- 生效窗口:仅当 `status='published'` 且当前时间处于 `[starts_at, ends_at]` 区间(空表示不限制)时,前台 `/api/notices` 会返回;排序 `is_pinned DESC, created_at DESC`。 -- 数据范围:平台全局公告(与租户无关)。如需“店铺公告”,需在该表增加 `shop_id` 并调整接口逻辑。 - -### email_codes -| Column Name | Data Type | Nullable | Default | Comment | -| ----------- | --------- | -------- | ------- | ------- | -| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | | -| email | VARCHAR(128) | NOT NULL | | 邮箱 | -| scene | VARCHAR(32) | NOT NULL | | 场景(login/register/…) | -| code_hash | VARCHAR(64) | NOT NULL | | 验证码哈希(SHA-256) | -| salt | VARCHAR(64) | NOT NULL | | 加盐字符串 | -| expire_at | DATETIME | NOT NULL | | 过期时间 | -| status | TINYINT UNSIGNED | NOT NULL | 0 | 0=unused,1=used,2=expired | -| fail_count | INT | NOT NULL | 0 | 错误次数 | -| ip | VARCHAR(64) | YES | | 发送IP | -| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | -| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | | - -**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_email_scene_created` (`email`,`scene`,`created_at`) - KEY: `idx_email_expire` (`expire_at`) +数据库结构文档(生产同步版) + +说明 +- 本文档依据当前生产库 `partsinquiry` 实时结构生成,确保与线上一致。 +- 结构脚本来源:`backend/db/db.sql`(已由 mysqldump 同步)。如需精确细节(触发器/索引选项/字符集等),以该 SQL 文件为准。 +- 下文为核心业务表的字段、索引与外键说明;其余表已在 `backend/db/db.sql` 完整记录。 + +连接信息(只读) +- Host: mysql.tonaspace.com +- Database: partsinquiry +- Collation: utf8mb4_0900_ai_ci + +目录(表清单) +- accounts +- admins +- attachments +- consults / consult_replies +- customers +- email_codes +- finance_categories +- global_skus +- inventories / inventory_movements +- normal_admin_audits +- notices +- other_transactions +- part_attribute_dictionary / part_attribute_templates / part_category_attributes / part_categories +- part_submissions / part_templates / part_template_params +- payments +- product_aliases / product_categories / product_images / product_prices / product_units / products +- purchase_orders / purchase_order_items / purchase_return_orders / purchase_return_order_items +- sales_orders / sales_order_items / sales_return_orders / sales_return_order_items +- shops / suppliers / users / user_identities / system_parameters / sms_codes / vip_price / vip_recharges / vip_users / wechat_sessions + +—— 以下为核心表详情(节选) —— + +### 表:accounts + +字段 + +| Column Name | Data Type | Nullable | Default | Comment | +| ----------- | ---------------- | -------- | -------------- | ------- | +| id | bigint unsigned | NO | AUTO_INCREMENT | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| name | varchar(64) | NO | | | +| type | enum('cash','bank','alipay','wechat','other') | NO | cash | | +| bank_name | varchar(64) | YES | | | +| bank_account| varchar(64) | YES | | | +| balance | decimal(18,2) | NO | 0.00 | | +| status | tinyint unsigned | NO | 1 | | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| updated_at | timestamp | NO | CURRENT_TIMESTAMP ON UPDATE | | +| deleted_at | datetime | YES | | | + +Indexes +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_accounts_shop_name` (`shop_id`,`name`) +- KEY: `idx_accounts_shop` (`shop_id`) + +Foreign Keys +- `fk_accounts_shop`: `shop_id` → `shops(id)` +- `fk_accounts_user`: `user_id` → `users(id)` + +--- + +### 表:customers + +字段 + +| Column Name | Data Type | Nullable | Default | Comment | +| ------------ | ------------------------------------------- | -------- | ------- | ----------------------------- | +| id | bigint unsigned | NO | AI | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| name | varchar(120) | NO | | | +| phone | varchar(32) | YES | | | +| address | varchar(255) | YES | | | +| mobile | varchar(32) | YES | | | +| contact_name | varchar(64) | YES | | | +| price_level | enum('零售价','批发价','大单报价') | NO | 零售价 | 默认售价列 | +| status | tinyint unsigned | NO | 1 | | +| ar_opening | decimal(18,2) | NO | 0.00 | | +| remark | varchar(255) | YES | | | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| updated_at | timestamp | NO | CURRENT_TIMESTAMP ON UPDATE | | +| deleted_at | datetime | YES | | | + +Indexes +- PRIMARY KEY: `id` +- KEY: `idx_customers_shop` (`shop_id`) +- KEY: `idx_customers_phone` (`phone`) +- KEY: `idx_customers_mobile` (`mobile`) + +Foreign Keys +- `fk_customers_shop`: `shop_id` → `shops(id)` +- `fk_customers_user`: `user_id` → `users(id)` + +--- + +### 表:products + +字段 + +| Column Name | Data Type | Nullable | Default | Comment | +| ---------------------- | ------------------- | -------- | ------- | ---------------------------------------- | +| id | bigint unsigned | NO | AI | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| name | varchar(120) | NO | | | +| category_id | bigint unsigned | YES | | | +| template_id | bigint unsigned | YES | | | +| brand | varchar(64) | YES | | | +| model | varchar(64) | YES | | | +| spec | varchar(128) | YES | | | +| origin | varchar(64) | YES | | | +| barcode | varchar(32) | YES | | | +| dedupe_key | varchar(512) | YES | | | +| alias | varchar(120) | YES | | | +| is_blacklisted | tinyint(1) | NO | 0 | | +| description | text | YES | | | +| global_sku_id | bigint unsigned | YES | | | +| source_submission_id | bigint unsigned | YES | | | +| attributes_json | json | YES | | | +| safe_min | decimal(18,3) | YES | | | +| safe_max | decimal(18,3) | YES | | | +| search_text | text | YES | | 供全文检索的聚合字段 | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| updated_at | timestamp | NO | CURRENT_TIMESTAMP ON UPDATE | | +| deleted_at | datetime | YES | | | +| is_active | tinyint (generated) | YES | | 生成列:deleted_at 为空则 1,否则 0 | + +Indexes +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_products_template_name_model` (`template_id`,`name`,`model`) +- UNIQUE KEY: `ux_products_shop_barcode_live` (`shop_id`,`barcode`,`is_active`) +- FULLTEXT: `ft_products_search` (`name`,`brand`,`model`,`spec`,`search_text`) +- KEY: `idx_products_shop` (`shop_id`), `idx_products_category` (`category_id`), `idx_products_template` (`template_id`), `idx_products_dedupe` (`dedupe_key`), `idx_products_deleted_at` (`deleted_at`), `idx_products_shop_blacklist` (`shop_id`,`is_blacklisted`) + +Foreign Keys +- `fk_products_shop`: `shop_id` → `shops(id)` +- `fk_products_user`: `user_id` → `users(id)` +- `fk_products_category`: `category_id` → `product_categories(id)` +- `fk_products_template`: `template_id` → `part_templates(id)` +- `fk_products_globalsku`: `global_sku_id` → `global_skus(id)` +- `fk_products_submission`: `source_submission_id` → `part_submissions(id)` + +触发器 +- BEFORE INSERT `trg_products_bi`: 设置 `search_text` +- BEFORE UPDATE `trg_products_au`: 设置 `search_text` + +--- + +### 表:sales_orders + +字段(摘录) + +| Column Name | Data Type | Nullable | Default | Comment | +| ----------- | ---------------- | -------- | ------- | ------- | +| id | bigint unsigned | NO | AI | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | 创建人 | +| customer_id | bigint unsigned | YES | | | +| order_no | varchar(32) | NO | | | +| order_time | datetime | NO | | | +| status | enum('draft','approved','returned','void') | NO | draft | | +| amount | decimal(18,2) | NO | 0.00 | 应收合计 | +| paid_amount | decimal(18,2) | NO | 0.00 | 已收合计 | +| remark | varchar(255) | YES | | | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| updated_at | timestamp | NO | CURRENT_TIMESTAMP ON UPDATE | | +| deleted_at | datetime | YES | | | + +Indexes +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_sales_order_no` (`shop_id`,`order_no`) +- KEY: `idx_sales_shop_time` (`shop_id`,`order_time`), `idx_sales_customer` (`customer_id`) + +Foreign Keys +- `fk_sales_shop`: `shop_id` → `shops(id)` +- `fk_sales_user`: `user_id` → `users(id)` +- `fk_sales_customer`: `customer_id` → `customers(id)` + +--- + +### 表:purchase_orders + +字段(摘录) + +| Column Name | Data Type | Nullable | Default | Comment | +| ----------- | ---------------- | -------- | ------- | ------- | +| id | bigint unsigned | NO | AI | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| supplier_id | bigint unsigned | YES | | | +| order_no | varchar(32) | NO | | | +| order_time | datetime | NO | | | +| status | enum('draft','approved','void','returned') | NO | draft | | +| amount | decimal(18,2) | NO | 0.00 | 应付合计 | +| paid_amount | decimal(18,2) | NO | 0.00 | 已付合计 | +| remark | varchar(255) | YES | | | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| updated_at | timestamp | NO | CURRENT_TIMESTAMP ON UPDATE | | +| deleted_at | datetime | YES | | | + +Indexes +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_purchase_order_no` (`shop_id`,`order_no`) +- KEY: `idx_purchase_shop_time` (`shop_id`,`order_time`), `idx_purchase_supplier` (`supplier_id`) + +Foreign Keys +- `fk_purchase_shop`: `shop_id` → `shops(id)` +- `fk_purchase_user`: `user_id` → `users(id)` +- `fk_purchase_supplier`: `supplier_id` → `suppliers(id)` + +--- + +### 表:part_submissions(配件提交) + +字段(摘录) + +| Column Name | Data Type | Nullable | Default | Comment | +| ----------------- | ---------------- | -------- | ------- | ------------------ | +| id | bigint unsigned | NO | AI | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| name | varchar(120) | YES | | | +| model_unique | varchar(128) | NO | | 型号(唯一) | +| brand | varchar(64) | YES | | | +| spec | varchar(128) | YES | | | +| template_id | bigint unsigned | YES | | | +| category_id | bigint unsigned | YES | | | +| status | enum('pending','approved','rejected') | NO | pending | | +| product_id | bigint unsigned | YES | | | +| global_sku_id | bigint unsigned | YES | | | +| barcode | varchar(64) | YES | | | +| dedupe_key | varchar(512) | YES | | | +| images/tags/attributes | json | YES | | | +| created_at/updated_at | timestamp | NO | CURRENT_TIMESTAMP | | + +Indexes(摘录) +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_part_model_unique` (`model_unique`) +- UNIQUE KEY: `ux_ps_template_name_model` (`template_id`,`name`,`model_unique`) +- KEY: `idx_sub_shop_status` (`shop_id`,`status`,`updated_at`), `idx_part_brand_model` (`brand`,`model_unique`), `idx_ps_dedupe` (`dedupe_key`) + +Foreign Keys(摘录) +- `fk_part_shop`: `shop_id` → `shops(id)` +- `fk_part_user`: `user_id` → `users(id)` +- `fk_part_submission_product`: `product_id` → `products(id)` +- `fk_part_submission_global_sku`: `global_sku_id` → `global_skus(id)` +- `fk_ps_template`: `template_id` → `part_templates(id)` + +--- + +### 表:product_aliases(别名) + +字段(摘录) + +| Column Name | Data Type | Nullable | Default | Comment | +| ----------- | ---------------- | -------- | ------- | ------- | +| id | bigint unsigned | NO | AI | | +| product_id | bigint unsigned | NO | | | +| alias | varchar(120) | NO | | | +| shop_id | bigint unsigned | NO | | | +| user_id | bigint unsigned | NO | | | +| created_at | timestamp | NO | CURRENT_TIMESTAMP | | +| deleted_at | datetime | YES | | | + +Indexes +- PRIMARY KEY: `id` +- UNIQUE KEY: `ux_product_alias` (`product_id`,`alias`) +- KEY: `idx_product_alias_product` (`product_id`) + +Foreign Keys +- `fk_alias_product`: `product_id` → `products(id)` +- `fk_alias_shop`: `shop_id` → `shops(id)` +- `fk_alias_user`: `user_id` → `users(id)` + +触发器 +- AFTER INSERT/UPDATE/DELETE:`trg_palias_ai`/`trg_palias_au`/`trg_palias_ad` 用于回写 `products.search_text` + +--- + +其余表 +- 所有其它表(见“目录”)的字段/索引/外键与生产一致,完整 DDL 已收录在 `backend/db/db.sql`。若需要将本文件扩展为全量字段清单,可由该 SQL 一键生成(建议脚本化生成避免手工遗漏)。 + +生成时间 +- 本文档基于线上结构同步,生成于本地更新时刻。 + + + diff --git a/doc/openapi.yaml b/doc/openapi.yaml index fea9cb9..3907511 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -2869,7 +2869,6 @@ components: model: { type: string } spec: { type: string } categoryId: { type: integer, format: int64, nullable: true } - unitId: { type: integer, format: int64 } safeMin: { type: number, nullable: true } safeMax: { type: number, nullable: true } purchasePrice: { type: number } @@ -2891,7 +2890,6 @@ components: model: { type: string, nullable: true } spec: { type: string, nullable: true } categoryId: { type: integer, format: int64, nullable: true } - unitId: { type: integer, format: int64 } safeMin: { type: number, nullable: true } safeMax: { type: number, nullable: true } prices: diff --git a/doc/product_enhancement_requirements.md b/doc/product_enhancement_requirements.md deleted file mode 100644 index ed4b240..0000000 --- a/doc/product_enhancement_requirements.md +++ /dev/null @@ -1,187 +0,0 @@ -# 货品功能扩展需求文档(草案) - -## 1. 当前实现概览 - -### 1.1 用户端(uni-app) -- `pages/product/list.vue`:支持关键字与按类别筛选、下拉分页、查看详情并跳转编辑;展示“平台推荐/我的提交”标签。 -- `pages/product/form.vue`:可创建/编辑货品,字段涵盖名称、条码、品牌、型号、规格、产地、单位、类别、安全库存、四列售价、初始库存、图片与备注;提供图片识码能力;图片上传走 `/api/attachments`。 -- **`pages/product/submit.vue`**:新增配件提交入口,支持型号唯一校验、多图上传、参数 JSON、备注、安全库存等字段,提交成功跳转“我的提交”。 -- **`pages/product/submissions.vue`、`pages/product/submission-detail.vue`**:新增提交列表与详情页,支持状态筛选、滚动分页、驳回重新提交、图片/参数展示。 -- 字典:调用 `/api/product-units`、`/api/product-categories` 读取全局单位/类别,并缓存到本地。 - -### 1.2 管理端(Vue3 + Element Plus) -- **`admin/src/views/parts/Submissions.vue`**:已上线配件审核模块,提供筛选、详情、编辑、通过/驳回、Excel 导出能力。 -- 其它模块:VIP 系统、公告管理、咨询回复、用户管理、配件管理、供应商管理、主数据字典。 -- 待迭代:登录页、角色权限细分、操作日志可视化。 - -### 1.3 后端(Spring Boot) -- `ProductSubmissionController` + `ProductSubmissionService`:完成用户提交、用户查看、管理员审核/驳回、导出全链路;审批通过时同步 `products`、`product_images`、`source_submission_id` 等字段。 -- OpenAPI 已新增 `/api/products/submissions*` 与 `/api/admin/parts/submissions*` 路径,并标记为 ✅ Fully Implemented。 -- 数据模型:`products`、`product_prices`、`inventories`、`product_images` 已投产;`part_submissions` 已扩展 JSON、状态、审核信息等字段。 -- 图片存储:统一走 `attachments`,提交与商品共享资源。 - -## 2. 差距分析 - -| 需求点 | 现状 | 差距 | -| --- | --- | --- | -| 用户提交配件待审核 | 用户端新增提交/列表/详情页面,与后台接口打通 | 持续跟踪型号唯一范围(店铺或全局);补充并发校验测试 | -| 审核管理 | 管理端已上线审核列表、详情编辑、通过/驳回、导出 | 后续可扩展批量操作、导出更大数据量的异步方案 | -| 数据查询增强 | 后端支持多条件、返回 total;前端列表分页同步 | 可继续优化查询性能(索引/缓存)与前端展示字段 | -| 图片管理 | 提交端与审核端均支持多图显示、预览、排序 | 可根据需求补充图片备注、AI 识别等增强能力 | -| 上架逻辑 | 审核通过自动写入 `products` 并关联 `source_submission_id`、`global_sku_id` | 待确认产品图片、参数同步策略是否满足后续拓展(如平台库) | -| 导出 | 管理端支持按筛选条件导出 Excel(2000 条以内同步导出) | 后续可评估异步导出/下载中心、导出模板自定义 | - -## 3. 设计方案 - -> 项目采用方案 A(基于 `part_submissions` + `products` 双轨流程),方案 B 已放弃,不再纳入考虑。 - -### 方案 A:基于现有 `part_submissions` + `products` 双轨流程(已确认) -1. **用户提交** - - 新增用户端页面 `pages/product/submit.vue`,字段:型号(model,唯一必填)、配件名称(name)、配件参数(parameters:自由文本/JSON 字段)、图片(多图,最多 9 张)、备注。 - - 调用 `POST /api/products/submissions`,写入 `part_submissions`:`shop_id`、`user_id`、`model_unique`、`status=pending`、`images JSON`、`extra_attrs`、`created_at`。 - - 验证规则: - - `model_unique` 同一店铺 + 全局唯一(需确认范围)。 - - 图片通过 `attachments` 保存,限制尺寸/数量。 - - 上传成功后展示提交状态(待审核)。 -2. **管理员审核** - - 新增管理端视图 `admin/src/views/parts/Submissions.vue`: - - 列表字段:型号、名称、提交人、提交时间、当前状态、备注、图片缩略图。 - - 过滤项:状态(待审/已审/驳回)、关键字(支持型号/名称/提交人)、提交时间区间、店铺。 - - 操作: - - 查看详情(弹窗):可编辑名称、型号、参数、图片(增删排序)、备注。 - - 审核按钮:批准/驳回,必须填写备注(驳回)。 - - 批量导出:选中或按筛选条件导出 Excel。 - - 审核通过逻辑: - - 检测是否存在相同型号产品: - - 若没有:创建 `products`(source_submission_id 引用)、初始化库存 0、价格默认 0,关联图片。 - - 若已有:补充图片、参数(可覆盖或合并),并记录 submission→product 对应关系。 - - 更新 `part_submissions.status=approved`、`reviewer_id`、`reviewed_at`、`product_id`。 - - 将产品显示给提交用户(若产品属于平台库,可关联 `global_sku_id`)。 - - 审核驳回:状态=reject,记录 `remark`、`reviewer_id`、`reviewed_at`。 -3. **数据查询** - - `GET /api/products` 增强:支持 `brand`、`model`、`status`(是否审核通过)、`createdStart/End` 等参数;返回 `{ list, total }`。 - - `GET /api/admin/parts/submissions` 支持分页+多条件,默认按创建时间倒序。 - - Excel 导出:`GET /api/admin/parts/submissions/export`,返回文件下载,支持当前过滤条件。 -4. **图片管理** - - 提交阶段:图片存储 `attachments`,ownerType=submission,路径按 hash 去重。 - - 审核详情弹窗内使用 `el-image` + preview 功能查看大图;允许删除/新增(调用 `/api/attachments` 上传)。 - - 审核通过时同步图片至 `product_images`(可重用 attachment URL,无需复制物理文件),根据排序保存 `sort_order`。 -5. **上架逻辑** - - 平台维护 `global_skus`(可选): - - 审核通过界面提供“关联平台配件”下拉,或创建新平台配件,同时写入 `global_skus`。 - - `products.global_sku_id` 记录来源,供其他店铺引用。 - - 用户提交的产品通过审核后自动加入其店铺的 `products`,并可在商品列表中展示“平台推荐/自有”区分。 - -### 4.1 数据模型变更细节 - -#### part_submissions(新增/调整) -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `name` | VARCHAR(120) | 配件名称(用户填写,可用于审核) | -| `parameters` | JSON | 结构化参数,如规格、尺寸、材质等 | -| `images` | JSON | 图片 URL 列表,提交阶段存储 | -| `status` | ENUM('pending','approved','rejected') | 当前审核状态 | -| `remark` | VARCHAR(255) | 审核备注(驳回原因等) | -| `reviewer_id` | BIGINT | 审核人 ID | -| `reviewed_at` | DATETIME | 审核时间 | -| `product_id` | BIGINT | 审核通过后关联的产品 ID | -| `global_sku_id` | BIGINT | 可选,关联平台配件 ID | -| `source_shop_id` | BIGINT | 原提交店铺(若与 shop_id 区分需求明确可保留) | - -> 若原表已有旧字段(如 `compatible`、`size` 等),可迁移到 `parameters` JSON 中或保留以兼容老数据。 - -#### products(新增字段) -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| `source_submission_id` | BIGINT | 来源提交记录 ID | -| `global_sku_id` | BIGINT | 平台配件 ID(可选) | -| `platform_status` | ENUM('platform','custom') | 产品来源标识(可选) | - -需要为 `model_unique`、`shop_id` 添加唯一索引,保障型号不可重复提交(规则确定后实施)。 - -### 4.2 接口契约摘要 - -| 接口 | 方法 | 说明 | 核心入参 | 返回 | -| --- | --- | --- | --- | --- | -| `/api/products/submissions` | POST | 用户提交配件 | `{ name, model, parameters?, images[], remark? }` | `{ id, status }` | -| `/api/products/submissions` | GET | 用户查看提交记录 | `status?, page, size` | `{ list, total }` | -| `/api/admin/parts/submissions` | GET | 管理端列表 | `status?, kw?, shopId?, reviewerId?, startAt?, endAt?, page, size` | `{ list, total }` | -| `/api/admin/parts/submissions/{id}` | GET | 查看详情 | - | 详细信息(含图片) | -| `/api/admin/parts/submissions/{id}` | PUT | 编辑字段(名称/参数/图片) | `{ name?, parameters?, images?, remark? }` | `{ ok: true }` | -| `/api/admin/parts/submissions/{id}/approve` | POST | 审核通过 | `{ remark?, assignGlobalSkuId?, createGlobalSku? }` | `{ ok: true, productId }` | -| `/api/admin/parts/submissions/{id}/reject` | POST | 审核驳回 | `{ remark }` | `{ ok: true }` | -| `/api/admin/parts/submissions/export` | GET | Excel 导出 | 同列表查询参数 | 二进制文件 | -| `/api/products` | GET | 增强查询 | `kw, brand, model, status, categoryId, startAt, endAt, page, size` | `{ list, total }` | - -OpenAPI 需同步更新:新增路径、请求体/响应体、状态标注。 - -### 4.3 前端/后端任务拆分 - -**后端** -1. 数据库迁移脚本(新增字段、索引、默认值)。 -2. 提交接口实现:存储提交记录、处理图片、型号唯一校验。 -3. 审核接口实现: - - 获取详情、编辑提交记录。 - - 批准流程(创建/更新产品、同步图片、记录审计)。 - - 驳回流程。 -4. 列表查询与导出(注意权限:仅管理员)。 -5. 产品查询增强(支持多条件和 total)。 -6. 单元测试/集成测试:审核通过/驳回、重复型号、导出大数据量。 - -**管理端前端** -1. 新建 `Submissions.vue` 页面: - - 列表 + 搜索表单 + 分页。 - - Excel 导出按钮。 -2. 审核详情弹窗组件: - - 多图预览/排序(Element Plus `el-image` + `el-upload` 或复用现有 `ImageUploader` 组件)。 - - 字段编辑与保存。 - - 审核通过/驳回按钮及确认弹窗。 -3. 上架结果联动:审批通过后刷新列表,提示成功。 - -**用户端前端** -1. 新增 `pages/product/submit.vue`: - - 表单输入 + 多图上传 + 校验(型号唯一、本地格式校验)。 - - 提交成功提示与跳转。 -2. 新增“我的提交”页面或在现有列表中添加状态指示。 -3. 货品列表/详情展示新字段(如“平台配件”标签、审核状态)。 - -## 5. 开发计划建议 -1. **第一阶段:后端改造** - - 数据库变更(通过 MysqlMCP):更新 `part_submissions`、`products`、`global_skus` 相关字段与索引。 - - 新增接口实现(提交、审核、导出、多条件查询)。 - - 扩展 `ProductService` 支持 total、更多筛选。 - - 调整 OpenAPI,标注实现状态。 -2. **第二阶段:管理端前端** - - 新建审核模块页面,集成组合查询 + 分页 + 导出。 - - 审核详情对话框支持多图预览/编辑、字段修改、审核按钮。 -3. **第三阶段:用户端前端** - - 新增“提交配件”入口(从货品列表或我的模块进入)。 - - 提交表单 + 多图上传(复用 ImageUploader)。 - - 提交记录列表(待审/已通过/驳回)与状态提示。 - - 审核通过后自动同步到货品列表(带标签)。 -4. **第四阶段:测试与文档** - - 定义审批流程测试用例:提交->审批->列表刷新->导出。 - - 验证图片上传、重复型号、并发审批场景。 - - 更新需求文档、开发文档、用户操作指引。 - -## 6. 验收标准 -- 用户端可提交配件:型号唯一校验、上传多图成功、提交后状态为“待审核”。 -- 管理端待审核列表能根据筛选条件展示结果,并支持详情查看(含大图预览、编辑字段)。 -- 管理端可对单条记录审核通过/驳回: - - 通过:在 `products` 中生成或更新商品;提交用户在商品列表中可见;状态变更为“已通过”。 - - 驳回:状态变更为“已驳回”,记录审核备注。 -- 导出功能可按筛选条件导出 Excel,至少包含 2000 条数据测试无超时;生成文件名避免冲突(时间戳+操作人)。 -- 数据查询支持组合条件,返回总数;前后端分页一致。 -- 图片管理:审核详情中可预览大图、删除、重新上传;重复文件自动识别 hash,避免冲突。 -- 上线后数据库结构、OpenAPI、开发文档同步更新,无遗漏。 - -## 7. 风险与注意事项 -- 型号唯一性需统一标准化(去空格、大小写)并考虑跨店铺冲突策略。 -- 审核通过时同步到 `products`,需明确库存与价格的初始值(建议默认 0,提醒用户自行编辑)。 -- Excel 导出需加限流或异步处理,防止长时间请求阻塞。 -- 图片引用避免重复占用存储;可直接引用同一 URL,而非复制文件。 -- 若需消息通知(如审核结束提醒),需要后续补充通知机制。 -- 需要回顾 `global_skus` 与 `products` 的关系,明确平台配件库与用户私有货品的边界,以免数据混乱。 - ---- -> 本文档结合现状与新增需求整理。请确认方案后,可进一步拆解任务并排期执行。 diff --git a/doc/requirements.md b/doc/requirements.md deleted file mode 100644 index 0925535..0000000 --- a/doc/requirements.md +++ /dev/null @@ -1,176 +0,0 @@ -* ### **配件查询App需求规格说明书** - - #### 1.0 项目概述 - 本项目旨在开发一款面向小微商户的移动端进销存管理应用,命名为“配件查询”。该应用核心功能是帮助用户高效管理商品、库存、销售、采购、客户、供应商及财务收支,并通过数据报表提供经营状况分析,助力商户实现数字化经营。 - 参考的小程序“智慧记进销存”,但是多了一个配件查询功能,以下所罗列的内容大多也参考至该小程序,如有歧义可优先参照这个小程序,拿不准优先问。 - 当前交付内容:移动端已实现基础的登录、VIP 开通、用户/客户/供应商管理、下单与报表入口;管理端完成会员、公告、咨询、配件、字典维护模块。 - - #### 2.0 功能模块需求 - - **2.1 首页 (Dashboard)** - - * **2.1.1 核心数据概览:** 首页需直观展示当日、当月的核心经营数据。 - > 已实现基础销量/库存统计,利润汇总需依赖真实成本数据完善。 - - * 今日销售额 - * 本月销售额 - * 本月利润 - * 库存商品数量 - - **2.1.2 广告位:** 在首页区域提供一个展示广告的区域。 - > 待设计,当前保留静态占位图。 - - * **2.1.3 快捷功能入口:** 提供一个快捷功能区域,方便用户快速访问常用功能。 - * 默认应包含:客户管理、销售开单、账户管理、供应商管理、进货开单、其他支出、VIP会员、报表统计等。 - * 现状:移动端 Tab 首页已集成常用入口,后续需根据配置扩展。 - - * **2.1.4 在线客服:** 提供一个悬浮的“咨询”或“在线客服”入口,方便用户随时获取帮助。 - * 当前通过“我的-咨询”入口触达,悬浮入口尚未实现。 - - **2.2 货品管理模块** - - * **2.2.1 货品列表与库存:** - * 展示所有货品的列表,包含名称、库存等基本信息。 - * 支持按“全部类别”或指定类别筛选货品。 - * 提供搜索功能,支持通过货品名称或条形码进行模糊查找。 - * 列表底部显示总货品种类数量。 - * 当库存为空时,应有明显的空状态提示,并引导用户“点击右上角‘+’录入货品信息”。 - * **2.2.2 新增/编辑货品:** - * 支持添加商品图片。 - * App端支持录入或扫描商品条形码;小程序端仅支持手动录入,不支持扫码。 - * **货品名称**为必填项。 - * 可为货品选择**类别**和**主单位**。 - * 支持自定义扩展货品属性(如品牌、型号、产地、保质期等)。 - * 货品图片支持多图上传,支持拖拽排序,支持图片放大预览。 - * 可录入**当前库存**、**安全库存**(一个数值区间,用于库存预警)。 - * 需分别录入四种价格,**进货价**、**批发价**、**大单报价**和**零售价**。 - * 提供**备注**字段,用于记录额外信息。 - * 保存后,可选择“继续新增”或返回列表。 - * **2.2.3 货品设置:** - * 支持自定义**货品类别**管理。 - * 支持自定义**货品单位**管理。 - * 提供开关选项,允许用户选择是否“隐藏零库存商品”和“隐藏进货价”。 - - **2.3 开单(交易)模块** - * **2.3.1 核心功能:** 该模块是应用的核心操作区,整合了销售、进货和财务记账功能。 - * **2.3.2 销售开单:** - * **出货单:** - * 自动记录开单**时间**,并支持手动修改。 - * 可选择**客户**,默认为“零售客户”。 - * 通过“+”号从货品列表中选择商品,自动计算**合计金额**。 - * 支持在订单中对单个商品进行操作(如修改数量、价格等)。 - * **退货单:** 用于处理客户退货业务。 - * **收款单:** 用于处理销售单的后续收款或直接创建收款记录。 - * **2.3.3 进货开单:** 用于记录从供应商处采购商品的业务流程。 - * **2.3.4 其他收入/支出:** - * **其他收入:** - * 支持对收入进行分类,如“销售收入”、“经营所得”、“利息收入”等。 - * 可选择**往来单位**和**结算账户**(如现金、银行等)。 - * 可添加备注并选择日期。 - * **其他支出:** - * 支持对支出进行分类,如“经营支出”、“办公用品”、“房租”等。 - * 同样支持选择**往来单位**和**结算账户**。 - - **2.4 明细查询模块** - * **2.4.1 维度筛选:** - * 提供按时间维度(自定义、本周、今日、本月、本年)快速筛选单据。 - * 提供按业务类型(销售、进货、收银、资金、盘点)进行分类查看。 - * **2.4.2 单据列表:** - * 在选定维度下,以列表形式展示所有相关单据。 - * 提供搜索功能,支持通过单据号、客户/供应商名、品名、备注等关键字查询。 - * 显示当前筛选条件下的总金额。 - * 当无数据时,提供清晰的空状态提示。 - * 提供“+”号,支持在当前分类下快速新建单据。 - - **2.5 报表统计模块** - * **2.5.1 资金报表:** - * **利润统计:** 分析指定时间范围内的收入、支出和利润。 - * **营业员统计:** 按销售人员维度统计销售业绩。 - * **经营业绩:** 提供综合性的经营状况分析。 - * **导入导出模块:** 提供导入导出功能方便用户切换手机或账号后仍能将旧数据导入。 - * **2.5.2 进销存报表:** - * **销售统计:** 按商品、客户、时间等维度分析销售数据。 - * **进货统计:** 按商品、供应商、时间等维度分析采购数据。 - * **库存统计:** 提供当前库存成本、数量及分布情况的报告。 - * **应收/应付对账单:** 生成与客户和供应商的对账单据。 - - **2.6 “我的”(用户中心)模块** - * **2.6.1 用户信息:** 显示用户头像、店铺名称、注册手机号及“老板”身份标识。 - > 个人信息读取 `/api/user/me` 已上线。 - * **2.6.2 会员与订单:** - * 提供**VIP会员**入口,展示会员特权。 - > `/pages/my/vip` 已接入 `/api/vip/status`,开通调用 `/api/vip/pay`。 - * 提供**我的订单**入口,可能用于查看应用内服务订单。 - > 入口预留,服务订单接口尚未上线。 - * **2.6.3 基础管理:** - * **供应商管理** - * **客户管理** - * **客户报价** - * **店铺管理** - * **2.6.4 设置中心:** - * **账号与安全:** - * 修改个人信息(头像、姓名)。 - * 修改登录密码。 - * **商品设置:** - * **系统参数:** - * 提供多种业务逻辑开关,如:“销售价低于进货价时提示”、“销售单默认全部收款”、“启用单行折扣”、“启用客户/供应商双身份”。 - * **关于与协议:** 包含“关于我们”、“隐私协议”、“个人信息安全投诉”等静态页面。 - * **账号操作:** 提供“账号注销”和“退出登录”功能。 - - #### 3.0 全局性需求 - - * **3.1 导航:** 采用底部Tab栏导航,包含“首页”、“货品”、“开单”、“明细”、“我的”五个主要模块。 - > uni-app 端已实现,管理端采用侧栏导航。 - * **3.2 统一的UI/UX:** 应用整体风格简洁、清晰,操作流程符合移动端使用习惯。 - * **3.3 空状态页面:** 在列表、报表等数据为空的页面,需提供友好的空状态提示图和引导性文字。 - * **3.4 数据同步:** 应用数据应在云端同步,保证用户更换设备或多设备使用时数据一致性。 - * **3.5 多租户数据隔离:** 所有业务数据按店铺(租户)隔离,用户不可访问他人数据。 - * 所有业务表需包含`user_id`并在读取/写入中强制按`user_id`过滤。 - * 支持租户内角色与权限控制;导出仅限本租户数据。 - * **3.6 公共SKU(全局商品库)众包与审核:** 全体用户共同补充、纠错SKU,经审核发布为全局可选SKU。 - * 用户可提交“新增SKU/编辑建议”,进入审核流(草稿/待审/驳回/发布/下架)。 - * 全局SKU字段:名称、品牌、规格、条码、主单位、图片、别名、分类标签等。 - * 各用户通过“本地商品”引用全局SKU,并保留本地私有字段(价格、库存、分类、单位换算、条码别名等)。 - * **3.7 商品模糊查询(增强):** 在货品列表、开单选品、对账等场景支持多字段模糊匹配。 - * 支持名称/条码/别名/拼音/品牌/规格模糊匹配,并高亮命中片段。 - * 支持全局SKU与本地商品联合检索,优先展示本地商品;结果可分页。 - * 需满足大规模SKU下的性能目标;可通过系统参数配置匹配策略。 - - * **3.8 客户端平台:** 提供移动App与小程序;小程序不支持商品条形码扫描功能。 - > HBuilderX 打包的 App 版本与微信小程序版本已具备扫描差异处理;条码识别仅用户端调用。 - - * **3.9 多列销售价格:** 销售价格分四列,即同一种商品有四个销售价格 - - ### 配件查询 - - 1. **数据查询功能** - - 多参数组合查询(分类、尺寸、型号等) - - 模糊匹配关键字 - - 分页展示查询结果 - - 一键导出Excel数据 - 2. **数据提交系统** - - 用户提交新配件数据 - - 型号为唯一必填项 - - 支持图片上传 - - 提交后等待管理员审核 - 3. **审核管理系统** - - 管理员查看待审核列表 - - 可编辑所有字段 - - 支持图片更新和删除 - - 一键批准或拒绝提交 - 4. **图片管理系统** - - 每条数据可关联多张图片 - - 点击图片可放大查看 - - 管理员可管理所有图片 - - 自动处理文件名冲突 - - ## 全局说明(必看) - - 由于这个文档写的还不是很完善,目前有存疑的部分先行参考小程序小程序“智慧记进销存”(功能和按钮可以参考,界面样式除外),管理端文档目前待定。 - - 客户要求的是做双端应用(app端+小程序端),需要考虑兼容性相关问题。 - - 本程序和“智慧记进销存”大多一致,主要的区别在于客户有配件查询要求,即在产品页面中要额外加一个配件查询按钮或入口,且要求一个产品要有四个销售价格(先按零售价 分销价 批发价 大客户价),且要求能自定义添加各种规格(尺寸,孔径等)。 - - 有疑惑的部分一定要及时沟通(如未提及的页面和功能需要确认的时候) - diff --git a/doc/vip_development.md b/doc/vip_development.md deleted file mode 100644 index dde359e..0000000 --- a/doc/vip_development.md +++ /dev/null @@ -1,331 +0,0 @@ -### VIP 功能开发文档 - -#### 1. 范围与目标 -- 目标:为“配件查询”应用提供面向店铺(租户)的会员体系(VIP),支持会员开通、审核、生效与到期管理,并为后续增值能力(如数据永久存储、高级报表、优先审核等)提供统一的权限判定入口。 -- 范围:后端服务与管理端优先落地;移动端展示会员状态与开通入口,后续再接入支付与自动续期。 - -#### 2. 现状与差距 -- 前端 App:`frontend/pages/my/vip.vue` 当前为静态演示页,读取 `VIP_PRICE_PER_MONTH` 展示,未接入后端状态与支付。 -- 管理端:已存在页面 - - `admin/src/views/vip/VipList.vue`:查询、编辑、启停。 - - `admin/src/views/vip/VipReview.vue`:列出待启用(审核)并提供通过/驳回。 -- 后端:`AdminVipController` 提供基础接口:列表、创建、更新、审核通过/驳回(部分实现)。依赖表 `vip_users`。 -- OpenAPI:`/doc/openapi.yaml` 已定义管理端 VIP 接口,标注为「❌ Partially Implemented」。 -- 数据库:本地 `backend/db/db.sql` 尚未创建 `vip_users` 表;而远程数据库文档 `/doc/database_documentation.md` 已包含该表结构。这是“脚本未对齐线上库”的差距,需要弥合(见实施计划)。 - -#### 3. 业务与权限 -- 归属维度:VIP 为“用户级”。每个用户仅归属一个店铺(无店员概念)。 -- 状态定义: - - `isVip`:是否为会员标记(1/0)。 - - `status`:启用状态(1=生效,0=未启用/停用)。 - - `expireAt`:到期时间(为空代表永久或未设置)。 - -#### 4. 数据模型设计(多方案) -- 方案A:单表 `vip_users` - - 字段:`id, shop_id, user_id, is_vip, status, expire_at, remark, reviewer_id, reviewed_at, created_at, updated_at` - - 优点:简单直观,满足当前开通/续期/停用/审核的核心需求。 - - 局限:后续若需要等级(gold/platinum)、付费订单、优惠券、权益包等,需要再扩展新表。 -- 方案B:三表拆分(推荐用于中长期) - - `vip_users`(会员资格)、`vip_levels`(等级/权益配置)、`vip_orders`(开通/续费订单记录)。 - - 优点:扩展性好,可平滑引入支付渠道、对账、活动定价。 - - 初期实施成本更高。可采用“增量演进”:先落地方案A,同步预留 `vip_orders` 的接口草案与库表蓝图,待支付接入时启用。 - -当前建议:首期采用方案A,满足“开通-审核-生效-到期”的MVP;同时在配置中预留价格与时长,便于后续衔接 `vip_orders`。 - -#### 5. 配置与价格策略(严禁硬编码) -- 前端价格:已通过 `frontend/common/config.js` 的 `VIP_PRICE_PER_MONTH` 从环境变量/本地存储读取,默认 15 元/月。 -- 后端价格与时长建议:通过 `system_parameters` 表维护,键建议: - - `vip.price`(单位:元/月,number) - - `vip.durationDays`(开通/续费时长,默认 30) - - 可选:`vip.trialDays`(试用天数,默认 0) -- 后端读取优先级:系统参数 > 环境变量(如 `VIP_PRICE`,`VIP_DURATION_DAYS`)> 安全默认值;并通过管理端配置面板维护参数(后续迭代)。 - -管理员端价格设置(新增): -- 平台统一价,存储于表 `vip_price`(仅一条记录)。 -- 在管理端“VIP管理”新增“价格设置”入口:读取/修改 `vip_price.price`。 -- 修改保存后立即生效;前端价格展示以后端返回为准(避免前后端不一致)。 - -#### 6. 接口契约(与 openapi.yaml 对齐) -- 管理端: - - GET `/api/admin/vips`(列表) - - POST `/api/admin/vips`(新增) - - PUT `/api/admin/vips/{id}`(更新 isVip/status/expireAt/remark) - - 审核流程已取消(approve/reject 已移除);统一由用户端一键开通或管理员直接设置。 -- App(建议新增,未在 openapi.yaml 中登记,待一方开工后再登记): - - GET `/api/vip/status`:返回当前用户 VIP 状态与到期时间。 - - POST `/api/vip/pay`:一键支付并立即开通(临时方案:点击即视为支付成功,直接写入/更新 `vip_users`,置 `status=1,isVip=1,expireAt=now+durationDays`;后续接入真实支付将替换该接口实现)。 - - 说明:上述 App 接口仅为“建议方案”,在前端或后端开始实现后再将其写入 `/doc/openapi.yaml` 并标注实现状态,避免“仅设计未实现”的文档污染。 - -示例返回(状态查询): -```json -{ - "isVip": true, - "status": 1, - "expireAt": "2025-12-31T23:59:59Z" -} -``` - -#### 7. 前后端对接要点 -- Header:管理端接口需要 `X-User-Id`(用于写入审核人);多租户场景下优先根据用户解析 `shop_id`,或显式传递 `X-Shop-Id`。 -- 列表过滤:`phone` 模糊匹配 `users.phone`,`status` 精确匹配 `vip_users.status`。 -- 审核流:通过/驳回接口必须记录 `reviewer_id` 与 `reviewed_at`,便于审计。 -- App 端状态展示:进入“我的-会员”页时调用 `GET /api/vip/status`,根据返回值展示“已激活/有效期至/立即开通”。 - -##### 7.1 数据可见性与留存策略(关键新增) -- 规则: - - VIP 用户:可查看“所有历史数据”(不做时间裁剪)。 - - 普通用户:仅显示“最近两个月内”的数据,早于两个月的数据不显示。 -- 配置项(严禁硬编码): - - `vip.dataRetentionDaysForNonVip`(默认 60,单位:天)写入 `system_parameters`;也可通过环境变量 `VIP_NONVIP_RETENTION_DAYS` 覆盖。 - - 当该值小于等于 0 时,视为“非VIP也不做裁剪”(便于灰度/应急)。 -- 后端落地建议: - - 在列表/查询型接口(如 `/api/orders`, `/api/purchase-orders`, `/api/payments`, `/api/inventories/logs`, `/api/other-transactions` 等)统一增加“非VIP数据时间窗”约束: - - 通过当前用户的 VIP 状态与 `expireAt` 判断是否 VIP;非VIP则在 SQL WHERE 中增加 `tx_time/order_time >= NOW() - INTERVAL retentionDays DAY` 等条件。 - - 采用服务层统一拦截/拼接过滤,避免各控制器重复实现。 - - 在导出/报表接口同样应用该约束,确保口径一致。 -- 前端落地建议: - - 当非VIP进行跨越两个月以上的日期筛选时,给出轻提示:“普通用户仅显示近两个月数据,开通VIP可查看全部历史”。 - - 页面不做本地硬裁剪,一切以后端约束为准,前端仅用于用户沟通与引导开通。 - -#### 8. 业务流程(Graphviz) -```dot -digraph G { - rankdir=LR; - node [shape=box, style=rounded]; - start [label="用户进入VIP页"]; - pay [label="一键支付开通\n(POST /api/vip/pay)"]; - active [label="生效: status=1, isVip=1\n设置expireAt=now+durationDays"]; - expire [label="到期: 定时任务或登录判定\n自动置 isVip=0 / 提示续期"]; - - start -> pay -> active -> expire; -} -``` - -#### 9. 实施计划(MVP) -1) 数据库脚本对齐(仅通过 MysqlMCP 执行) - - 若目标库缺失 `vip_users`:按 `/doc/database_documentation.md` 定义创建,并补充必要索引与外键。 - - 备注:执行后同步更新 `/doc/database_documentation.md` 中的更新时间与差异说明(保持与线上一致)。 -2) 后端接口完备 - - 补齐 `AdminVipController` 的参数校验、店铺隔离、分页 total 统计(可选)。 - - 新增 App 侧接口:`GET /api/vip/status` 与 `POST /api/vip/pay`(点击即开通临时方案)。当开始实现后,将路径登记至 `/doc/openapi.yaml` 并标注状态。 - - 配置读取:接入 `system_parameters` 的 `vip.price`/`vip.durationDays`。 -3) 管理端接入 - - `VipList.vue` 与 `VipReview.vue` 接口字段对齐;在创建/编辑时透传 `shopId`(或后端根据用户解析)。 - - 在“VIP管理”中新增“价格设置”对话框:读取/修改 `vip.price`(走系统参数接口或新增专用接口)。 -4) App 接入 - - `pages/my/vip.vue` 调用 `GET /api/vip/status`;“立即开通”改为调用 `POST /api/vip/pay`(临时:点击即开通)。 -5) 定时/登录时到期检查 - - 每日定时任务或用户登录时,对已到期记录置 `isVip=0` 并提示续期(可由查询接口实时判断并回写)。 - -#### 10. 测试用例(核心) -- 列表:无过滤/按手机号/按状态返回正确集合;分页稳定。 -- 新增:缺少 `shopId`/`userId` 报错;完整入参成功写入,`status=0` 待启用。 -- 更新:仅更新传入字段,`updated_at` 变更。 -- 审核:通过后 `status=1` 且记录 `reviewer_id/reviewed_at`;驳回后 `status=0`。 -- 到期:`expireAt < now()` 时,状态查询返回 `isVip=false`(或提示续期),符合设计。 -- 数据可见性: - - 非VIP:订单/支付/库存流水/其他收支等查询接口,返回记录的最早时间不早于“当前时间-60天”(默认值);当调整 `vip.dataRetentionDaysForNonVip` 后行为随之变化。 - - VIP:在同样条件下可查询到两个月前的历史记录。 - - 前端在选择超窗的日期范围时出现轻提示,不影响接口正常返回。 -- 一键支付开通: - - 点击开通后接口返回成功,`vip_users` 写入/更新,`status=1,isVip=1,expireAt=now+durationDays`。 - - 再次点击在有效期内应提示“已是VIP/延长有效期”,并将 `expireAt` 顺延 `durationDays`(若设计为续期)。 -- 管理端价格设置: - - 读取当前价格成功显示;修改后保存成功,重新读取为新值;前端展示同步更新(以接口返回为准)。 - -#### 11. 非功能性要求 -- 审计:记录审核人与审核时间。 -- 性能:常用过滤列建索引(`shop_id,status`)。 -- 安全:所有接口在租户维度强制鉴权与隔离;仅店主/平台管理员可审核。 - -#### 12. 验收标准 -- 管理端可增改查 VIP,支持启停与审核通过/驳回。 -- App 可正确显示会员状态与有效期,支持提交开通申请。 -- 配置可通过参数调整价格与时长,无任何硬编码常量。 -- OpenAPI 中 VIP 路径状态标注准确,接口字段与实现一致。 -- 数据可见性:非VIP仅显示近两个月数据;VIP显示全部历史;可通过系统参数动态调整非VIP留存天数。 -- 一键支付:点击立即开通可直接成为VIP并设置到期时间;有效期内再次开通实现续期(如有设计)。 -- 价格设置:管理员可在“VIP管理”中读取/修改VIP价格,保存后前端价格展示与后端一致。 - -#### 13. 普通管理员(Admin-Lite)与申请机制(新增需求) - -- 背景:在现有平台管理员(`admins` 表)之外,新增“普通管理员(Normal Admin)”,供用户端的 VIP 用户申请成为普通管理员后,登录一个精简的普通管理端(admin-lite)。 -- 目标: - - 普通管理端仅包含“配件审核”与“我的”两个功能。 - - 普通管理员使用“用户端账号(邮箱 + 密码)”登录普通管理端。 - - 申请资格:仅限 VIP(且建议要求处于有效期内)。 - -##### 13.1 业务规则 -- 申请前置:用户必须为 VIP 用户(建议校验 `vip_users.status=1` 且 `expire_at>now()`)。 -- 审批策略(多方案): - - 方案A(推荐)平台审核:提交申请→平台管理员审核→通过后赋予“普通管理员”权限。 - - 方案B 自动通过:成为 VIP 后点击“申请”即自动获取权限(适合早期冷启动)。 - - 方案C 店主审核:若后续引入“店主/员工”角色体系,可由店主审批本店普通管理员。 -- 有效性约束(推荐):普通管理员权限与 VIP 状态绑定;当 VIP 过期则自动失去普通管理员权限(或进入“受限”状态,仅保留查看)。 -- 范围隔离:普通管理员仅可访问其所属店铺 `shop_id` 范围内的资源(含配件提交与审核)。 - -##### 13.2 数据模型(多方案) -- 方案A:复用 `users.role`,并新增审计表(快速落地) - - `users.role` 可写入 `normal_admin` 作为标识;新增 `normal_admin_audits` 记录申请、审批与撤销流水。 - - 优点:改动小,落地快。 - - 风险:`role` 单值扩展性差,后续多角色并存不便。 -- 方案B:新增 `user_roles`(推荐中长期) - - 结构:`id, user_id, role, status, created_at, updated_at`,可并存多个角色;配套 `user_role_audits` 留痕。 - - 优点:扩展性强,便于细粒度权限。 - - 成本:需要通用角色判定与拦截器改造。 -- 方案C:新增 `normal_admins` - - 结构:`id, shop_id, user_id, status, expire_at(可选), remark, reviewer_id, reviewed_at, created_at, updated_at` - - 优点:语义清晰;可直接与 VIP 绑定。 - - 成本:专用表+专用逻辑,通用性略弱。 - -当前建议:MVP 采用方案A或C(二选一),优先保证上线速度;后续演进到方案B以支撑更多角色与权限。 - -配置项(严禁硬编码,写入 `system_parameters` 或全局参数表): -- `normalAdmin.autoApprove`:是否自动通过申请(默认 false)。 -- `normalAdmin.requiredVipActive`:是否要求 VIP 有效期内(默认 true)。 - -##### 13.3 接口契约(开始实现任一侧后同步登记至 `/doc/openapi.yaml` 并标注状态) -- 用户端(App/小程序): - - POST `/api/normal-admin/apply`:提交申请。需要登录且满足 VIP 条件;根据 `normalAdmin.autoApprove` 决定是否即时生效。 - - GET `/api/normal-admin/application/status`:查询本人申请状态与生效情况。 -- 平台管理端: - - GET `/api/admin/normal-admin/applications`:申请列表(支持状态/关键词/时间范围)。 - - POST `/api/admin/normal-admin/applications/{id}/approve`:审批通过。 - - POST `/api/admin/normal-admin/applications/{id}/reject`:审批驳回(需备注)。 -- 普通管理端鉴权(见 13.4): - - 登录沿用用户侧认证:`POST /api/auth/email/login` 或现有登录接口,后端签发 Token 时加入 `scope`/`role` 标识(如 `normal_admin`)。 - -示例返回(申请状态): -```json -{ "status": "approved", "isVip": true, "vipExpireAt": "2025-12-31T23:59:59Z" } -``` - -##### 13.4 鉴权与拦截(Admin-Lite) -- 新增拦截器 `NormalAdminAuthInterceptor`(或等效过滤器): - - 校验登录 Token 为用户侧 Token;读取 `role/scope` 含 `normal_admin`。 - - 若启用 `normalAdmin.requiredVipActive`:同时校验 `vip_users` 有效。 - - 强制多租户隔离:将 `shop_id` 固化为当前用户所属店铺,杜绝越权访问。 -- 路由命名建议:`/api/normal-admin/**` 与平台 `/api/admin/**` 区隔;服务层可复用同一业务逻辑。 -- 兼容方案:若短期沿用现有 `/api/admin/parts/submissions*`,需在后端根据 Token 类型与 `shop_id` 自动收敛到本店数据(不建议长期共用)。 - -##### 13.5 普通管理端(admin-lite)前端 -- 技术栈:Vue3 + Vite + Element Plus,与现管理端一致;新建 `normal-admin/` 工程或在现有 `admin/` 下以独立入口构建。 -- 模块: - - 配件审核:复制 `admin/src/views/parts/Submissions.vue`,仅保留必要字段与操作;HTTP 基址指向 `/api/normal-admin/...`(或复用后端别名);列表限定为本店数据。 - - 我的:展示账户信息、VIP 状态、普通管理员状态、退出登录;若未获批且为 VIP,显示“申请成为普通管理员”按钮(调用 `/api/normal-admin/apply`)。 -- 路由示例: - - `/` → 重定向 `/parts/submissions` - - `/parts/submissions` → 审核页 - - `/my` → 我的 -- 配置(环境变量,不得硬编码): - - `VITE_APP_TITLE=配件审核(普通管理端)` - - `VITE_API_BASE=/api`(与部署网关一致) - - `VITE_ENABLE_EXPORT=true/false`(是否开启导出) - -##### 13.6 流程图(Graphviz) -```dot -digraph G { - rankdir=LR; - node [shape=box, style=rounded]; - a[label="成为VIP\n(GET /api/vip/status)"]; - b[label="申请普通管理员\n(POST /api/normal-admin/apply)"]; - c[label="平台审批/自动通过\n(根据 normalAdmin.autoApprove)"]; - d[label="获得 normal_admin 权限\n(Token 含 scope)"]; - e[label="登录普通管理端\n(邮箱+密码)"]; - f[label="配件审核\n(/api/normal-admin/parts/submissions*)"]; - - a -> b -> c -> d -> e -> f; -} -``` - -##### 13.7 验收标准(Admin-Lite) -- 登录:使用用户邮箱+密码成功登录普通管理端;非普通管理员登录被拒绝并提示申请。 -- 资格:非 VIP 点击“申请”提示需先成为 VIP;VIP 点击“申请”按配置自动通过或进入待审;审批通过后刷新权限可访问。 -- 范围:仅能查看与操作本店 `shop_id` 范围内的配件提交;越权访问被拦截。 -- 审核:通过/驳回、备注、图片管理、参数编辑等操作与现管理端一致;操作成功刷新列表。 -- 导出(可选):按配置开放导出功能,文件名与权限口径与现实现一致。 -- 退化:当 VIP 失效时,若配置为强绑定,应在下一次鉴权时移除 `normal_admin` 访问权限(或降级为只读)。 - -##### 13.8 实施计划(补充) -1) 后端: - - 新增 `NormalAdminAuthInterceptor` 与 `/api/normal-admin/**` 路由;实现申请接口与(可选)平台审批接口;复用配件审核服务,默认按当前用户 `shop_id` 限定范围。 - - Token 扩展:在用户侧登录成功时,根据数据库角色写入 `role/scope=normal_admin`。 - - 配置读取:接入 `normalAdmin.autoApprove`、`normalAdmin.requiredVipActive`(来自 `system_parameters` 或全局参数表)。 -2) 前端(admin-lite): - - 建立最小可用工程,复制并精简 `Submissions.vue`;新增 `My.vue`;完善登录页与路由守卫(未授权跳转登录)。 - - “我的”页集成申请按钮与状态显示;成功后刷新权限并跳转审核页。 -3) 文档与 OpenAPI: - - 任一侧开工后,将上述接口登记至 `/doc/openapi.yaml` 并标注实现状态(❌/✅)。 - -##### 13.9 风险与注意事项 -- 权限收敛:严禁普通管理员访问跨店铺数据;所有查询/导出在服务层统一追加 `shop_id=当前用户.shop_id` 条件。 -- 身份冲突:若同一用户既是平台管理员又是普通管理员,需优先以平台后台入口登录对应端;两个端的 Token/域名/路由需区分。 -- 续期与降级:明确 VIP 失效后的权限处理策略与用户提示文案,避免误用。 -- 审计留痕:申请/审批/撤销必须入审计表;审核操作记录操作人与时间。 - -##### 13.10 选型落地(方案A) -- 选型结论: - - 审批策略采用「方案A(平台审核)」:提交申请→平台管理员审核→通过后赋予“普通管理员”权限。 - - 数据模型采用「方案A(复用 `users.role` + 审计表)」:当前生效权限以 `users.role` 为准,申请/审批/撤销全量留痕于审计表。 - -- 审计表 DDL(提交后通过 MysqlMCP 执行;此处为权威定义,禁止硬编码): -```sql -CREATE TABLE IF NOT EXISTS normal_admin_audits ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - shop_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - action ENUM('apply','approve','reject','revoke','expire') NOT NULL, - remark VARCHAR(255) NULL, - operator_admin_id BIGINT UNSIGNED NULL COMMENT '平台管理员ID(apply时可空)', - previous_role VARCHAR(32) NULL, - new_role VARCHAR(32) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY idx_naudit_shop_time (shop_id, created_at), - KEY idx_naudit_user_time (user_id, created_at), - CONSTRAINT fk_naudit_shop FOREIGN KEY (shop_id) REFERENCES shops(id), - CONSTRAINT fk_naudit_user FOREIGN KEY (user_id) REFERENCES users(id), - CONSTRAINT fk_naudit_admin FOREIGN KEY (operator_admin_id) REFERENCES admins(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='普通管理员申请/审批审计日志'; -``` - -- 状态判定与待办列表(基于审计流,不单独建申请表): - - 当前状态 = 按 `user_id` 取最后一条审计记录的 `action`。 - - 待审批 = 最后一条为 `apply` 且其后没有 `approve/reject`。 - - 失效处理 = 定时或登录时检测 VIP 过期,若配置 `normalAdmin.requiredVipActive=true`,写入 `expire` 审计并触发降级(见下)。 - -- 接口约定细化(开始实现任一侧后再登记至 `/doc/openapi.yaml` 并标注状态): - - 用户申请:POST `/api/normal-admin/apply` - - 校验:已登录 + VIP 有效(当 `requiredVipActive=true`)。 - - 行为:写入 `normal_admin_audits(action='apply')`;若 `autoApprove=true` 则转调用审批通过流程。 - - 审批列表:GET `/api/admin/normal-admin/applications` - - 规则:按照审计记录推导“当前为 apply 且未被approve/reject”的记录;支持关键词与时间过滤;返回 `{ list, total }`。 - - 审批通过:POST `/api/admin/normal-admin/applications/{userId}/approve` - - 行为:读取当前 `users.role` 记为 `previous_role`;更新 `users.role='normal_admin'`;写入审计 `approve`(含 `previous_role/new_role`)。 - - 审批驳回:POST `/api/admin/normal-admin/applications/{userId}/reject` - - 行为:写入审计 `reject`(需 remark)。 - - 撤销权限:POST `/api/admin/normal-admin/users/{userId}/revoke` - - 行为:将 `users.role` 回滚到最近一次 `approve` 的 `previous_role`(若不存在则设为 `'staff'` 或根据 `is_owner` 退回 `'owner'`);写入审计 `revoke`。 - -- 鉴权与Token(Admin-Lite): - - 登录沿用用户端登录接口(邮箱/密码),签发 Token 时附带 `role` 与 `shopId`;Admin-Lite 入口要求 `role='normal_admin'`。 - - 在拦截器 `NormalAdminAuthInterceptor` 中: - - 校验 `role='normal_admin'`;若 `requiredVipActive=true`,同时校验当前用户 VIP 有效。 - - 将请求上下文中的 `shop_id` 固化为当前用户 `shop_id`,所有查询自动拼接该条件。 - -- 失效与回滚策略: - - 当 VIP 过期(检测点:每日任务/登录/接口访问拦截),若启用强绑定则触发降级: - - 将 `users.role` 由 `normal_admin` 回滚至上次 `approve` 的 `previous_role`(若无记录:`is_owner=1` 回滚到 `'owner'`,否则 `'staff'`)。 - - 写入 `expire` 审计(remark 记录触发缘由)。 - -- 风险与缓解(方案A局限): - - `users.role` 为单值字段,无法并存多角色;短期仅用于识别 Admin-Lite 访问权限,不影响用户侧功能(用户侧权限建议依赖 `is_owner` 与接口授权)。 - - 后续若需多角色并存,按 13.2 的方案B 迁移至 `user_roles`,并在迁移期间保留写入 `users.role` 的兼容逻辑。 - -- 测试要点补充: - - 申请→审批通过:审计有 `apply`、`approve` 两条,`users.role` 变为 `normal_admin`,Admin-Lite 可登录访问。 - - 申请→驳回:审计有 `apply`、`reject`,`users.role` 不变,Admin-Lite 拒绝访问。 - - 撤销:`users.role` 回滚为 `previous_role`,新增 `revoke` 审计;再次访问被拒绝。 - - VIP 过期:若强绑定开启,触发降级并写入 `expire`,Admin-Lite 访问被拒绝;关闭强绑定时仅告警不降级。 - -—— 本文档为当前功能实现说明与落地细节,不含历史变更记录;如有与数据模型不匹配之处,以 `/doc/database_documentation.md` 为准并及时校准。 diff --git a/doc/后端使用文档.md b/doc/后端使用文档.md deleted file mode 100644 index 87e18f6..0000000 --- a/doc/后端使用文档.md +++ /dev/null @@ -1,139 +0,0 @@ -https://icons8.com/ - -### 后端使用文档(简版) - -本文件用于指导在新电脑上启动 Spring Boot 后端,并直接连接远程 MySQL 数据库。 - -### 环境要求 -- **操作系统**: Windows 10/11(PowerShell) -- **JDK**: 17 及以上(`java -version` 应显示 17+) -- **网络**: 可访问 `mysql.tonaspace.com:3306` -- **构建工具**: 无需单独安装 Maven(项目已提供 `mvnw.cmd`) - -### 快速启动(默认连接远程库) -1) 打开 PowerShell,进入项目后端目录: -```powershell -cd backend -``` -2) 启动后端(使用默认远程数据库配置): -```powershell -.\mvnw.cmd spring-boot:run -DskipTests -``` -> 说明:`application.properties` 已内置远程库默认值(`DB_URL/DB_USER/DB_PASSWORD`)。除非你的终端已设置了同名环境变量并想覆盖,否则无需再配置。 - -### 启动 Python 条码识别服务(可选) - -本项目提供 Python 条码识别服务(FastAPI,目录 `backend/txm`),可与 Java 同时启动或独立启动。 - -- 独立启动(推荐先验证): -```powershell -cd .\backend\txm; python -m pip install -r requirements.txt; python -m app.server.main -``` - -- 随 Java 一并启动(通过环境变量启用;默认关闭): -```powershell -$env:PY_BARCODE_ENABLED="true"; $env:PY_BARCODE_WORKDIR=".\txm"; $env:PY_BARCODE_PYTHON="python"; .\mvnw.cmd spring-boot:run -DskipTests -``` - -可覆盖的相关配置键(同名环境变量可覆盖,括号为默认值): - -``` -python.barcode.enabled=${PY_BARCODE_ENABLED:false} -python.barcode.working-dir=${PY_BARCODE_WORKDIR:./txm} -python.barcode.python=${PY_BARCODE_PYTHON:python} -python.barcode.app-module=${PY_BARCODE_APP_MODULE:app.server.main} -python.barcode.use-module-main=${PY_BARCODE_USE_MODULE:true} -python.barcode.host=${PY_BARCODE_HOST:127.0.0.1} -python.barcode.port=${PY_BARCODE_PORT:8000} -python.barcode.health-path=${PY_BARCODE_HEALTH:/openapi.json} -python.barcode.startup-timeout-sec=${PY_BARCODE_TIMEOUT:20} -python.barcode.log-file=${PY_BARCODE_LOG:} -``` - -Java 侧代理接口:`POST /api/barcode/scan`(表单字段名:`file`)。 -返回: - -``` -{ - success: true, - barcodeType: 'EAN13' | 'CODE128' | 'QRCODE' | ..., - barcode: '字符串', - others: [{ type, code }, ...] // 可能为空 -} -``` - -说明:优先 EAN-13,否则返回任意码制的第一个结果,并同时返回 others。 - -### 可选:显式指定远程数据库(避免被旧环境变量覆盖) -如需显式声明一次连接信息(建议在怀疑本机已有旧变量时使用): -```powershell -$env:DB_URL="jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci"; $env:DB_USER="root"; $env:DB_PASSWORD="TONA1234" -.\mvnw.cmd spring-boot:run -DskipTests -``` - -### 启动验证 -- 浏览器访问:`http://localhost:8080/api/dashboard/overview` - - 返回概览数据即表示服务与数据库连接正常 - -### 按用户ID登录(用户端快速登录通道) -> 仅在调试或特定场景启用。默认关闭。 - -1) 启用开关(临时): -```powershell -$env:AUTH_ID_LOGIN_ENABLED="true"; .\mvnw.cmd spring-boot:run -DskipTests -``` -2) 请求示例: -```http -POST http://localhost:8080/api/auth/login-by-id -Content-Type: application/json - -{ "userId": 2 } -``` -3) 成功返回:`{ token, expiresIn, user:{ userId, shopId, phone? } }` -4) 之后在调用业务接口时携带:`Authorization: Bearer ` - -### 常见问题 -- **端口被占用**:更换启动端口 -```powershell -.\mvnw.cmd spring-boot:run -DskipTests -Dserver.port=8081 -``` -- **远程库连不通**:检查网络是否放行 `mysql.tonaspace.com:3306`;如在公司网络,确认代理/防火墙策略已放通。 -- **Java 未安装或版本不符**:安装 JDK 17,并在新开终端内确认 `java -version`。 - -### 可选设置 -- 占位图(非必需):若需启用 `/api/attachments/placeholder` -```powershell -$env:ATTACHMENTS_PLACEHOLDER_IMAGE="C:\Users\Public\Pictures\placeholder.png" -``` -- 前端联调 CORS(按需): -```powershell -$env:CORS_ALLOWED_ORIGINS="http://localhost:5173" -``` - -### 可选:打包为可执行 JAR -```powershell -cd backend; .\mvnw.cmd clean package -DskipTests; java -jar .\target\demo-0.0.1-SNAPSHOT.jar -``` - -### SMTP 邮件配置(Windows PowerShell) - -请在启动后端前设置以下环境变量(QQ 邮箱): - -```powershell -$env:MAIL_HOST="smtp.qq.com"; $env:MAIL_PORT="465"; $env:MAIL_PROTOCOL="smtps"; $env:MAIL_USERNAME="sdssds@163.com"; $env:MAIL_PASSWORD="NQLihrab8vGiAjiE"; $env:MAIL_FROM="sdssds@163.com"; $env:MAIL_SUBJECT_PREFIX="[配件查询]" -``` - -说明: -- MAIL_USERNAME/MAIL_FROM:发件邮箱地址 -- MAIL_PASSWORD:SMTP 授权码 -- 采用 465 + SMTPS + SSL;若使用 587,请改为 `MAIL_PORT=587` 并设置 `spring.mail.properties.mail.smtp.starttls.enable=true`。 - -### 邮箱验证码接口 -- POST `/api/auth/email/send`:请求体 `{ email, scene? }`,成功返回 `{ ok, cooldownSec }` -- POST `/api/auth/email/login`:请求体 `{ email, code }`,成功返回 `{ token, expiresIn, user }` - -返回的 JWT 通过 `Authorization: Bearer ` 使用,解析后包含 `userId/shopId/email/provider` 等声明。 - -以上即为在新电脑上启动后端并连接远程数据库的最小步骤。 - - diff --git a/doc/开发进度与问题.md b/doc/开发进度与问题.md deleted file mode 100644 index 2075346..0000000 --- a/doc/开发进度与问题.md +++ /dev/null @@ -1,35 +0,0 @@ -## 当前开发进度概览(2025-09-27) - -### 一、后端(Spring Boot) -- **会员体系**:已实现 `vip_users`、`vip_price`、`vip_recharges` CRUD 及接口 `/api/vip/*`、`/api/admin/vip/*`。 -- **公告管理**:`/api/admin/notices` 完整可用,支持创建/编辑/发布/下线。 -- **附件系统**:`/api/attachments` 支持本地存储、hash 去重与 URL 校验;用户与管理端均在使用。 -- **条码代理**:`/api/barcode/scan` 代理 Python TXM 服务,需人工启动 Python 端。 -- **鉴权机制**:管理员接口仍依赖 `X-Admin-Id` 兼容头;JWT 登录接口已实现但尚未前端集成。 - -### 二、管理端(Vue 3 + Element Plus) -- 已上线模块:VIP 系统、VIP 列表、公告管理、咨询回复、用户管理、配件管理、主数据字典、**配件审核(新增 Submissions 页面,支持筛选/详情/通过/驳回/导出)**。 -- 尚缺模块:登录页、角色权限细分、操作日志可视化。 -- 交互问题:表格分页统一使用后端分页参数,个别页面(如 VIP 列表)暂未展示 total,需要补齐。 - -### 三、移动端(uni-app) -- **主流程**:商品、订单、客户、供应商、报表等基础功能可用。 -- **登录/注册**:`pages/auth/login.vue` 新增邮箱密码登录、验证码注册、忘记密码三合一页面,调用 `/api/auth/password/login`、`/api/auth/email/register`、`/api/auth/email/reset-password`;验证码发送支持 register/reset 场景。 -- **配件提交(新增)**:`pages/product/submit.vue`、`pages/product/submissions.vue`、`pages/product/submission-detail.vue` 已接入配件提交、列表、详情;支持多图上传、参数 JSON、驳回重新提交,与后端 `/api/products/submissions*` 系列接口对齐。 -- **VIP 页面**:`pages/my/vip.vue` 已接入状态查询与一键开通,待接续续费提示与权益引导。 -- **咨询入口**:请求与管理端对接正常,尚缺悬浮入口。 -- **扫码**:仅 App 端调用条码识别,小程序端提示不支持扫码。 - -### 四、数据库 -- 远程库包含 VIP、附件等新增表,本地脚本 `backend/db/db.sql` 尚未同步;执行结构变更需通过 MysqlMCP 并手动更新脚本。 -- 需追加的建表脚本:`vip_users`、`vip_price`、`vip_recharges`、`attachments` 等。 - -### 五、待解决问题 -1. **管理员登录上线**:前端需接入 `/api/admin/auth/login` 并替换本地 ADMIN_ID 写死逻辑。 -2. **VIP 续期逻辑**:`/api/vip/pay` 当前覆盖式设置到期时间,需确认是否改为顺延有效期并记录 `expire_from`。 -3. **公告富文本**:现为纯文本,若需富文本需评估安全策略。 -4. **前端分页补充**:管理端表格统一展示 total 并接入分页控件。 -5. **数据库脚本同步**:更新 `backend/db/db.sql` 与 doc 文档保持一致,避免新环境缺表。 -6. **条码服务部署**:需编写部署说明,确定 Python TXM 服务是否随 Java 进程自动启动。 -7. **登录方式文档同步**:OpenAPI 已新增 `/api/auth/email/reset-password`,前端登录页三流程已实现,需对外文档同步展示入口与约束。 -8. **配件提交流程验收**:需补充测试用例(提交→审核→导出),并确认型号唯一策略是否支持多店共享或全局唯一。 diff --git a/doc/数据库设计文档-核心业务表.md b/doc/数据库设计文档-核心业务表.md new file mode 100644 index 0000000..19365b9 --- /dev/null +++ b/doc/数据库设计文档-核心业务表.md @@ -0,0 +1,997 @@ +# 配件查询系统 - 数据库设计文档(核心业务表) + +**版本**: 1.0 +**更新时间**: 2025-10-01 +**数据库**: MySQL 8.0 +**字符集**: utf8mb4 / utf8mb4_0900_ai_ci + +--- + +## 一、租户与用户体系 + +### 1.1 shops(店铺/租户) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| name | VARCHAR(100) | NO | | 店铺名称 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_shops_status` (`status`) + +**说明**:多租户隔离的核心表,所有业务数据必须关联shop_id。 + +--- + +### 1.2 users(用户) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 所属店铺 | +| phone | VARCHAR(32) | YES | NULL | 手机号(全局唯一) | +| email | VARCHAR(128) | YES | NULL | 邮箱(全局唯一) | +| avatar_url | VARCHAR(512) | YES | NULL | 头像URL | +| name | VARCHAR(64) | NO | | 用户姓名 | +| role | VARCHAR(32) | NO | 'staff' | 角色:owner/staff/normal_admin | +| password_hash | VARCHAR(255) | YES | NULL | 密码哈希 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| is_owner | TINYINT(1) | NO | 0 | 是否店主 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_users_shop` (`shop_id`) +- UNIQUE: `uk_users_phone` (`phone`) +- UNIQUE: `ux_users_email` (`email`) + +**外键**: +- `fk_users_shop`: `shop_id` → `shops(id)` + +**说明**:支持手机号、邮箱双登录方式;role字段支持普通管理员权限标识。 + +--- + +### 1.3 admins(平台管理员) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| username | VARCHAR(64) | NO | | 登录名 | +| phone | VARCHAR(32) | YES | NULL | 手机号 | +| password_hash | VARCHAR(255) | YES | NULL | 密码哈希 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- UNIQUE: `ux_admins_username` (`username`) +- UNIQUE: `ux_admins_phone` (`phone`) + +**说明**:平台管理员不归属任何店铺,可跨租户管理数据。 + +--- + +## 二、商品与库存体系 + +### 2.1 products(商品) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 所属店铺 | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(120) | NO | | 商品名称 | +| category_id | BIGINT UNSIGNED | YES | NULL | 类别ID | +| template_id | BIGINT UNSIGNED | YES | NULL | 配件模板ID | +| brand | VARCHAR(64) | YES | NULL | 品牌 | +| model | VARCHAR(64) | YES | NULL | 型号 | +| spec | VARCHAR(128) | YES | NULL | 规格 | +| origin | VARCHAR(64) | YES | NULL | 产地 | +| barcode | VARCHAR(32) | YES | NULL | 条码 | +| dedupe_key | VARCHAR(512) | YES | NULL | 去重键 | +| alias | VARCHAR(120) | YES | NULL | 别名 | +| is_blacklisted | TINYINT(1) | NO | 0 | 黑名单标记 | +| description | TEXT | YES | NULL | 描述 | +| global_sku_id | BIGINT UNSIGNED | YES | NULL | 全局SKU ID | +| source_submission_id | BIGINT UNSIGNED | YES | NULL | 来源提交ID | +| attributes_json | JSON | YES | NULL | 扩展属性JSON | +| safe_min | DECIMAL(18,3) | YES | NULL | 安全库存下限 | +| safe_max | DECIMAL(18,3) | YES | NULL | 安全库存上限 | +| search_text | TEXT | YES | NULL | 全文检索聚合字段 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | +| is_active | TINYINT | YES | | 计算字段:deleted_at IS NULL | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_products_shop` (`shop_id`) +- KEY: `idx_products_category` (`category_id`) +- KEY: `idx_products_template` (`template_id`) +- KEY: `idx_products_dedupe` (`dedupe_key`) +- KEY: `idx_products_shop_blacklist` (`shop_id`, `is_blacklisted`) +- FULLTEXT: `ft_products_search` (`name`, `brand`, `model`, `spec`, `search_text`) +- UNIQUE: `ux_products_shop_barcode` (`shop_id`, `barcode`) +- UNIQUE: `ux_products_template_name_model` (`template_id`, `name`, `model`) + +**外键**: +- `fk_products_shop`: `shop_id` → `shops(id)` +- `fk_products_user`: `user_id` → `users(id)` +- `fk_products_category`: `category_id` → `product_categories(id)` +- `fk_products_template`: `template_id` → `part_templates(id)` +- `fk_products_globalsku`: `global_sku_id` → `global_skus(id)` + +**说明**:核心商品表,支持模板化参数、全文检索、去重和黑名单管理。 + +--- + +### 2.2 product_prices(商品价格) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| product_id | BIGINT UNSIGNED | NO | | 商品ID(主键) | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 最后修改人 | +| purchase_price | DECIMAL(18,2) | NO | 0.00 | 进货价 | +| retail_price | DECIMAL(18,2) | NO | 0.00 | 零售价 | +| wholesale_price | DECIMAL(18,2) | NO | 0.00 | 批发价 | +| big_client_price | DECIMAL(18,2) | NO | 0.00 | 大客户价 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `product_id` +- KEY: `idx_prices_shop` (`shop_id`) + +**外键**: +- `fk_prices_product`: `product_id` → `products(id)` ON DELETE CASCADE +- `fk_prices_shop`: `shop_id` → `shops(id)` +- `fk_prices_user`: `user_id` → `users(id)` + +**约束**:所有价格字段 >= 0 + +**说明**:四列销售价格支持不同客户等级定价策略。 + +--- + +### 2.3 inventories(库存) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| product_id | BIGINT UNSIGNED | NO | | 商品ID(主键) | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 最后修改人 | +| quantity | DECIMAL(18,3) | NO | 0.000 | 库存数量 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `product_id` +- KEY: `idx_inventories_shop` (`shop_id`) + +**外键**: +- `fk_inv_product`: `product_id` → `products(id)` ON DELETE CASCADE +- `fk_inv_shop`: `shop_id` → `shops(id)` +- `fk_inv_user`: `user_id` → `users(id)` + +**约束**:quantity >= 0 + +**说明**:支持小数精度(3位),适配多种计量单位。 + +--- + +### 2.4 product_categories(商品类别) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID(0=全局) | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(64) | NO | | 类别名称 | +| parent_id | BIGINT UNSIGNED | YES | NULL | 父类别ID | +| sort_order | INT | NO | 0 | 排序 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_categories_shop` (`shop_id`) +- KEY: `idx_categories_parent` (`parent_id`) +- UNIQUE: `ux_categories_shop_name` (`shop_id`, `name`) + +**外键**: +- `fk_categories_shop`: `shop_id` → `shops(id)` +- `fk_categories_user`: `user_id` → `users(id)` +- `fk_categories_parent`: `parent_id` → `product_categories(id)` + +**说明**:shop_id=0为全局字典,所有租户共享。 + +--- + +### 2.5 product_units(商品单位) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID(0=全局) | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(16) | NO | | 单位名称 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_units_shop` (`shop_id`) +- UNIQUE: `ux_units_shop_name` (`shop_id`, `name`) + +**外键**: +- `fk_units_shop`: `shop_id` → `shops(id)` +- `fk_units_user`: `user_id` → `users(id)` + +**说明**:shop_id=0为全局字典,所有租户共享。 + +--- + +### 2.6 product_images(商品图片) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 上传人 | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| url | VARCHAR(512) | NO | | 图片URL | +| hash | VARCHAR(64) | YES | NULL | 内容哈希(去重) | +| sort_order | INT | NO | 0 | 排序 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_product_images_product` (`product_id`) +- UNIQUE: `ux_product_image_hash` (`product_id`, `hash`) + +**外键**: +- `fk_pimg_shop`: `shop_id` → `shops(id)` +- `fk_pimg_user`: `user_id` → `users(id)` +- `fk_pimg_product`: `product_id` → `products(id)` ON DELETE CASCADE + +**说明**:通过hash实现图片去重。 + +--- + +### 2.7 product_aliases(商品别名) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| alias | VARCHAR(120) | NO | | 别名 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_product_alias_product` (`product_id`) +- UNIQUE: `ux_product_alias` (`product_id`, `alias`) + +**外键**: +- `fk_alias_shop`: `shop_id` → `shops(id)` +- `fk_alias_user`: `user_id` → `users(id)` +- `fk_alias_product`: `product_id` → `products(id)` + +**说明**:支持商品多别名检索,触发器自动同步到search_text。 + +--- + +## 三、配件审核与模板体系 + +### 3.1 part_submissions(配件提交) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 提交店铺 | +| user_id | BIGINT UNSIGNED | NO | | 提交用户 | +| name | VARCHAR(120) | YES | NULL | 配件名称 | +| external_code | VARCHAR(64) | YES | NULL | 外部编码 | +| model_unique | VARCHAR(128) | NO | | 规范化型号(唯一) | +| brand | VARCHAR(64) | YES | NULL | 品牌 | +| spec | VARCHAR(128) | YES | NULL | 规格 | +| unit_id | BIGINT UNSIGNED | YES | NULL | 单位 | +| category_id | BIGINT UNSIGNED | YES | NULL | 类别 | +| template_id | BIGINT UNSIGNED | YES | NULL | 模板 | +| tags | JSON | YES | NULL | 标签 | +| attributes | JSON | YES | NULL | 参数JSON | +| images | JSON | YES | NULL | 图片URL数组 | +| size | VARCHAR(64) | YES | NULL | 尺寸(兼容) | +| aperture | VARCHAR(64) | YES | NULL | 孔径(兼容) | +| compatible | TEXT | YES | NULL | 适配信息 | +| barcode | VARCHAR(64) | YES | NULL | 条码 | +| dedupe_key | VARCHAR(512) | YES | NULL | 去重键 | +| remark | TEXT | YES | NULL | 备注 | +| status | ENUM('pending','approved','rejected') | NO | 'pending' | 审核状态 | +| reviewer_id | BIGINT UNSIGNED | YES | NULL | 审核人 | +| product_id | BIGINT UNSIGNED | YES | NULL | 关联商品ID | +| global_sku_id | BIGINT UNSIGNED | YES | NULL | 关联全局SKU | +| reviewed_at | DATETIME | YES | NULL | 审核时间 | +| review_remark | VARCHAR(255) | YES | NULL | 审核备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_ps_shop` (`shop_id`) +- KEY: `idx_ps_user` (`user_id`) +- KEY: `idx_ps_brand` (`brand`) +- KEY: `idx_ps_status` (`status`) +- KEY: `idx_ps_template` (`template_id`) +- KEY: `idx_ps_dedupe` (`dedupe_key`) +- KEY: `idx_ps_created_at` (`created_at`) +- UNIQUE: `ux_part_model_unique` (`model_unique`) +- UNIQUE: `ux_ps_template_name_model` (`template_id`, `name`, `model_unique`) + +**外键**: +- `fk_ps_shop`: `shop_id` → `shops(id)` +- `fk_ps_user`: `user_id` → `users(id)` +- `fk_ps_reviewer`: `reviewer_id` → `admins(id)` +- `fk_ps_product`: `product_id` → `products(id)` +- `fk_ps_global_sku`: `global_sku_id` → `global_skus(id)` +- `fk_ps_template`: `template_id` → `part_templates(id)` + +**说明**:用户提交配件数据,审核通过后生成products记录。model_unique全局唯一。 + +--- + +### 3.2 part_templates(配件模板) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| category_id | BIGINT UNSIGNED | NO | | 绑定类别 | +| name | VARCHAR(120) | NO | | 配件名 | +| model_rule | VARCHAR(255) | YES | NULL | 型号规则说明 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| created_by_admin_id | BIGINT UNSIGNED | YES | NULL | 创建管理员 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_pt_category` (`category_id`) +- KEY: `idx_pt_status` (`status`) +- KEY: `idx_pt_admin` (`created_by_admin_id`) +- KEY: `idx_part_templates_deleted_at` (`deleted_at`) + +**外键**: +- `fk_pt_category`: `category_id` → `product_categories(id)` +- `fk_pt_admin`: `created_by_admin_id` → `admins(id)` + +**说明**:配件模板定义,关联多个参数字段。 + +--- + +### 3.3 part_template_params(模板参数) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| template_id | BIGINT UNSIGNED | NO | | 所属模板 | +| field_key | VARCHAR(64) | NO | | 参数键 | +| field_label | VARCHAR(120) | NO | | 参数名(展示) | +| type | ENUM('string','number','boolean','enum','date') | NO | | 参数类型 | +| required | TINYINT(1) | NO | 0 | 是否必填 | +| unit | VARCHAR(32) | YES | NULL | 单位 | +| enum_options | JSON | YES | NULL | 枚举选项 | +| searchable | TINYINT(1) | NO | 0 | 参与检索 | +| fuzzy_searchable | TINYINT(1) | NO | 0 | 可模糊查询(数值) | +| fuzzy_tolerance | DECIMAL(18,6) | YES | NULL | 容差(NULL=平台默认) | +| dedupe_participate | TINYINT(1) | NO | 0 | 参与去重 | +| sort_order | INT | NO | 0 | 排序 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_ptp_template` (`template_id`) +- KEY: `idx_ptp_sort` (`template_id`, `sort_order`) +- UNIQUE: `ux_ptp_field_key` (`template_id`, `field_key`) + +**外键**: +- `fk_ptp_template`: `template_id` → `part_templates(id)` ON DELETE CASCADE + +**说明**:定义模板的参数字段,支持类型化、验证和模糊搜索。 + +--- + +### 3.4 global_skus(全局SKU) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| name | VARCHAR(120) | NO | | SKU名称 | +| brand | VARCHAR(64) | YES | NULL | 品牌 | +| model | VARCHAR(64) | YES | NULL | 型号 | +| spec | VARCHAR(128) | YES | NULL | 规格 | +| barcode | VARCHAR(32) | YES | NULL | 条码 | +| unit_id | BIGINT UNSIGNED | YES | NULL | 单位 | +| tags | JSON | YES | NULL | 标签 | +| status | ENUM('published','offline') | NO | 'published' | 状态 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_global_skus_brand_model` (`brand`, `model`) +- UNIQUE: `ux_global_skus_barcode` (`barcode`) + +**外键**: +- `fk_globalsku_unit`: `unit_id` → `product_units(id)` + +**说明**:全局共享的商品库,供各租户引用。 + +--- + +## 四、客户与供应商 + +### 4.1 customers(客户) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(120) | NO | | 客户名称 | +| contact_name | VARCHAR(64) | YES | NULL | 联系人 | +| mobile | VARCHAR(32) | YES | NULL | 手机 | +| phone | VARCHAR(32) | YES | NULL | 座机 | +| address | VARCHAR(255) | YES | NULL | 送货地址 | +| price_level | ENUM('零售价','批发价','大单报价') | NO | '零售价' | 默认价格等级 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| ar_opening | DECIMAL(18,2) | NO | 0.00 | 期初应收 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_customers_shop` (`shop_id`) +- KEY: `idx_customers_phone` (`phone`) +- KEY: `idx_customers_mobile` (`mobile`) + +**外键**: +- `fk_customers_shop`: `shop_id` → `shops(id)` +- `fk_customers_user`: `user_id` → `users(id)` + +**说明**:price_level关联product_prices的四列价格之一。 + +--- + +### 4.2 suppliers(供应商) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(120) | NO | | 供应商名称 | +| contact_name | VARCHAR(64) | YES | NULL | 联系人 | +| mobile | VARCHAR(32) | YES | NULL | 手机 | +| phone | VARCHAR(32) | YES | NULL | 电话 | +| address | VARCHAR(255) | YES | NULL | 经营地址 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| ap_opening | DECIMAL(18,2) | NO | 0.00 | 期初应付 | +| ap_payable | DECIMAL(18,2) | NO | 0.00 | 当前应付(实时) | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_suppliers_shop` (`shop_id`) +- KEY: `idx_suppliers_phone` (`phone`) +- KEY: `idx_suppliers_mobile` (`mobile`) + +**外键**: +- `fk_suppliers_shop`: `shop_id` → `shops(id)` +- `fk_suppliers_user`: `user_id` → `users(id)` + +**说明**:ap_payable由订单和付款记录联动维护。 + +--- + +### 4.3 accounts(结算账户) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| name | VARCHAR(64) | NO | | 账户名称 | +| type | ENUM('cash','bank','alipay','wechat','other') | NO | 'cash' | 账户类型 | +| bank_name | VARCHAR(64) | YES | NULL | 银行名称 | +| bank_account | VARCHAR(64) | YES | NULL | 银行账号 | +| balance | DECIMAL(18,2) | NO | 0.00 | 账户余额 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_accounts_shop` (`shop_id`) +- UNIQUE: `ux_accounts_shop_name` (`shop_id`, `name`) + +**外键**: +- `fk_accounts_shop`: `shop_id` → `shops(id)` +- `fk_accounts_user`: `user_id` → `users(id)` + +**说明**:支持现金、银行、支付宝、微信等多种结算方式。 + +--- + +## 五、订单与财务 + +### 5.1 sales_orders(销售单) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| customer_id | BIGINT UNSIGNED | YES | NULL | 客户ID | +| order_no | VARCHAR(32) | NO | | 订单号 | +| order_time | DATETIME | NO | | 订单时间 | +| status | ENUM('draft','approved','returned','void') | NO | 'draft' | 单据状态 | +| amount | DECIMAL(18,2) | NO | 0.00 | 应收合计 | +| paid_amount | DECIMAL(18,2) | NO | 0.00 | 已收合计 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_sales_shop_time` (`shop_id`, `order_time`) +- KEY: `idx_sales_customer` (`customer_id`) +- UNIQUE: `ux_sales_order_no` (`shop_id`, `order_no`) + +**外键**: +- `fk_sales_shop`: `shop_id` → `shops(id)` +- `fk_sales_user`: `user_id` → `users(id)` +- `fk_sales_customer`: `customer_id` → `customers(id)` + +**说明**:approved后自动扣减库存;付款记录关联payments表。 + +--- + +### 5.2 sales_order_items(销售单明细) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| order_id | BIGINT UNSIGNED | NO | | 订单ID | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| quantity | DECIMAL(18,3) | NO | | 数量 | +| unit_price | DECIMAL(18,2) | NO | | 单价 | +| discount_rate | DECIMAL(5,2) | NO | 0.00 | 折扣率(0-100) | +| cost_price | DECIMAL(18,2) | NO | 0.00 | 成本单价 | +| cost_amount | DECIMAL(18,2) | NO | 0.00 | 成本金额 | +| amount | DECIMAL(18,2) | NO | | 行金额 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_soi_order` (`order_id`) +- KEY: `idx_soi_product` (`product_id`) + +**外键**: +- `fk_soi_order`: `order_id` → `sales_orders(id)` ON DELETE CASCADE +- `fk_soi_product`: `product_id` → `products(id)` + +**约束**: +- quantity > 0 +- unit_price >= 0 +- discount_rate >= 0 AND <= 100 + +**说明**:记录开单时的成本价,用于利润分析。 + +--- + +### 5.3 purchase_orders(进货单) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| supplier_id | BIGINT UNSIGNED | YES | NULL | 供应商ID | +| order_no | VARCHAR(32) | NO | | 订单号 | +| order_time | DATETIME | NO | | 订单时间 | +| status | ENUM('draft','approved','void','returned') | NO | 'draft' | 单据状态 | +| amount | DECIMAL(18,2) | NO | 0.00 | 应付合计 | +| paid_amount | DECIMAL(18,2) | NO | 0.00 | 已付合计 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_purchase_shop_time` (`shop_id`, `order_time`) +- KEY: `idx_purchase_supplier` (`supplier_id`) +- UNIQUE: `ux_purchase_order_no` (`shop_id`, `order_no`) + +**外键**: +- `fk_purchase_shop`: `shop_id` → `shops(id)` +- `fk_purchase_user`: `user_id` → `users(id)` +- `fk_purchase_supplier`: `supplier_id` → `suppliers(id)` + +**说明**:approved后自动增加库存。 + +--- + +### 5.4 purchase_order_items(进货单明细) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| order_id | BIGINT UNSIGNED | NO | | 订单ID | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| quantity | DECIMAL(18,3) | NO | | 数量 | +| unit_price | DECIMAL(18,2) | NO | | 单价 | +| amount | DECIMAL(18,2) | NO | | 行金额 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_poi_order` (`order_id`) +- KEY: `idx_poi_product` (`product_id`) + +**外键**: +- `fk_poi_order`: `order_id` → `purchase_orders(id)` ON DELETE CASCADE +- `fk_poi_product`: `product_id` → `products(id)` + +**约束**: +- quantity > 0 +- unit_price >= 0 + +--- + +### 5.5 payments(收付款记录) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 操作人 | +| biz_type | ENUM('sale','purchase','other') | NO | | 业务类型 | +| biz_id | BIGINT UNSIGNED | YES | NULL | 业务单据ID | +| account_id | BIGINT UNSIGNED | NO | | 结算账户 | +| direction | ENUM('in','out') | NO | | 收款/付款 | +| amount | DECIMAL(18,2) | NO | | 金额 | +| pay_time | DATETIME | NO | | 付款时间 | +| category | VARCHAR(64) | YES | NULL | 分类key(其他收支) | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_payments_shop_time` (`shop_id`, `pay_time`) +- KEY: `idx_payments_biz` (`biz_type`, `biz_id`) + +**外键**: +- `fk_payments_shop`: `shop_id` → `shops(id)` +- `fk_payments_user`: `user_id` → `users(id)` +- `fk_payments_account`: `account_id` → `accounts(id)` + +**约束**:amount > 0 + +**说明**:统一管理销售收款、进货付款和其他收支。 + +--- + +### 5.6 other_transactions(其他收支) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| type | ENUM('income','expense') | NO | | 收入/支出 | +| category | VARCHAR(64) | NO | | 分类key | +| counterparty_type | VARCHAR(32) | YES | NULL | customer/supplier/other | +| counterparty_id | BIGINT UNSIGNED | YES | NULL | 往来单位ID | +| account_id | BIGINT UNSIGNED | NO | | 结算账户 | +| amount | DECIMAL(18,2) | NO | | 金额 | +| tx_time | DATETIME | NO | | 交易时间 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_ot_shop_time` (`shop_id`, `tx_time`) +- KEY: `idx_ot_account` (`account_id`) + +**外键**: +- `fk_ot_shop`: `shop_id` → `shops(id)` +- `fk_ot_user`: `user_id` → `users(id)` +- `fk_ot_account`: `account_id` → `accounts(id)` + +**约束**:amount > 0 + +**说明**:记录非进销业务的其他收入和支出。 + +--- + +### 5.7 inventory_movements(库存流水) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 操作人 | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| source_type | VARCHAR(32) | NO | | 来源:sale/purchase/return/adjust/audit | +| source_id | BIGINT UNSIGNED | YES | NULL | 来源单据ID | +| qty_delta | DECIMAL(18,3) | NO | | 数量增减(+/-) | +| amount_delta | DECIMAL(18,2) | YES | NULL | 金额增减 | +| cost_price | DECIMAL(18,2) | YES | NULL | 成本单价 | +| cost_amount | DECIMAL(18,2) | YES | NULL | 成本金额 | +| reason | VARCHAR(64) | YES | NULL | 原因/类别 | +| tx_time | DATETIME | NO | | 业务时间 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_im_shop_time` (`shop_id`, `tx_time`) +- KEY: `idx_im_product` (`product_id`) + +**说明**:所有库存变动的审计日志,出库为负,入库为正。 + +--- + +## 六、销售退货 + +### 6.1 sales_return_orders(销售退货单) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| customer_id | BIGINT UNSIGNED | YES | NULL | 客户ID | +| order_no | VARCHAR(32) | NO | | 订单号 | +| order_time | DATETIME | NO | | 订单时间 | +| status | ENUM('approved','void') | NO | 'approved' | 单据状态 | +| amount | DECIMAL(18,2) | NO | 0.00 | 退货金额合计 | +| paid_amount | DECIMAL(18,2) | NO | 0.00 | 已退/已收合计 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_sr_shop_time` (`shop_id`, `order_time`) +- UNIQUE: `ux_sr_order_no` (`shop_id`, `order_no`) + +**外键**: +- `fk_sr_shop`: `shop_id` → `shops(id)` +- `fk_sr_user`: `user_id` → `users(id)` +- `fk_sr_customer`: `customer_id` → `customers(id)` + +**说明**:approved后增加库存。 + +--- + +### 6.2 sales_return_order_items(销售退货明细) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| order_id | BIGINT UNSIGNED | NO | | 订单ID | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| quantity | DECIMAL(18,3) | NO | | 数量 | +| unit_price | DECIMAL(18,2) | NO | | 单价 | +| discount_rate | DECIMAL(5,2) | NO | 0.00 | 折扣率 | +| cost_price | DECIMAL(18,2) | NO | 0.00 | 成本单价 | +| cost_amount | DECIMAL(18,2) | NO | 0.00 | 成本金额 | +| amount | DECIMAL(18,2) | NO | | 行金额 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_sroi_order` (`order_id`) +- KEY: `idx_sroi_product` (`product_id`) + +**外键**: +- `fk_sroi_order`: `order_id` → `sales_return_orders(id)` ON DELETE CASCADE +- `fk_sroi_product`: `product_id` → `products(id)` + +--- + +### 6.3 purchase_return_orders(进货退货单) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建人 | +| supplier_id | BIGINT UNSIGNED | YES | NULL | 供应商ID | +| order_no | VARCHAR(32) | NO | | 订单号 | +| order_time | DATETIME | NO | | 订单时间 | +| status | ENUM('approved','void') | NO | 'approved' | 单据状态 | +| amount | DECIMAL(18,2) | NO | 0.00 | 退货金额合计 | +| paid_amount | DECIMAL(18,2) | NO | 0.00 | 已付合计 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_pr_shop_time` (`shop_id`, `order_time`) +- UNIQUE: `ux_pr_order_no` (`shop_id`, `order_no`) + +**外键**: +- `fk_pr_shop`: `shop_id` → `shops(id)` +- `fk_pr_user`: `user_id` → `users(id)` +- `fk_pr_supplier`: `supplier_id` → `suppliers(id)` + +**说明**:approved后减少库存。 + +--- + +### 6.4 purchase_return_order_items(进货退货明细) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| order_id | BIGINT UNSIGNED | NO | | 订单ID | +| product_id | BIGINT UNSIGNED | NO | | 商品ID | +| quantity | DECIMAL(18,3) | NO | | 数量 | +| unit_price | DECIMAL(18,2) | NO | | 单价 | +| amount | DECIMAL(18,2) | NO | | 行金额 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_proi_order` (`order_id`) +- KEY: `idx_proi_product` (`product_id`) + +**外键**: +- `fk_proi_order`: `order_id` → `purchase_return_orders(id)` ON DELETE CASCADE +- `fk_proi_product`: `product_id` → `products(id)` + +--- + +## 七、触发器 + +### 7.1 products.search_text 维护触发器 + +**trg_products_ai** (AFTER INSERT) +```sql +UPDATE products +SET search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec) +WHERE id = NEW.id; +``` + +**trg_products_au** (BEFORE UPDATE) +```sql +SET NEW.search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec); +``` + +### 7.2 product_aliases 同步触发器 + +**trg_palias_ai** (AFTER INSERT) +```sql +-- 聚合所有别名到products.search_text +UPDATE products p +JOIN ( + SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases + FROM product_aliases pa + WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL + GROUP BY pa.product_id +) a ON a.product_id = p.id +SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases) +WHERE p.id = NEW.product_id; +``` + +**trg_palias_au** (AFTER UPDATE)、**trg_palias_ad** (AFTER DELETE) - 类似逻辑 + +### 7.3 consults 回复状态触发器 + +**trg_consult_replies_ai** (AFTER INSERT) +```sql +UPDATE consults +SET status = 'resolved', updated_at = NOW() +WHERE id = NEW.consult_id; +``` + +--- + +## 八、数据完整性约束 + +### 8.1 CHECK约束 + +- `products`: `safe_min <= safe_max` +- `product_prices`: 所有价格 >= 0 +- `inventories`: `quantity >= 0` +- `sales_order_items`: `quantity > 0, unit_price >= 0, discount_rate BETWEEN 0 AND 100` +- `purchase_order_items`: `quantity > 0, unit_price >= 0` +- `payments`: `amount > 0` +- `other_transactions`: `amount > 0` + +### 8.2 唯一性约束 + +- `shops`: 无业务层面唯一约束 +- `users`: `phone` 全局唯一、`email` 全局唯一 +- `products`: `(shop_id, barcode)` 唯一、`(template_id, name, model)` 唯一 +- `part_submissions`: `model_unique` 全局唯一、`(template_id, name, model_unique)` 唯一 +- `customers/suppliers`: 无强制唯一约束(允许同名) +- `accounts`: `(shop_id, name)` 唯一 +- `sales_orders/purchase_orders`: `(shop_id, order_no)` 唯一 + +--- + +## 九、设计说明 + +### 9.1 多租户隔离 + +- 所有业务表必须包含 `shop_id` +- 全局字典表(单位、类别)使用 `shop_id=0` 表示平台共享 +- 查询必须强制按 `shop_id` 过滤 +- VIP数据可见性:非VIP用户仅显示最近60天(可配置)数据 + +### 9.2 软删除策略 + +- 主要业务表使用 `deleted_at` 字段标记软删除 +- 查询默认过滤 `deleted_at IS NULL` +- 部分关联表(如订单明细)采用级联删除 + +### 9.3 审计与追踪 + +- 所有表包含 `created_at`, `updated_at` 时间戳 +- 审核表(part_submissions、vip_users)记录审核人和审核时间 +- 库存变动通过 `inventory_movements` 完整留痕 +- 普通管理员申请通过 `normal_admin_audits` 留痕 + +### 9.4 性能优化 + +- 高频查询字段建立复合索引(如 `shop_id + order_time`) +- `products.search_text` 使用FULLTEXT索引支持全文检索 +- 价格和库存表采用主键为业务主键(product_id)的设计 +- 合理使用外键约束,但不过度使用以避免性能损失 + +### 9.5 扩展性设计 + +- 商品属性通过 `attributes_json` 支持动态扩展 +- 配件模板系统支持类型化参数定义 +- 财务分类通过配置表 `finance_categories` 动态管理 +- 预留 `global_sku_id` 支持平台级商品库 + +--- + +**文档维护说明**: +- 任何数据库结构变更必须同步更新本文档 +- 执行DDL操作后需标注更新时间 +- 新增表需补充完整的字段说明和索引信息 +- 变更需同步更新 `backend/db/db.sql` 脚本 + diff --git a/doc/数据库设计文档-辅助配置表.md b/doc/数据库设计文档-辅助配置表.md new file mode 100644 index 0000000..6681dc4 --- /dev/null +++ b/doc/数据库设计文档-辅助配置表.md @@ -0,0 +1,584 @@ +# 配件查询系统 - 数据库设计文档(辅助配置表) + +**版本**: 1.0 +**更新时间**: 2025-10-01 +**数据库**: MySQL 8.0 +**字符集**: utf8mb4 / utf8mb4_0900_ai_ci + +--- + +## 一、VIP会员体系 + +### 1.1 vip_users(会员用户) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 用户ID | +| is_vip | TINYINT(1) | NO | 1 | 是否VIP(1是 0否) | +| status | TINYINT UNSIGNED | NO | 0 | 启用状态:1启用 0停用 | +| expire_at | DATETIME | YES | NULL | 到期时间 | +| remark | VARCHAR(255) | YES | NULL | 备注/审核说明 | +| reviewer_id | BIGINT UNSIGNED | YES | NULL | 审核人 | +| reviewed_at | DATETIME | YES | NULL | 审核时间 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_vu_shop_user` (`shop_id`, `user_id`) +- KEY: `idx_vu_shop_status` (`shop_id`, `status`) + +**外键**: +- `fk_vu_shop`: `shop_id` → `shops(id)` +- `fk_vu_user`: `user_id` → `users(id)` +- `fk_vu_reviewer`: `reviewer_id` → `users(id)` + +**说明**: +- VIP状态判定:`status=1 AND is_vip=1 AND (expire_at IS NULL OR expire_at >= NOW())` +- 非VIP用户数据可见性:最近60天(可配置) +- VIP用户:查看全部历史数据 + +--- + +### 1.2 vip_price(VIP价格) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| price | DECIMAL(10,2) | NO | | 单月价格(元) | + +**索引**:无 + +**说明**: +- 全局配置表,仅一条记录 +- 表示平台统一VIP单月价格 +- 管理端可读取和修改 + +--- + +### 1.3 vip_recharges(VIP充值记录) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 用户ID | +| price | DECIMAL(10,2) | NO | | 本次充值价格(元) | +| duration_days | INT | NO | | 本次续期天数 | +| expire_from | DATETIME | YES | NULL | 生效前到期时间 | +| expire_to | DATETIME | NO | | 生效后到期时间 | +| channel | VARCHAR(32) | NO | 'oneclick' | 渠道(oneclick/...) | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_vr_shop` (`shop_id`) +- KEY: `idx_vr_user` (`user_id`) + +**外键**: +- `fk_vr_shop`: `shop_id` → `shops(id)` +- `fk_vr_user`: `user_id` → `users(id)` + +**说明**:记录每次VIP开通/续费的历史,支持对账和查询。 + +--- + +## 二、普通管理员体系 + +### 2.1 normal_admin_audits(普通管理员审计) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 用户ID | +| action | ENUM('apply','approve','reject','revoke','expire') | NO | | 操作类型 | +| remark | VARCHAR(255) | YES | NULL | 备注 | +| operator_admin_id | BIGINT UNSIGNED | YES | NULL | 平台管理员ID | +| previous_role | VARCHAR(32) | YES | NULL | 变更前角色 | +| new_role | VARCHAR(32) | YES | NULL | 变更后角色 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_naudit_shop_time` (`shop_id`, `created_at`) +- KEY: `idx_naudit_user_time` (`user_id`, `created_at`) + +**外键**: +- `fk_naudit_shop`: `shop_id` → `shops(id)` +- `fk_naudit_user`: `user_id` → `users(id)` +- `fk_naudit_admin`: `operator_admin_id` → `admins(id)` + +**说明**: +- 普通管理员申请、审批、撤销的完整审计日志 +- 当前状态 = 按user_id取最后一条记录的action +- VIP失效触发降级时写入expire审计 + +--- + +## 三、身份与认证 + +### 3.1 user_identities(第三方身份映射) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 用户ID | +| provider | ENUM('wechat_mp','wechat_app') | NO | | 身份提供方 | +| openid | VARCHAR(64) | NO | | 微信openid | +| unionid | VARCHAR(64) | YES | NULL | 微信unionid | +| nickname | VARCHAR(64) | YES | NULL | 昵称 | +| avatar_url | VARCHAR(512) | YES | NULL | 头像URL | +| last_login_at | DATETIME | YES | NULL | 最后登录时间 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_identity_user` (`user_id`) +- KEY: `idx_identity_shop` (`shop_id`) +- UNIQUE: `ux_identity_provider_openid` (`provider`, `openid`) +- UNIQUE: `ux_identity_unionid` (`unionid`) + +**外键**: +- `fk_identity_shop`: `shop_id` → `shops(id)` +- `fk_identity_user`: `user_id` → `users(id)` + +**说明**: +- 支持微信小程序和APP登录 +- 短信登录使用users.phone作为全局唯一身份,不创建identity记录 + +--- + +### 3.2 wechat_sessions(微信会话) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| provider | ENUM('wechat_mp','wechat_app') | NO | | 提供方 | +| openid | VARCHAR(64) | NO | | 微信openid | +| session_key | VARCHAR(128) | NO | | 会话密钥 | +| expires_at | DATETIME | NO | | 过期时间 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_wechat_session_expires` (`expires_at`) +- UNIQUE: `ux_wechat_session` (`provider`, `openid`) + +**说明**:临时存储微信会话密钥。 + +--- + +### 3.3 sms_codes(短信验证码) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| phone | VARCHAR(32) | NO | | 手机号 | +| scene | VARCHAR(32) | NO | 'login' | 场景:login/register/... | +| code_hash | CHAR(64) | NO | | 验证码哈希(SHA-256) | +| salt | CHAR(32) | NO | | 加盐字符串 | +| expire_at | DATETIME | NO | | 过期时间 | +| status | TINYINT UNSIGNED | NO | 0 | 0=active 1=used 2=expired 3=blocked | +| fail_count | TINYINT UNSIGNED | NO | 0 | 错误次数 | +| ip | VARCHAR(45) | YES | NULL | 发送IP | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_phone_created_at` (`phone`, `created_at`) +- KEY: `idx_phone_scene_status` (`phone`, `scene`, `status`) +- KEY: `idx_expire_at` (`expire_at`) +- KEY: `idx_ip_created_at` (`ip`, `created_at`) + +**说明**: +- 验证码采用哈希+盐存储 +- 支持多场景(登录、注册、重置密码等) +- 记录失败次数防止暴力破解 + +--- + +### 3.4 email_codes(邮箱验证码) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| email | VARCHAR(128) | NO | | 邮箱 | +| scene | VARCHAR(32) | NO | | 场景:login/register/reset | +| code_hash | VARCHAR(64) | NO | | 验证码哈希(SHA-256) | +| salt | VARCHAR(64) | NO | | 加盐字符串 | +| expire_at | DATETIME | NO | | 过期时间 | +| status | TINYINT UNSIGNED | NO | 0 | 0=unused 1=used 2=expired | +| fail_count | INT | NO | 0 | 错误次数 | +| ip | VARCHAR(64) | YES | NULL | 发送IP | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_email_scene_created` (`email`, `scene`, `created_at`) +- KEY: `idx_email_expire` (`expire_at`) + +**说明**:与sms_codes类似,用于邮箱验证场景。 + +--- + +## 四、咨询与公告 + +### 4.1 consults(咨询) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 所属店铺 | +| user_id | BIGINT UNSIGNED | NO | | 提问用户 | +| topic | VARCHAR(120) | NO | | 主题 | +| message | TEXT | NO | | 咨询内容 | +| status | ENUM('open','resolved','closed') | NO | 'open' | 状态 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_consult_shop_status` (`shop_id`, `status`) +- KEY: `fk_consult_user` (`user_id`) + +**外键**: +- `fk_consult_shop`: `shop_id` → `shops(id)` +- `fk_consult_user`: `user_id` → `users(id)` + +**说明**: +- open=未解决,resolved=已解决,closed=关闭 +- 触发器:有回复时自动标记为resolved + +--- + +### 4.2 consult_replies(咨询回复) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| consult_id | BIGINT UNSIGNED | NO | | 所属咨询 | +| user_id | BIGINT UNSIGNED | NO | | 回复人(管理员) | +| content | TEXT | NO | | 回复内容 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 回复时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_cr_consult` (`consult_id`) +- KEY: `fk_cr_user` (`user_id`) + +**外键**: +- `fk_cr_consult`: `consult_id` → `consults(id)` +- `fk_cr_user`: `user_id` → `users(id)` + +**说明**:管理员对咨询的回复记录。 + +--- + +### 4.3 notices(公告) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| title | VARCHAR(120) | NO | | 标题 | +| content | VARCHAR(500) | NO | | 内容(跑马灯) | +| tag | VARCHAR(32) | YES | NULL | 标签(如"活动") | +| is_pinned | TINYINT(1) | NO | 0 | 是否置顶 | +| starts_at | DATETIME | YES | NULL | 生效开始时间 | +| ends_at | DATETIME | YES | NULL | 生效结束时间 | +| status | ENUM('draft','published','offline') | NO | 'published' | 状态 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | +| deleted_at | DATETIME | YES | NULL | 软删除标记 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_notices_time` (`starts_at`, `ends_at`) + +**说明**: +- 平台全局公告,与租户无关 +- 前台仅显示status='published'且在有效期内的公告 +- 排序:is_pinned DESC, created_at DESC + +--- + +## 五、系统配置 + +### 5.1 system_parameters(系统参数) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID | +| user_id | BIGINT UNSIGNED | NO | | 创建/修改人 | +| key | VARCHAR(64) | NO | | 参数键 | +| value | JSON | NO | | 参数值(JSON) | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_sysparams_shop` (`shop_id`) +- UNIQUE: `ux_sysparams_shop_key` (`shop_id`, `key`) + +**外键**: +- `fk_sysparams_shop`: `shop_id` → `shops(id)` +- `fk_sysparams_user`: `user_id` → `users(id)` + +**说明**: +- 租户级配置,支持JSON格式存储复杂配置 +- 常用配置键: + - `vip.dataRetentionDaysForNonVip`: 非VIP数据保留天数(默认60) + - `normalAdmin.autoApprove`: 普通管理员自动审批(默认false) + - `normalAdmin.requiredVipActive`: 要求VIP有效(默认true) + +--- + +### 5.2 finance_categories(财务分类) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | NO | | 店铺ID(0=全局) | +| type | ENUM('income','expense') | NO | | 收入/支出 | +| key | VARCHAR(64) | NO | | 分类key | +| label | VARCHAR(120) | NO | | 分类名称 | +| sort_order | INT | NO | 0 | 排序 | +| status | TINYINT UNSIGNED | NO | 1 | 1=启用 0=停用 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_fc_shop_type` (`shop_id`, `type`) +- UNIQUE: `ux_fc_shop_key` (`shop_id`, `key`) + +**说明**: +- 管理其他收入/支出的分类 +- shop_id=0为平台默认分类 +- 读取优先级:finance_categories表 → system_parameters → application.properties + +--- + +## 六、附件管理 + +### 6.1 attachments(通用附件) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| shop_id | BIGINT UNSIGNED | YES | NULL | 店铺ID(全局资源可空) | +| user_id | BIGINT UNSIGNED | YES | NULL | 上传人 | +| owner_type | VARCHAR(32) | NO | | 归属类型:product/part_submission/... | +| owner_id | BIGINT UNSIGNED | NO | | 归属ID | +| url | VARCHAR(512) | NO | | 文件URL | +| hash | VARCHAR(64) | YES | NULL | 内容哈希(SHA-256) | +| meta | JSON | YES | NULL | 元数据 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_attachments_owner` (`owner_type`, `owner_id`) +- UNIQUE: `ux_attachments_hash` (`hash`) + +**外键**: +- `fk_att_shop`: `shop_id` → `shops(id)` +- `fk_att_user`: `user_id` → `users(id)` + +**说明**: +- 通过hash实现文件去重 +- 支持多种归属类型(商品、配件提交、全局SKU等) +- meta字段存储文件元信息(大小、类型等) + +--- + +## 七、配件参数字典(扩展) + +### 7.1 part_attribute_dictionary(参数字典) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| attribute_name | VARCHAR(64) | NO | | 参数名称 | +| attribute_unit | VARCHAR(16) | YES | NULL | 单位 | +| attribute_type | ENUM('numeric','text','enum') | NO | 'text' | 类型 | +| enum_values | JSON | YES | NULL | 枚举值 | +| is_searchable | TINYINT(1) | NO | 1 | 是否可搜索 | +| sort_order | INT | NO | 0 | 排序 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- UNIQUE: `ux_pad_name` (`attribute_name`) + +**说明**:全局参数字典,定义可用的配件参数。 + +--- + +### 7.2 part_categories(配件分类) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| name | VARCHAR(64) | NO | | 分类名称 | +| parent_id | BIGINT UNSIGNED | YES | NULL | 父分类 | +| sort_order | INT | NO | 0 | 排序 | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_pc_parent` (`parent_id`) +- UNIQUE: `ux_pc_name` (`name`) + +**说明**:配件专用分类,支持层级结构。 + +--- + +### 7.3 part_category_attributes(分类参数关联) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| category_id | BIGINT UNSIGNED | NO | | 分类ID | +| attribute_id | BIGINT UNSIGNED | NO | | 参数ID | +| is_required | TINYINT(1) | NO | 0 | 是否必填 | +| sort_order | INT | NO | 0 | 排序 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_pca_category` (`category_id`) +- UNIQUE: `ux_pca_cat_attr` (`category_id`, `attribute_id`) + +**外键**: +- `fk_pca_category`: `category_id` → `part_categories(id)` ON DELETE CASCADE +- `fk_pca_attribute`: `attribute_id` → `part_attribute_dictionary(id)` ON DELETE CASCADE + +**说明**:定义每个配件分类应包含哪些参数。 + +--- + +### 7.4 part_attribute_templates(参数模板) + +| 字段 | 类型 | 空值 | 默认值 | 说明 | +|------|------|------|--------|------| +| id | BIGINT UNSIGNED | NO | AUTO_INCREMENT | 主键 | +| template_name | VARCHAR(64) | NO | | 模板名称 | +| category_id | BIGINT UNSIGNED | YES | NULL | 关联分类 | +| attributes | JSON | NO | | 参数定义JSON | +| created_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | NO | CURRENT_TIMESTAMP | 更新时间 | + +**索引**: +- PRIMARY KEY: `id` +- KEY: `idx_pat_category` (`category_id`) +- UNIQUE: `ux_pat_name` (`template_name`) + +**说明**:预定义的参数模板,快速应用到配件。 + +--- + +## 八、配置参数说明 + +### 8.1 VIP相关配置 + +| 配置键 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| vip.dataRetentionDaysForNonVip | number | 60 | 非VIP数据保留天数 | +| vip.price | number | 15 | VIP单月价格(元) | +| vip.durationDays | number | 30 | 开通时长(天) | + +### 8.2 普通管理员配置 + +| 配置键 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| normalAdmin.autoApprove | boolean | false | 是否自动通过申请 | +| normalAdmin.requiredVipActive | boolean | true | 是否要求VIP有效 | + +### 8.3 财务分类配置 + +**收入分类**(income_categories): +- operation_income: 经营所得 +- interest_income: 利息收入 +- other_income: 其它收入 +- deposit_ar_income: 收订金/欠款 +- investment_income: 投资收入 +- sale_income: 销售收入 +- account_operation: 账户操作 +- fund_transfer_in: 资金转账转入 + +**支出分类**(expense_categories): +- operation_expense: 经营支出 +- office_supplies: 办公用品 +- rent: 房租 +- interest_expense: 利息支出 +- other_expense: 其它支出 +- account_operation: 账户操作 +- fund_transfer_out: 资金转账转出 + +--- + +## 九、数据安全与性能 + +### 9.1 敏感数据加密 + +- 密码:SHA-256哈希存储(password_hash) +- 验证码:SHA-256哈希+盐(code_hash + salt) +- 第三方token:加密存储(wechat_sessions.session_key) + +### 9.2 索引策略 + +- 高频查询字段:shop_id, user_id, status, created_at +- 唯一性约束:email, phone, openid, hash +- 复合索引:(shop_id, status), (user_id, created_at) +- 全文索引:仅用于products.search_text + +### 9.3 数据清理 + +**定期清理**: +- sms_codes/email_codes:清理7天前expired记录 +- wechat_sessions:清理过期会话 +- inventory_movements:归档6个月前数据(可选) + +**软删除**: +- 业务表采用deleted_at标记 +- 查询默认过滤deleted_at IS NULL +- 定期归档软删除数据 + +--- + +## 十、扩展性设计 + +### 10.1 JSON字段应用 + +- products.attributes_json: 扩展商品属性 +- part_submissions.attributes/images/tags: 灵活配件数据 +- attachments.meta: 文件元信息 +- system_parameters.value: 动态配置 + +### 10.2 ENUM类型管理 + +- 新增枚举值需要ALTER TABLE +- 重要状态采用ENUM确保数据一致性 +- 次要分类采用VARCHAR + 配置表 + +### 10.3 多语言支持预留 + +- 公告内容可扩展为JSON:{zh: "中文", en: "English"} +- 财务分类label可支持多语言 +- 当前版本仅支持简体中文 + +--- + +**文档维护说明**: +- 辅助表主要用于系统配置、认证、审计和扩展功能 +- 任何结构变更必须同步更新本文档和`backend/db/db.sql` +- 配置参数变更需同时更新application.properties默认值 +- 新增表需评估是否属于核心业务表或辅助表,放入对应文档 + diff --git a/doc/模板参数可模糊查询_功能需求文档.md b/doc/模板参数可模糊查询_功能需求文档.md deleted file mode 100644 index a587db0..0000000 --- a/doc/模板参数可模糊查询_功能需求文档.md +++ /dev/null @@ -1,127 +0,0 @@ -## 模板参数可模糊查询(±容差)功能需求文档 - -### 1. 背景与目标 -当前用户端「按模板参数查询」要求参数值与数据库完全相同才能命中,实际使用中数值类参数(如内径、外径、长度等)存在测量/录入微小误差,严格等值导致命中率偏低。新增能力:在管理端创建模板时,为每个参数提供「可模糊查询」选项;开启后,用户搜索该参数时按数值区间匹配(±容差);未开启的参数继续精确等值。 - -### 2. 业务范围 -- 场景:用户端/管理端的商品列表查询(含「按模板参数查询」模式)。 -- 对象:模板参数定义(仅限数值型参数生效)。 -- 不影响:名称/品牌/型号/规格关键字搜索逻辑;非数值类型参数的等值匹配逻辑。 - -### 3. 术语与约束 -- 模板参数类型:string/number/boolean/enum/date。 -- 模糊查询仅对 type=number 生效;其他类型不展示该选项或忽略配置。 -- 容差(tolerance):对搜索入参 v,匹配区间为 \[v - tolerance, v + tolerance](闭区间)。默认容差为 1(见配置项),可在参数层级单独覆盖。 -- 组合关系:多参数为 AND 关系;每个参数根据其「可模糊查询」与容差独立计算。 - -### 4. 交互与流程 -- 管理端-模板配置: - - 新建/编辑模板参数时,新增选项: - - 可模糊查询(开关,仅当类型为 number 显示) - - 容差值(number,>0,显示单位提示,同 `unit` 字段;当开关开启时必填,否则置空) - - 校验: - - type≠number 时禁止开启; - - 容差必须为正数,支持小数; - - 可保存为“使用平台默认容差”,当字段留空时后端落默认(见配置)。 -- 用户端/管理端-按模板参数查询: - - 入参与现状一致:仍以 `templateId` + 多个 `param_*` 传参; - - 行为变化: - - 对应参数若开启可模糊查询:按区间 \[v - tol, v + tol] 比较; - - 否则:仍为精确等值比较。 - -### 5. 数据模型变更(待实施) -- 表:`part_template_params` - - 新增列: - - `fuzzy_searchable` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许模糊查询(仅数值型)' - - `fuzzy_tolerance` DECIMAL(18,6) NULL COMMENT '容差;NULL 表示使用平台默认容差' - - 说明: - - 仅当 `type='number' AND fuzzy_searchable=1` 时才使用容差; - - 初始迁移将全部历史记录置为 `fuzzy_searchable=0, fuzzy_tolerance=NULL`,保持现有行为不变。 - -### 6. 配置项(后端) -- `search.fuzzy.enabled`(bool,默认 true):是否启用模糊查询全局开关; -- `search.fuzzy.defaultTolerance`(decimal,默认 1.0):当参数未设置 `fuzzy_tolerance` 时使用; -- 读取途径:Spring 配置(application.properties/yaml)或环境变量。禁止在代码中硬编码数字 1。 -- 仅全局配置,不支持租户级(`system_parameters`)覆盖;无需设置小数精度上限/最大容差限制。 - -### 7. 接口协议与兼容性 -- 查询接口:`GET /api/products`(已存在) - - 入参保持不变:`templateId`、`param_*`。 - - 语义扩展(无须 `templateId` 也启用模糊):后端将基于商品行的 `template_id` 与参数定义逐行判定某个 `param_*` 是否启用 ±容差;若该参数在对应模板中未开启模糊或非数值型,则对该条件执行等值匹配。 -- 模板接口:`POST /api/admin/part-templates`、`PUT /api/admin/part-templates/{id}`(已存在) - - 参数定义对象新增字段: - - `fuzzySearchable`(boolean) - - `fuzzyTolerance`(number,nullable) - - 若前端暂未改造,后端默认按 `fuzzySearchable=false` 处理,兼容旧请求体。 - -(根据「接口规范生效条件」,待功能开发完成后更新 `doc/openapi.yaml` 中对应 schema 与描述,并在 summary/description 标注实现状态) - -### 8. 后端实现要点(建议方案) -当前实现(精确匹配),示意: -```sql --- 现状(等值): -AND JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, '$.内径')) = '10' -``` - -推荐实现: -- 行级判定方案(支持无 `templateId` 也启用模糊): - - 对每个传入的 `param_=v`: - - 以 `EXISTS` 子查询或 `JOIN part_template_params ptp ON ptp.template_id=p.template_id AND ptp.field_key=''` 获取参数定义; - - 若 `ptp.type='number' AND ptp.fuzzy_searchable=1`:对 `v` 解析为数值,计算 `tol = COALESCE(ptp.fuzzy_tolerance, :defaultTolerance)`; - - 下限截断:`lower = GREATEST(0, v - tol)`; - - 条件: - ```sql - CAST(JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, '$.')) AS DECIMAL(18,6)) BETWEEN :lower AND :upper - ``` - - 否则:执行等值匹配: - ```sql - JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, '$.')) = :val - ``` -- 快路径(可选):当请求携带 `templateId` 时,可先一次性加载该模板参数定义映射到内存,按映射决定每个条件构造,以减少 `JOIN/EXISTS` 次数。 -- 容差取值:优先 `ptp.fuzzy_tolerance`,否则全局 `search.fuzzy.defaultTolerance`。 - -性能建议: -- 初期:允许全表扫描 + JSON_EXTRACT;观察真实 QPS 与延迟; -- 进阶(可选):对热点参数引入“生成列 + 索引”(Generated Column),例如: - - 在 `products` 增加 `attr_ DECIMAL(18,6) GENERATED ALWAYS AS (CAST(JSON_UNQUOTE(JSON_EXTRACT(attributes_json, '$.')) AS DECIMAL(18,6))) STORED` 并建索引,以支持范围查询; - - 仅对访问量高的少数参数启用,避免列爆炸。 - -### 9. 管理端实现要点(UI/校验) -- `admin/src/views/parts/Templates.vue`: - - 参数编辑行新增: - - 开关:可模糊查询(仅 type=number 显示) - - 数值输入:容差(显示单位,>0,支持小数;留空表示使用平台默认) - - 保存/加载兼容:与后端新增字段映射,历史数据默认显示为关闭态。 - - 校验:当参数开启模糊时,对应值在 UI 侧仅允许数字输入;单位提示与 `unit` 一致。 - -### 10. 验收标准(Test Cases) -- 单参数-模糊:模板字段 `内径`(number,fuzzy=true,tolerance=1);商品 A/B/C 分别取值 9/10/11;搜索 `param_内径=10` 命中 A/B/C。 -- 单参数-精确:同上但 fuzzy=false;搜索 `param_内径=10` 仅命中 B。 -- 多参数组合:`内径`(fuzzy=true, tol=0.5)、`长度`(fuzzy=false);搜索 `param_内径=10`、`param_长度=20` 仅命中满足区间与等值的交集。 -- 无 templateId:也启用模糊;后端逐行按 `p.template_id` 与参数定义判定是否应用容差。 -- 容差来源:当 `fuzzy_tolerance=NULL` 时,生效平台默认容差;覆盖值生效优先级高于默认。 -- 非数值参数:即使请求携带 `param_颜色=黑`,也严格等值。 - - 下限截断:当 `v - tol < 0` 时,以 `0` 作为下限;不支持负数参数匹配。 - - 非法输入:当参数在模板中开启模糊但请求值非数字时,返回 400(Bad Request)。 - -### 11. 兼容与回退 -- 不改动现有请求入参与返回体,历史客户端无需升级亦可按原精确逻辑使用; -- 新能力由模板参数配置显式开启,可随时在模板中关闭; -- 如需全局关闭,可通过 `search.fuzzy.enabled=false` 临时禁用(后端配置)。 - -### 12. 风险与注意事项 -- 数据质量:历史 `attributes_json` 中数值可能以字符串存储;需统一以 `CAST(JSON_UNQUOTE(...))` 解析。 -- 单位与容差:UI 需提示单位;容差与单位一一对应,避免“毫米 vs 厘米”误解。 -- 性能:范围查询较等值更难走索引;必要时引入“生成列+索引”优化热点字段。 - - 负数与边界:不支持负数参数;区间采用闭区间,且下限截断为 `0`。 - -### 13. 实施清单(参考) -1) 数据库:为 `part_template_params` 增列 `fuzzy_searchable`、`fuzzy_tolerance`;(变更需通过 MysqlMCP,成功后同步更新 `doc/database_documentation.md` 与 `backend/db/db.sql`) -2) 配置:新增 `search.fuzzy.*` 配置项并给出默认值(全局生效,无租户级覆盖); -3) 管理端:模板参数编辑 UI 新增开关与容差输入; -4) 后端:按 8 节改造查询 SQL 构建逻辑(无 `templateId` 也启用模糊,行级按模板判定); -5) 文档:在功能开发完成后更新 `doc/openapi.yaml` 中模板参数 schema 与 `GET /api/products` 的查询规则说明,并标注实现状态; -6) 发布:前后端同步上线;无需灰度与回滚开关; -7) 验收:按 10 节用例覆盖单测/集成测试与手工回归。 - - diff --git a/doc/货品删除功能开发文档.md b/doc/货品删除功能开发文档.md deleted file mode 100644 index 1f03324..0000000 --- a/doc/货品删除功能开发文档.md +++ /dev/null @@ -1,175 +0,0 @@ -## 货品删除功能开发文档(软删方案) - -### 1. 背景与目标 -- 将“与货品相关”的删除行为统一为软删除,避免历史引用断裂,支持后续恢复与审计。 -- 用户仅保留“拉黑/恢复”,订单维持“作废 void”,不做删除。 - -### 2. 范围 -- 货品主表:`products` -- 关联信息:`product_images`、`product_prices`、`inventories`、`product_aliases` -- 相关查询接口:商品搜索、详情、导出(如有) - -### 2.1 父子级联关系(必须遵守) -- 分类(`product_categories`) → 模板(`part_templates`) → 商品(`products`) -- 规则: - - 删除分类 ⇒ 级联软删该分类下所有模板;再级联软删由这些模板创建的所有商品;并同时软删所有 `category_id=该分类` 的商品(包括未通过模板创建的商品)。 - - 删除模板 ⇒ 仅软删该模板下的商品,不影响同分类其它模板的商品。 - - 订单不可删除,仅允许作废(void),因此采用“软删”是必要前提,避免历史订单断裂。 - - 恢复:当前不提供任何恢复入口;如未来开放,恢复不做级联,需逐层独立恢复以避免误恢复。 - -### 3. 设计要点 -- 软删标记:使用 `products.deleted_at DATETIME NULL`(已存在)。被软删即视为“不对外可见”。 -- 恢复:当前不提供恢复入口。若未来开放,语义为将 `deleted_at=NULL`。 -- 查询默认过滤:所有列表/搜索默认附加 `deleted_at IS NULL`(当前搜索已实现)。 -- 详情访问:若记录被软删,返回 404(或通过 `includeDeleted=true` 显式读取)。 -- 关联表处理:软删商品时不物理删除图片/价格/库存/别名(均按商品引用读取,详情被 404 屏蔽即可)。 - -- 模板软删标记统一:为 `part_templates` 引入 `deleted_at DATETIME NULL` 以统一软删标记;`status` 字段保留为启停用,不代表软删。所有查询需同时过滤 `deleted_at IS NULL AND status=1`(按需)。 - -- 字典与作用域:分类与单位属于 `shop_id=0` 的全局字典。删除分类会影响所有店铺下此分类的模板与商品;此操作需平台管理员权限并要求二次确认。 - -- 报表与搜索:默认排除软删记录;不提供“含回收站”开关。 - -- 数据保留与清理:支持配置项 `SOFT_DELETE_RETENTION_DAYS`(默认永久保留,仅清理无引用对象)。 - -- 单位删除校验:移除对已废弃 `products.unit_id` 的校验逻辑。 - -### 4. 数据库与索引 -现状:`products` 存在唯一约束 `UNIQUE(shop_id, barcode)`。软删后可能需要“同店铺、同条码”重新建商品。 - -- 目标:唯一约束仅作用于“活动记录”(未软删)。 -- 做法:增加生成列 `is_active` 并重建唯一索引(MySQL 8)。 - -DDL(上线脚本草案) -```sql --- 仅对生产环境执行一次;如已存在请跳过对应步骤 -ALTER TABLE products - ADD COLUMN is_active TINYINT AS (CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) STORED, - ADD INDEX idx_products_deleted_at (deleted_at); - --- 重建唯一索引,使其仅约束未软删记录 -DROP INDEX ux_products_shop_barcode ON products; -- 若不存在请忽略 -CREATE UNIQUE INDEX ux_products_shop_barcode_live ON products(shop_id, barcode, is_active); -``` - -风险与说明 -- “条码为空”不会受唯一约束影响(MySQL 对 NULL 不唯一);符合预期。 -- 老数据不受影响;后续删除改为软删即可。 -- 若未来需要“永久删除”,可新增仅限平台运维的强删脚本,先清理关联,再物理删除目标商品。 -- 如未来开放“恢复”,当恢复商品与现存“活动记录”在 `(shop_id, barcode)` 上冲突时,恢复应返回 `409 Conflict` 并附带冲突商品信息。 - -模板表 DDL(新增软删标记) -```sql -ALTER TABLE part_templates - ADD COLUMN deleted_at DATETIME NULL, - ADD INDEX idx_part_templates_deleted_at (deleted_at); -``` - -### 5. 接口设计(OpenAPI 约定) -说明:按规范,等后端开始开发即补充到 `/doc/openapi.yaml` 并标注实现状态;本方案不新增任何“恢复”接口。 - -1) 软删商品(行为不变,明确语义) -- Method/Path: `DELETE /api/products/{id}` -- 语义:软删,将 `deleted_at=NOW()`。 -- 返回:`200 {}` -- 鉴权:需要 `X-Shop-Id`/`X-User-Id` 或 Token,且仅允许同店铺数据。 - -2) 商品详情(行为调整) -- Method/Path: `GET /api/products/{id}` -- 默认:若 `deleted_at IS NOT NULL` 返回 `404`。 -- 可选:`includeDeleted=true` 时允许读取已软删详情(仅管理端使用)。 - -3) 恢复接口 -- 不同意新增以下恢复接口:`PUT /api/admin/dicts/categories/{id}/restore`、`PUT /api/admin/part-templates/{id}/restore`、`PUT /api/products/{id}/restore`。 - -### 6. 后端实现说明 -- Controller 改动(示意) - - `ProductController.delete(id, shopId)`:保持现有调用,内部执行软删。 - - `GET /api/products/{id}`:调用 `productService.findDetail(id)` 前,先判断 `deleted_at`,若非空且未显式 `includeDeleted` → `404`。 - -- Service 改动(核心) - - 移除/不提供任何恢复相关方法。 - - `findDetail(id)`:若被软删且无 `includeDeleted` 参数 → 返回空 Optional。 - - 模板表采用 `deleted_at` 表示软删,`status` 表示启停用;查询需同时过滤 `deleted_at IS NULL` 与必要的 `status` 条件。 - -#### 6.1 级联软删伪代码 -```java -// 分类软删 -void deleteCategorySoft(Long categoryId) { - // 1) 标记分类 deleted_at - UPDATE product_categories SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL; - // 2) 级联模板软删(统一使用 deleted_at) - UPDATE part_templates SET deleted_at=NOW() WHERE category_id=? AND deleted_at IS NULL; - // 3) 级联商品软删:模板创建的商品 + 直接挂在分类下的商品 - UPDATE products SET deleted_at=NOW() WHERE ( - template_id IN (SELECT id FROM part_templates WHERE category_id=?) - OR category_id=? - ) AND deleted_at IS NULL; -} - -// 模板软删(不波及其它模板) -void deleteTemplateSoft(Long templateId) { - // 1) 模板标记为软删 - UPDATE part_templates SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL; - // 2) 级联商品软删(仅该模板下) - UPDATE products SET deleted_at=NOW() WHERE template_id=? AND deleted_at IS NULL; -} -``` - -### 7. 前端改动 -- 列表页:保持不显示软删项(现已过滤)。 -- 详情页:若接口返回 404,提示“已被删除或无权限”。 -- 管理端:不提供“回收站/恢复”入口;删除按钮提示:该操作为软删除,对前台不可见,当前无恢复入口。 - -### 8. 权限与审计 -- 鉴权:沿用现有用户/店铺头部识别;仅同店铺商品可操作。 -- 权限边界: - - 普通用户:仅可删除本用户的货品;无权删除模板与分类;无恢复权限。 - - 店铺管理员:仅有审核功能;无删除模板/分类与恢复权限。 - - 平台管理员:可删除货品、模板、分类;删除全局分类需二次确认;无恢复权限。 -- 审计:不记录操作日志(操作者、时间、来源 IP、对象 ID 与名称),以简化开发。 - -### 9. 测试用例 -- 删除后搜索不可见;`GET /api/products/{id}` 返回 404。 -- 条码唯一:软删后允许同店铺同条码新建。 -- (如未来开放恢复)恢复时如与现有活动记录冲突,返回 409 并附带冲突商品信息。 - -### 10. 发布与回滚 -- 发布顺序: - 1) 执行数据库 DDL(生成列与索引)。 - 2) 上线后端(调整 detail 行为,移除/不提供恢复逻辑)。 - 3) 上线前端(不提供回收站/恢复入口)。 -- 回滚: - - 后端回滚到旧版本;DDL 不需要回退(生成列与新索引向前兼容)。 - -### 11. FAQ / 风险 -- 问:软删后图片与价格是否清理? - - 答:不清理,保持数据可恢复;若永久删除再统一清理关联。 -- 问:库存与统计是否包含软删商品? - - 答:常规统计应排除软删;如需包含,增加显式参数。 -- 问:条码冲突如何处理? - - 答:按“活动记录”唯一;如未来开放恢复,发现冲突则返回 409,并指明冲突商品。 -- 问:字典(分类/单位)是否为全局维度?删除是否影响所有店铺? - - 答:是,`shop_id=0` 全局字典;删除全局分类会影响所有店铺下该分类的模板与商品,需平台管理员二次确认。 -- 问:是否保留“强删”入口? - - 答:保留仅限平台运维的强删入口(默认关闭)。分类/模板强删前需校验无订单关联商品后再执行。 -- 问:为何不做物理删除? - - 答:订单/流水等历史记录必须可追溯;物理删除会破坏外键与统计。软删能满足“前台不可见、后台可恢复”的业务诉求。 - - -### 12. 任务拆解(实施) -- 后端: - - [ ] `GET /api/products/{id}` 软删返回 404 / 支持 `includeDeleted` - - [ ] 分类删除级联扩展:同时软删 `category_id=该分类` 的商品(含未走模板创建) - - [ ] 模板表引入 `deleted_at`;查询同时过滤 `deleted_at IS NULL` 与必要的 `status` - - [ ] 移除“单位删除校验检查 products.unit_id”的逻辑 -- 数据库: - - [ ] 为 `products` 增加 `is_active` 与唯一索引(见 DDL) - - [ ] 为 `part_templates` 增加 `deleted_at` 与索引 -- 前端管理端: - - [ ] 删除按钮文案更新(软删除,对前台不可见,当前无恢复入口) - - [ ] 不提供“回收站/恢复”入口 - -(本文件为技术方案与实施指引,变更上线后请同步 `/doc/openapi.yaml` 与 `/doc/database_documentation.md`) - - diff --git a/doc/项目开发文档.md b/doc/项目开发文档.md new file mode 100644 index 0000000..4087f8c --- /dev/null +++ b/doc/项目开发文档.md @@ -0,0 +1,793 @@ +# 配件查询系统 - 项目开发文档 + +**项目名称**:配件查询App +**版本**:v1.0 +**更新时间**:2025-10-01 +**技术栈**:Spring Boot 3 + MySQL 8.0 + Vue 3 + uni-app + +--- + +## 一、项目概述 + +### 1.1 项目定位 + +面向小微商户的移动端进销存管理应用,核心功能包括: +- 商品管理(库存、价格、类别) +- 进销存业务(销售开单、进货开单、退货处理) +- 客户与供应商管理 +- 财务管理(账户、收支、报表) +- **配件查询**:支持配件参数化查询、用户提交、审核上架 + +### 1.2 系统架构 + +``` +前端层: +├── 移动端(uni-app): 用户端App + 微信小程序 +├── 管理端(Vue3 + Element Plus): 平台管理后台 +└── 普通管理端(Vue3 + Element Plus): 精简审核后台 + +后端层: +├── Spring Boot 3.x:RESTful API服务 +├── MySQL 8.0:数据持久化 +├── JWT:用户认证 +└── Python FastAPI(可选):条码识别服务 + +部署层: +├── 数据库:mysql.tonaspace.com:3306 +├── 后端:Java应用服务器 +└── 前端:静态资源 + CDN +``` + +### 1.3 多租户模型 + +- **租户隔离**:所有业务数据必须关联shop_id,严格按租户隔离 +- **全局字典**:类别、单位使用shop_id=0作为平台共享字典 +- **权限体系**: + - 平台管理员(admins表):跨租户管理 + - 店主(users.is_owner=1):店铺所有权限 + - 员工(users.role=staff):基础操作权限 + - 普通管理员(users.role=normal_admin):配件审核权限 + +--- + +## 二、核心功能需求 + +### 2.1 用户端功能(移动App + 小程序) + +#### 2.1.1 首页Dashboard +- ✅ **数据概览**:今日/本月销售额、本月利润、库存数量 +- ✅ **快捷入口**:客户管理、销售开单、账户管理、供应商管理、进货开单、其他支出、VIP会员、报表统计 +- ⚠️ **在线客服**:咨询入口已实现,悬浮按钮待添加 + +#### 2.1.2 货品管理 +**列表功能**: +- ✅ 按类别筛选、关键字搜索(名称/条码/别名) +- ✅ 显示库存数量、零库存提示 +- ✅ 总货品种类统计 + +**新增/编辑**: +- ✅ 商品图片上传(多图、排序、预览) +- ✅ 扫描条形码(App支持,小程序不支持) +- ✅ 必填项:商品名称 +- ✅ 可选项:类别、品牌、型号、规格、产地、条码、描述 +- ✅ 价格:进货价、零售价、批发价、大客户价(四列) +- ✅ 库存:当前库存、安全库存上下限 +- ✅ 支持模板化参数录入 + +**货品设置**: +- ✅ 类别管理(读取全局字典) +- ✅ 单位管理(读取全局字典) +- ⚠️ 隐藏零库存商品、隐藏进货价:前端功能待实现 + +#### 2.1.3 配件查询与提交 +- ✅ **配件搜索**:多参数组合查询、模糊匹配、分页展示 +- ✅ **配件提交**: + - 型号唯一校验 + - 多图上传(最多9张) + - 参数JSON录入 + - 备注、安全库存 + - 提交后进入待审核状态 +- ✅ **提交记录**:查看pending/approved/rejected状态 +- ✅ **提交详情**:查看审核结果、驳回原因 + +#### 2.1.4 开单模块 +**销售单**: +- ✅ 出货单:选择客户、添加商品、计算合计、收款 +- ✅ 退货单:客户退货处理 +- ✅ 收款单:后续收款记录 + +**进货单**: +- ✅ 进货开单:选择供应商、添加商品、付款 +- ✅ 进货退货:向供应商退货 + +**其他收支**: +- ✅ 其他收入:分类、往来单位、结算账户、备注 +- ✅ 其他支出:分类、往来单位、结算账户、备注 +- ✅ 财务分类动态配置 + +#### 2.1.5 明细查询 +- ✅ 时间维度筛选:自定义、本周、今日、本月、本年 +- ✅ 业务类型筛选:销售、进货、收银、资金、盘点 +- ✅ 关键字搜索:单据号、客户/供应商名、品名、备注 +- ✅ 总金额统计 +- ✅ 快速新建单据 + +#### 2.1.6 报表统计 +**资金报表**: +- ✅ 利润统计:按时间分析收入/支出/利润 +- ✅ 销售报表:按客户或按货品维度聚合 +- ⚠️ 营业员统计:待实现 +- ⚠️ 导入导出:待实现 + +**进销存报表**: +- ✅ 销售统计:按商品/客户/时间维度 +- ✅ 进货统计:按商品/供应商/时间维度 +- ✅ 库存统计:当前库存、成本、分布 +- ⚠️ 应收应付对账单:待实现 + +#### 2.1.7 我的(用户中心) +**个人信息**: +- ✅ 查看头像、姓名、手机号、邮箱 +- ✅ 修改头像、姓名 +- ✅ 修改密码 + +**VIP会员**: +- ✅ VIP状态查询(is_vip、expire_at、status) +- ✅ 一键开通VIP(点击即开通,临时方案) +- ✅ 充值记录查询 +- ✅ 数据可见性: + - VIP用户:查看全部历史数据 + - 普通用户:仅显示最近60天数据(可配置) + +**基础管理**: +- ✅ 客户管理:新增、编辑、查询、默认价格等级 +- ✅ 供应商管理:新增、编辑、查询、欠款管理 + +**登录与注册**: +- ✅ 手机号注册/登录 +- ✅ 邮箱+密码登录 +- ✅ 邮箱验证码注册 +- ✅ 忘记密码(邮箱验证码重置) +- ⚠️ 短信验证码登录:后端已实现,前端待接入 +- ⚠️ 微信登录:预留接口,待接入 + +**账号与安全**: +- ✅ 修改个人信息 +- ✅ 修改登录密码 +- ⚠️ 账号注销、退出登录:待实现 + +--- + +### 2.2 管理端功能(平台管理后台) + +#### 2.2.1 用户管理 +- ✅ 用户列表:按shop_id/关键字/分页查询 +- ✅ 编辑用户:姓名、手机、角色、状态 +- ✅ 拉黑/解除拉黑:置用户status=0/1 + +#### 2.2.2 用户配件管理 +- ✅ 配件列表:按shop_id/关键字查询,显示模板信息 +- ✅ 编辑配件:品牌、型号、规格、图片 +- ✅ 配件恢复:取消软删除 + +#### 2.2.3 配件审核 +- ✅ 提交列表:按状态/关键字/时间/店铺筛选 +- ✅ 审核详情:查看完整信息、图片预览 +- ✅ 编辑提交:修改名称、参数、图片、备注 +- ✅ 审核通过:生成products记录、关联图片、记录审核人 +- ✅ 审核驳回:记录驳回原因、审核人、审核时间 +- ✅ Excel导出:按筛选条件导出(限2000条) + +#### 2.2.4 配件模板管理 +- ✅ 模板列表:查看所有模板 +- ✅ 模板详情:查看参数定义 +- ⚠️ 创建模板:后端接口已实现,前端待接入 +- ⚠️ 更新模板:后端接口已实现,前端待接入 +- ✅ 删除模板:软删除(status=0)或强制删除 + +#### 2.2.5 VIP管理 +- ✅ 会员列表:按手机号/分页查询 +- ✅ 价格设置:读取/修改vip_price表 +- ✅ 价格配置接口:`GET/PUT /api/admin/vip/price` +- ⚠️ 新增会员:后端接口已实现,前端待接入 +- ⚠️ 更新会员:后端接口已实现,前端待接入 + +#### 2.2.6 公告管理 +- ✅ 公告列表:按状态/关键字查询 +- ✅ 创建公告:标题、内容、标签、有效期、状态 +- ⚠️ 编辑公告:后端接口已实现,前端待接入 +- ⚠️ 发布/下线:后端接口已实现,前端待接入 + +#### 2.2.7 咨询回复 +- ✅ 咨询列表:按shop_id/状态/关键字查询 +- ✅ 回复咨询:单次回复并自动标记已解决 +- ✅ 查看历史:查看用户历史咨询与回复 + +#### 2.2.8 主数据字典 +- ✅ 主单位维护:新增、编辑、删除(shop_id=0) +- ✅ 主类别维护:新增、编辑、删除(shop_id=0) + +#### 2.2.9 普通管理员审批 +- ⚠️ 申请列表:查看待审核申请 +- ⚠️ 审批通过:赋予normal_admin权限 +- ⚠️ 审批驳回:记录驳回原因 +- ⚠️ 撤销权限:移除normal_admin权限 + +#### 2.2.10 管理员登录 +- ⚠️ 登录接口:后端已实现JWT签发,前端待接入 +- 当前方案:本地存储写入ADMIN_ID临时方案 + +--- + +### 2.3 普通管理端功能(Admin-Lite) + +#### 2.3.1 定位 +- 面向VIP用户申请成为普通管理员后使用 +- 仅包含"配件审核"与"我的"两个功能 +- 使用用户端账号(邮箱+密码)登录 + +#### 2.3.2 配件审核(复用平台管理端逻辑) +- ⚠️ 提交列表:限定为本店数据 +- ⚠️ 审核详情:查看、编辑、图片管理 +- ⚠️ 通过/驳回:操作权限限定本店 +- ⚠️ 导出(可选):按配置开放 + +#### 2.3.3 我的 +- ⚠️ 账户信息:展示VIP状态、普通管理员状态 +- ⚠️ 申请入口:未获批VIP显示"申请成为普通管理员"按钮 +- ⚠️ 退出登录 + +#### 2.3.4 权限规则 +- 申请资格:必须为VIP用户(可配置是否要求有效期内) +- 审批策略: + - 方案A(当前采用):平台审核 + - 方案B:自动通过(通过配置normalAdmin.autoApprove控制) +- 有效性约束:普通管理员权限与VIP状态绑定(可配置) +- 范围隔离:仅可访问所属shop_id的数据 + +--- + +## 三、技术实现 + +### 3.1 后端技术栈 + +**框架与依赖**: +- Spring Boot 3.x +- Spring Data JPA +- Spring Security(JWT) +- MySQL Connector +- Apache POI(Excel导出) +- Java Mail(邮件验证码) + +**项目结构**: +``` +src/main/java/com/example/demo/ +├── account/ # 账户管理 +├── admin/ # 管理端控制器 +├── attachment/ # 附件服务 +├── auth/ # 认证服务(JWT、邮箱、短信、密码) +├── barcode/ # 条码识别代理 +├── common/ # 公共组件(拦截器、异常处理、配置) +├── consult/ # 咨询管理 +├── customer/ # 客户管理 +├── dashboard/ # 首页概览 +├── notice/ # 公告管理 +├── order/ # 订单管理 +├── product/ # 商品管理(含配件提交、模板) +├── report/ # 报表服务 +├── supplier/ # 供应商管理 +├── user/ # 用户管理 +└── vip/ # VIP管理 +``` + +**配置管理**(application.properties): +- 数据库连接:通过环境变量`DB_URL/DB_USER/DB_PASSWORD`注入 +- JWT配置:`jwt.secret`、`jwt.expiresIn` +- 邮件SMTP:`spring.mail.*` +- 附件存储:`attachments.upload.storage-dir` +- VIP配置:`vip.dataRetentionDaysForNonVip`(默认60天) +- 普通管理员:`normalAdmin.autoApprove`(默认false) +- ⚠️ 禁止硬编码:所有配置值必须通过环境变量或配置文件注入 + +**认证与鉴权**: +- JWT Token:用户登录后签发,包含userId、shopId、role +- AdminAuthInterceptor:平台管理端鉴权,支持Bearer Token和X-Admin-Id头 +- NormalAdminAuthInterceptor:普通管理端鉴权,校验role=normal_admin且VIP有效 + +--- + +### 3.2 前端技术栈 + +#### 3.2.1 移动端(uni-app) +**技术选型**: +- HBuilderX +- Vue 2 +- uni-ui组件库 + +**目录结构**: +``` +frontend/ +├── pages/ # 页面 +│ ├── auth/ # 登录注册 +│ ├── index/ # 首页 +│ ├── product/ # 货品管理(含配件提交) +│ ├── order/ # 开单 +│ ├── detail/ # 明细 +│ ├── my/ # 我的 +│ ├── customer/ # 客户 +│ ├── supplier/ # 供应商 +│ ├── account/ # 账户 +│ └── report/ # 报表 +├── components/ # 组件 +│ ├── tabs/ # Tab组件 +│ ├── layout/ # 布局组件 +│ └── ImageUploader/ # 图片上传 +└── common/ # 公共文件 + ├── config.js # 配置(API_BASE_URL、VIP_PRICE等) + ├── http.js # HTTP封装 + ├── constants.js # 常量 + └── navigation.js # 导航 +``` + +**配置管理**(common/config.js): +- API_BASE_URL:优先级:环境变量 > 本地存储 > 默认值 +- SHOP_ID:店铺ID配置 +- ENABLE_DEFAULT_USER:开发模式开关 +- VIP_PRICE_PER_MONTH:VIP价格(默认15元) +- ⚠️ 所有配置禁止硬编码,必须支持环境变量覆盖 + +#### 3.2.2 管理端(Vue 3 + Element Plus) +**技术选型**: +- Vue 3 + Vite +- Element Plus +- Axios + +**目录结构**: +``` +admin/ +├── src/ +│ ├── views/ # 页面 +│ │ ├── admin/ # 用户管理 +│ │ ├── card/ # 卡片 +│ │ ├── consult/ # 咨询 +│ │ ├── dict/ # 字典 +│ │ ├── normal-admin/ # 普通管理员审批 +│ │ ├── notice/ # 公告 +│ │ ├── parts/ # 配件管理(含审核Submissions.vue) +│ │ ├── supplier/ # 供应商 +│ │ ├── users/ # 用户 +│ │ └── vip/ # VIP管理 +│ ├── api/ # API封装 +│ ├── router/ # 路由 +│ └── styles/ # 样式 +└── vite.config.ts # Vite配置 +``` + +**HTTP配置**(src/api/http.ts): +- 自动注入X-Shop-Id、X-Admin-Id、X-User-Id头 +- 优先级:localStorage > 环境变量 > 默认值 + +#### 3.2.3 普通管理端(Vue 3 + Element Plus) +**项目位置**:`normal-admin/` + +**功能**: +- 复制admin项目结构,精简为配件审核+我的两个模块 +- 登录沿用用户端认证接口 +- 鉴权要求:role=normal_admin且VIP有效 + +⚠️ **当前状态**:项目框架已建立,核心功能待实现 + +--- + +### 3.3 数据库设计 + +**核心设计原则**: +1. 多租户隔离:所有业务表包含shop_id +2. 软删除:使用deleted_at标记,查询时过滤 +3. 审计追踪:记录创建人、修改人、时间戳 +4. 外键约束:合理使用,避免过度影响性能 +5. 索引优化:高频查询字段建立复合索引 + +**表分类**: +- 核心业务表(30个):见《数据库设计文档-核心业务表.md》 +- 辅助配置表(16个):见《数据库设计文档-辅助配置表.md》 + +**重要约束**: +- products.barcode:(shop_id, barcode)唯一 +- part_submissions.model_unique:全局唯一 +- users.phone/email:全局唯一 +- accounts.name:(shop_id, name)唯一 +- orders.order_no:(shop_id, order_no)唯一 + +--- + +### 3.4 接口设计 + +**接口文档**:`doc/openapi.yaml` + +**实现状态标注**: +- ✅ Fully Implemented:前后端均已实现并联调通过 +- ❌ Partially Implemented:仅一方实现或未完全联调 + +**核心接口组**: +1. 认证:`/api/auth/*` +2. 用户端:`/api/user/*`、`/api/products/*`、`/api/orders/*`、`/api/vip/*` +3. 平台管理端:`/api/admin/*` +4. 普通管理端:`/api/normal-admin/*` +5. 公共:`/api/notices`、`/api/attachments`、`/api/finance/categories` + +**Header约定**: +- X-Shop-Id:店铺ID(默认1) +- X-User-Id:用户ID(用户端接口必传) +- X-Admin-Id:管理员ID(管理端接口必传) +- Authorization:Bearer Token(JWT方案,逐步迁移) + +--- + +## 四、开发状态 + +### 4.1 已完成功能 + +**后端**: +- ✅ 用户认证体系(邮箱、密码、JWT) +- ✅ 商品管理(CRUD、价格、库存、图片) +- ✅ 配件提交与审核(用户提交、管理员审核、导出) +- ✅ 配件模板管理(CRUD、参数定义) +- ✅ 客户与供应商管理 +- ✅ 订单管理(销售、进货、退货、付款) +- ✅ 财务管理(账户、其他收支、分类配置) +- ✅ 库存流水记录 +- ✅ VIP状态查询与一键开通 +- ✅ VIP充值记录查询 +- ✅ 公告管理(CRUD、发布、下线) +- ✅ 咨询与回复 +- ✅ 主数据字典维护 +- ✅ 附件上传与校验 +- ✅ 首页概览统计 +- ✅ 销售报表(按客户/货品维度) +- ✅ 条码识别代理 + +**移动端(uni-app)**: +- ✅ 登录注册(邮箱密码、邮箱验证码) +- ✅ 首页概览 +- ✅ 商品列表、详情、新增、编辑 +- ✅ 配件提交、列表、详情 +- ✅ 开单(销售、进货、退货、其他收支) +- ✅ 客户与供应商管理 +- ✅ VIP状态查询与开通 +- ✅ 用户信息查看与修改 + +**管理端(Vue3)**: +- ✅ 用户管理(列表、编辑、拉黑) +- ✅ 用户配件管理(列表、编辑、恢复) +- ✅ 配件审核(列表、详情、通过、驳回、导出) +- ✅ 模板列表、详情、删除 +- ✅ VIP列表、价格设置 +- ✅ 公告列表、创建 +- ✅ 咨询列表、回复、历史 +- ✅ 主数据字典维护 + +--- + +### 4.2 待完成功能 + +**高优先级**: +- ⚠️ 管理员登录页(前端) +- ⚠️ 普通管理员审批流程(前后端) +- ⚠️ 普通管理端核心功能(前端) +- ⚠️ 短信验证码登录(前端接入) +- ⚠️ 数据库脚本同步(backend/db/db.sql缺少多个表) + +**中优先级**: +- ⚠️ 公告编辑、发布、下线(前端) +- ⚠️ VIP新增、更新(前端) +- ⚠️ 配件模板创建、更新(前端) +- ⚠️ 账号注销、退出登录 +- ⚠️ 货品设置(隐藏零库存、隐藏进货价) +- ⚠️ 悬浮客服入口 +- ⚠️ 应收应付对账单 +- ⚠️ 营业员统计 + +**低优先级**: +- ⚠️ 微信登录(第三方认证) +- ⚠️ 数据导入导出 +- ⚠️ 多语言支持 +- ⚠️ 公告富文本编辑 +- ⚠️ 操作日志可视化 + +--- + +### 4.3 已知问题 + +#### 4.3.1 严重问题 +1. **数据库脚本不一致**: + - backend/db/db.sql缺少:admins、vip_users、vip_price、vip_recharges、sms_codes、email_codes、normal_admin_audits、consults、consult_replies、notices等表 + - 新环境部署时无法初始化 + - **建议**:立即同步db.sql与线上数据库结构 + +2. **密码默认值泄露**: + - application.properties第14行硬编码数据库密码默认值 + - **建议**:移除默认值,强制要求环境变量 + +3. **unitId字段不一致**: + - Product实体无unitId字段(已移除) + - ProductDtos仍保留unitId + - OpenAPI文档中ProductDetail包含unitId + - **建议**:统一移除所有unitId引用 + +#### 4.3.2 中等问题 +1. **身份验证混乱**: + - 后端支持JWT和X-Admin-Id两种方式 + - 前端仍使用X-Admin-Id + - **建议**:统一迁移到JWT Bearer Token + +2. **OpenAPI状态不准确**: + - 多个已实现接口仍标记为"Partially Implemented" + - **建议**:逐一验证并更新状态 + +3. **配置硬编码**: + - 前端存在多处硬编码fallback值(http://127.0.0.1:8080) + - **建议**:通过配置文件统一管理 + +--- + +## 五、部署与运维 + +### 5.1 环境要求 + +**生产环境**: +- JDK 17+ +- MySQL 8.0+ +- Node.js 16+(前端构建) +- Python 3.8+(可选,条码识别服务) + +**开发环境**: +- IntelliJ IDEA / VS Code +- HBuilderX(uni-app开发) +- Maven 3.8+ +- Git + +--- + +### 5.2 环境变量配置 + +**后端必需**: +```bash +# 数据库 +DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry +DB_USER=root +DB_PASSWORD=<实际密码> + +# JWT +JWT_SECRET=<随机生成的密钥> +JWT_EXPIRES_IN=86400 + +# 邮件(如需邮箱验证码) +MAIL_HOST=smtp.qq.com +MAIL_PORT=465 +MAIL_USERNAME=<邮箱> +MAIL_PASSWORD=<授权码> +MAIL_FROM=<发件邮箱> + +# 附件存储 +ATTACHMENTS_DIR=./data/attachments +ATTACHMENTS_PLACEHOLDER_IMAGE=<占位图路径> + +# VIP配置 +VIP_NONVIP_RETENTION_DAYS=60 +``` + +**前端必需**: +```bash +# 移动端(uni-app) +VITE_APP_API_BASE_URL=https://api.example.com +VITE_APP_SHOP_ID=1 +VITE_APP_VIP_PRICE=15 + +# 管理端(Vue3) +VITE_APP_API_BASE_URL=https://api.example.com +VITE_APP_TITLE=配件查询管理端 +``` + +--- + +### 5.3 部署流程 + +**后端部署**: +```bash +# 1. 打包 +mvn clean package -DskipTests + +# 2. 上传jar包 +scp target/demo-0.0.1-SNAPSHOT.jar user@server:/app/ + +# 3. 启动服务 +java -jar demo-0.0.1-SNAPSHOT.jar \ + --spring.profiles.active=prod \ + --server.port=8080 +``` + +**前端部署**: +```bash +# 移动端(uni-app) +# HBuilderX: 发行 → 原生App-云打包 / 小程序-微信 + +# 管理端(Vue3) +cd admin +npm run build +# 将dist目录上传到Nginx/CDN +``` + +**数据库初始化**: +```bash +# 首次部署执行 +mysql -h mysql.tonaspace.com -u root -p partsinquiry < backend/db/db.sql + +# ⚠️ 注意:当前db.sql不完整,需先同步结构 +``` + +--- + +### 5.4 监控与日志 + +**日志配置**: +- 路径:`./logs/application.log` +- 级别:生产环境INFO,开发环境DEBUG +- 轮转:按日期分割,保留30天 + +**监控指标**: +- 应用健康检查:`/actuator/health` +- JVM内存:通过Spring Boot Actuator暴露 +- 数据库连接池:Hikari监控 +- 接口响应时间:Logback日志记录 + +**告警规则**: +- 数据库连接失败 +- 磁盘空间<10% +- 内存使用>90% +- 接口5xx错误率>1% + +--- + +## 六、开发规范 + +### 6.1 代码规范 + +**后端(Java)**: +- 遵循阿里巴巴Java开发手册 +- 统一使用Lombok简化代码 +- 异常处理:GlobalExceptionHandler统一捕获 +- 日志:使用SLF4J + Logback +- 事务:@Transactional注解,只读操作标记readOnly=true + +**前端(JavaScript/TypeScript)**: +- ESLint规则:推荐配置 +- 组件命名:PascalCase +- 变量命名:camelCase +- 常量命名:UPPER_SNAKE_CASE + +--- + +### 6.2 Git工作流 + +**分支策略**: +- main:生产环境 +- develop:开发环境 +- feature/*:功能分支 +- hotfix/*:紧急修复 + +**提交规范**: +``` +feat: 新功能 +fix: 修复bug +docs: 文档更新 +style: 代码格式调整 +refactor: 重构 +test: 测试相关 +chore: 构建/工具配置 +``` + +**禁止操作**: +- ❌ 直接push到main分支 +- ❌ 提交时跳过hooks(--no-verify) +- ❌ force push到main/master +- ❌ 提交大文件(>10MB) +- ❌ 提交敏感信息(密码、密钥) + +--- + +### 6.3 数据库变更流程 + +1. **本地测试**:在开发环境验证DDL +2. **更新文档**:同步修改`doc/数据库设计文档-*.md` +3. **更新脚本**:同步修改`backend/db/db.sql` +4. **执行变更**:通过MysqlMCP执行线上变更 +5. **验证结果**:查询information_schema确认结构 +6. **更新代码**:修改Entity、DTO、Service +7. **更新接口**:修改`doc/openapi.yaml` + +⚠️ **禁止直接在生产环境手动执行DDL** + +--- + +## 七、测试策略 + +### 7.1 单元测试 + +**覆盖率要求**: +- Service层:>80% +- Controller层:>60% +- Util工具类:>90% + +**测试框架**: +- JUnit 5 +- Mockito +- Spring Boot Test + +### 7.2 集成测试 + +**关键场景**: +- 配件提交 → 审核 → 生成商品 +- 销售开单 → 库存扣减 → 收款 +- VIP开通 → 数据可见性变化 +- 普通管理员申请 → 审批 → 权限生效 + +### 7.3 性能测试 + +**压测指标**: +- 商品列表查询:<200ms(1000并发) +- 订单创建:<500ms(100并发) +- 配件搜索:<300ms(支持全文检索) + +**工具**: +- JMeter +- Apache Bench + +--- + +## 八、后续规划 + +### 8.1 短期(1-2月) + +1. 完成普通管理员审批流程 +2. 前端统一迁移到JWT认证 +3. 同步数据库脚本(db.sql) +4. 补充缺失的前端功能(公告编辑、VIP管理等) +5. 完善单元测试覆盖率 + +### 8.2 中期(3-6月) + +1. 微信登录集成 +2. 数据导入导出功能 +3. 高级报表(利润分析、库龄分析) +4. 消息通知系统(审核结果、VIP到期提醒) +5. 多语言支持(国际化) + +### 8.3 长期(6月+) + +1. 移动端原生性能优化 +2. 大数据量优化(分库分表) +3. 多角色权限体系(RBAC) +4. 第三方支付集成(微信支付、支付宝) +5. 数据分析与BI看板 +6. API开放平台(OAuth2.0) + +--- + +## 九、联系方式 + +**技术支持**: +- 项目地址:C:\Users\21826\Desktop\wj\PartsInquiry +- 数据库:mysql.tonaspace.com:3306 +- 文档路径:doc/ + +**文档维护**: +- 数据库变更后必须更新数据库设计文档 +- 接口变更后必须更新openapi.yaml +- 新功能上线后必须更新本开发文档 + +--- + +**文档修订历史**: +- 2025-10-01:初始版本,整合现有需求与开发状态 + diff --git a/frontend/pages/index/index.vue b/frontend/pages/index/index.vue index 95ce2b6..93c4c51 100644 --- a/frontend/pages/index/index.vue +++ b/frontend/pages/index/index.vue @@ -68,6 +68,12 @@ + + + + 配件查询 + + 常用功能 @@ -268,6 +274,20 @@ }) }, + onPartsSearchTap() { + // 设置标志,让货品列表页打开时自动切换到"查询"Tab并选择"按模板参数查询" + try { + uni.setStorageSync('PRODUCT_SEARCH_CONFIG', JSON.stringify({ + openTab: 'search', + mode: 'template' + })) + } catch(e) { + console.error('[index] 设置存储标志失败:', e) + } + // 跳转到货品列表页(tabBar页面使用switchTab) + uni.switchTab({ url: '/pages/product/list' }) + }, + onIconError(item) { item.img = '' } @@ -324,6 +344,34 @@ page { .notice-text { color: $uni-text-color; font-size: 28rpx; line-height: 36rpx; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .notice-tag { color: $uni-color-primary; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx; background: rgba(76,141,255,0.18); } + /* 配件查询按钮 */ + .parts-search-btn { + margin: 0 24rpx 20rpx; + padding: 24rpx 32rpx; + border-radius: 20rpx; + background: linear-gradient(135deg, #4C8DFF 0%, #3d73e6 100%); + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + box-shadow: 0 8rpx 20rpx rgba(76,141,255,0.25); + transition: all 0.2s ease; + } + .parts-search-btn-active { + opacity: 0.85; + transform: scale(0.98); + } + .parts-search-icon { + width: 52rpx; + height: 52rpx; + filter: brightness(0) invert(1); + } + .parts-search-text { + color: #fff; + font-size: 34rpx; + font-weight: 800; + letter-spacing: 2rpx; + } /* 分割标题 */ .section-title { display: flex; align-items: center; gap: 16rpx; padding: 10rpx 28rpx 0; flex: 0 0 auto; } diff --git a/frontend/pages/my/index.vue b/frontend/pages/my/index.vue index ade6850..787ee06 100644 --- a/frontend/pages/my/index.vue +++ b/frontend/pages/my/index.vue @@ -155,6 +155,15 @@ export default { this.shopName = storeName const phone = profile?.phone || uni.getStorageSync('USER_MOBILE') || '' this.mobile = phone + // 保存邮箱到本地存储 + const email = profile?.email || '' + try { + if (email) { + uni.setStorageSync('USER_EMAIL', email) + } else { + uni.removeStorageSync('USER_EMAIL') + } + } catch(_){} } catch(e) { try { const storeName = uni.getStorageSync('SHOP_NAME') || '' diff --git a/frontend/pages/product/list.vue b/frontend/pages/product/list.vue index 267edab..4f33782 100644 --- a/frontend/pages/product/list.vue +++ b/frontend/pages/product/list.vue @@ -65,7 +65,8 @@ - + + @@ -99,6 +100,27 @@ export default { onShow() { const hasToken = (() => { try { return !!uni.getStorageSync('TOKEN') } catch(e){ return false } })() if (!hasToken) return + + // 检查是否需要打开查询Tab并设置查询模式(从首页"配件查询"按钮进入) + try { + const configStr = uni.getStorageSync('PRODUCT_SEARCH_CONFIG') + if (configStr) { + const config = JSON.parse(configStr) + // 切换到指定Tab + if (config.openTab) { + this.tab = config.openTab + } + // 设置查询模式 + if (config.mode) { + this.query.mode = config.mode + } + // 清除标志,避免下次进入时再次切换 + uni.removeStorageSync('PRODUCT_SEARCH_CONFIG') + } + } catch(e) { + console.error('[list] 处理查询配置失败:', e) + } + // 从创建/编辑页返回时,确保刷新最新列表 this.reload() }, @@ -211,7 +233,10 @@ export default { } catch (e) { uni.showToast({ title: '删除失败', icon: 'none' }) } - } + }, + goSubmit() { + uni.navigateTo({ url: '/pages/product/submit' }) + } } } @@ -241,7 +266,7 @@ export default { .card-params .param { color:$uni-text-color-grey; font-size: 22rpx; background:$uni-bg-color-grey; padding: 2rpx 6rpx; border-radius: 8rpx; } .price { margin-left: 20rpx; color:$uni-color-primary; } .empty { height: 60vh; display:flex; align-items:center; justify-content:center; color:$uni-text-color-grey; } -.fab { position: fixed; right: 30rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; background:$uni-color-primary; color:#fff; border-radius: 50rpx; text-align:center; line-height: 100rpx; font-size: 48rpx; box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15); } +.fab { position: fixed; right: 30rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; background: linear-gradient(135deg, #4c8dff, #6ab7ff); color: #fff; border-radius: 50rpx; display: flex; align-items: center; justify-content: center; font-size: 48rpx; box-shadow: 0 20rpx 40rpx rgba(0,0,0,0.2); } diff --git a/frontend/pages/product/product-detail.vue b/frontend/pages/product/product-detail.vue index 3002977..5036feb 100644 --- a/frontend/pages/product/product-detail.vue +++ b/frontend/pages/product/product-detail.vue @@ -120,24 +120,134 @@ export default { diff --git a/frontend/pages/product/submit.vue b/frontend/pages/product/submit.vue index d67abbd..77120d8 100644 --- a/frontend/pages/product/submit.vue +++ b/frontend/pages/product/submit.vue @@ -1,10 +1,5 @@